Server Mode — Setup
End-to-end setup for mode="server". In server mode the browser talks only
to your server: it holds the storage credentials and the cloud-drive OAuth
secrets, and it is the trust boundary every upload passes through.
This page is the hub — the config object, the routes, the limits, the hooks, and the observability seam. The per-framework mount recipes live on their own pages, linked from Framework adapters below.
Rough time budget: 15–30 minutes including provider OAuth registration.
1. Install
pnpm add @upupjs/react @upupjs/server@upupjs/server is the Node-side handler. The React package has no
dependency on it — your client bundle stays free of S3 SDKs.
2. Mount the handler
The example below is the Next.js App Router; every other framework takes the same config object through a one-line adapter.
// app/api/upup/[...route]/route.ts
import { createUpupHandler, InMemoryTokenStore } from '@upupjs/server'
const handler = createUpupHandler({
storage: {
type: 'aws',
bucket: process.env.S3_BUCKET!,
region: process.env.S3_REGION!,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
// Required, server-only: a stable, high-entropy secret (min 16 chars),
// shared across every server instance. createUpupHandler throws without it.
uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!,
providers: {
googleDrive: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
dropbox: {
appKey: process.env.DROPBOX_APP_KEY!,
appSecret: process.env.DROPBOX_APP_SECRET!,
},
// oneDrive, box — same shape
},
tokenStore: new InMemoryTokenStore(), // swap for Redis in prod
getUserId: async req => {
// Resolve the session user. Return null → OAuth 401s.
const session = await getSessionFromCookie(req)
return session?.userId ?? null
},
})
export const GET = handler
export const POST = handlergetUserId (or auth) is required once you set providers or tokenStore
— it scopes tokens per user, and without it the upload routes return 403 AUTH_REQUIRED. See Server auth.
The handler routes on path suffix: /presign, /multipart/*,
/auth/:provider, /auth/:provider/cb, /files/:provider,
/files/:provider/transfer, /health. All paths are relative to the
folder you mount it at.
Running Express, Fastify, Hono, the Pages Router, or a bare Node server instead? See Framework adapters below — same config object, one adapter call each.
3. Point the uploader at it
<UpupUploader
mode="server"
serverUrl="/api/upup"
provider="aws"
sources={['local', 'googleDrive', 'dropbox']}
/>No cloud-drive clientId props needed on the client — the server
holds them.
4. Register OAuth apps
For each drive you enable:
| Provider | Console | Callback URL |
|---|---|---|
| Google Drive | console.cloud.google.com → APIs → OAuth 2.0 Client IDs | https://yourapp.com/api/upup/auth/google-drive/cb |
| OneDrive | portal.azure.com → App registrations | https://yourapp.com/api/upup/auth/one-drive/cb |
| Dropbox | www.dropbox.com/developers/apps | https://yourapp.com/api/upup/auth/dropbox/cb |
| Box | app.box.com/developers/console | https://yourapp.com/api/upup/auth/box/cb |
Scopes required:
- Google:
https://www.googleapis.com/auth/drive.readonly - OneDrive:
Files.Read.All offline_access(offline_accessis what makes Microsoft return a refresh token — without it, sessions die when the access token expires) - Dropbox:
files.content.read files.metadata.read - Box:
root_readonly
5. Production token store
InMemoryTokenStore is a reference implementation. Replace with any
KV-shaped store for production:
// Redis example
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
const tokenStore = {
async get(key) {
return (await redis.get(key)) ?? null
},
async set(key, value, ttlSeconds) {
if (ttlSeconds) await redis.setex(key, ttlSeconds, value)
else await redis.set(key, value)
},
async delete(key) {
await redis.del(key)
},
}The contract is three strings-in-strings-out methods. Cloudflare KV, DynamoDB, Postgres — anything shaped like this works.
6. Tuning
createUpupHandler({
// ...storage, uploadTokenSecret, and providers from step 2
maxFileSize: 500 * 1024 * 1024, // 500 MB
allowedTypes: ['image/*', 'video/*'],
hooks: {
onBeforeUpload: async (file, req) => {
// Return false to reject the upload
return true
},
onFileUploaded: async (file, req) => {
// Persist a DB row pointing at file.key
},
},
})The server→S3 multipart cutoff on the cloud-drive transfer path is
fixed and not configurable: files up to 5 MiB stream through as a single
PUT; larger files use S3 multipart with 5 MiB chunks. Server memory
envelope is one chunk at a time regardless of file size — the old
multipartThreshold knob was removed so the memory bound cannot be raised
away by configuration.
7. Large files: the multipart flow
By default a file is uploaded whole: POST /presign hands the browser one
signed URL and the bytes go straight to storage. Turn on multipart
(client-side resumable: { protocol: 'multipart' }, threshold 5 MiB by
default — see Resumable uploads) and the
browser drives the four-step lifecycle above against your server instead:
POST /multipart/init— the server starts the S3 upload and returns the object key, theuploadId, the part size, and an HMAC-signed token. The token binds the key, theuploadId, the owning user, an expiry, and a size envelope. It is the only state the client carries; the server keeps no session.POST /multipart/sign-part— one call per part, sending the token and a part number, answered with a presigned URL for exactly that part.- The part
PUTs go browser → storage directly. Part bytes never traverse your server on this path, so a 5 GB upload costs it nothing but the signing calls. Parts are 5 MiB by default and floored at S3's 5 MiB minimum; the browser uploads several in parallel and collects each part'sETag. POST /multipart/complete— the server sums the bytes S3 actually received, rejects and aborts the upload with403if the total falls outside the token's signed envelope, and only then finalizes the object. Cancelled or failed runs callPOST /multipart/abort.
Two consequences worth knowing:
- Any instance can serve any step. The signed token replaces
server-side session state, so a load-balanced or serverless deployment
works as long as every instance shares the same
uploadTokenSecret. Different secrets across instances is the classic mid-upload failure — the/healthfingerprint below catches it. - The token's owner is enforced, not just its signature. With
getUserIdconfigured,sign-part,complete, andabortall check that the caller is the user the token was issued to and answer403 AUTH_DENIEDotherwise. Without agetUserIdresolver, possession of the token is the model.
This is a different path from the cloud-drive transfer described in Limits, where the server itself pulls bytes from a drive and pushes them to S3.
8. Re-authentication
When an OAuth access token expires, the server returns
401 { reauth: true }. The React component catches this and surfaces
the provider's "Sign in" button. One click re-auths and the user
continues where they left off.
Re-auth is the fallback, not the routine: when the provider issued a
refresh token, the server stores it alongside the access token (as a
no-expiry entry in tokenStore) and refreshes proactively before drive
calls, so users rarely see the prompt. The reauth: true path fires
when there is no refresh token or the refresh itself fails.
Framework adapters
createUpupHandler is a plain (req: Request) => Promise<Response>
function. Every adapter wraps that same handler and takes the same config
object — the one you built in step 2.
| Framework | Entry point | Import from |
|---|---|---|
| Express | createUpupMiddleware | @upupjs/server/express |
| Fastify | createUpupPlugin | @upupjs/server/fastify |
| Hono | createUpupRoutes | @upupjs/server/hono |
| Next.js — App Router | createUpupNextHandler | @upupjs/next/server |
| Next.js — Pages Router | createUpupPagesHandler | @upupjs/next/server |
Each page carries the complete mount recipe for that framework plus its own body-parsing, proxy-origin, and CORS notes — those genuinely differ, and getting them wrong is the most common first-run failure.
One config, every adapter
Define the config once and share it:
// lib/upup-config.ts
import type { UpupServerConfig } from '@upupjs/server'
export const upupConfig: UpupServerConfig = {
storage: {
type: 'aws',
bucket: process.env.S3_BUCKET!,
region: process.env.S3_REGION!,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!,
// providers, tokenStore, getUserId… as in step 2
}In a Next.js app you can author the same object through
defineUpupConfig from @upupjs/next/server for editor autocomplete —
it's a typed pass-through, not a second validation layer (required-field
validation always happens inside createUpupHandler, so direct callers
are protected too).
Custom Node server
Any Node framework without a dedicated adapter can reuse the same bridge
the Express, Fastify, and Pages Router adapters are built on —
toWebRequest and writeWebResponse from @upupjs/server/node-bridge.
Don't hand-roll the conversion; the bridge already handles multi-value
headers and skips content-length (Node recomputes it, and a copied value
risks a mismatch).
import { createServer } from 'node:http'
import { createUpupHandler } from '@upupjs/server'
import { toWebRequest, writeWebResponse } from '@upupjs/server/node-bridge'
import { upupConfig } from './lib/upup-config'
const handler = createUpupHandler(upupConfig)
createServer(async (req, res) => {
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(Buffer.from(chunk))
const webReq = toWebRequest({
url: new URL(req.url ?? '/', `http://${req.headers.host}`).toString(),
method: req.method ?? 'GET',
headers: req.headers,
// toWebRequest drops the body for GET/HEAD on its own.
body: chunks.length ? Buffer.concat(chunks) : undefined,
})
await writeWebResponse(
{
status: code => {
res.statusCode = code
},
setHeader: (name, value) => {
res.setHeader(name, value)
},
send: body => {
res.end(body)
},
},
await handler(webReq),
)
}).listen(3000)The sink is three methods — status, setHeader, send — so Express's
res, Fastify's reply, and NextApiResponse all satisfy it with a thin
rename.
Lifecycle hooks
Three optional hooks let you gate uploads and react to completions:
createUpupHandler({
// ...storage, uploadTokenSecret
hooks: {
onBeforeUpload: async (file, req) => true, // false rejects with 403
onFileUploaded: async (file, req) => {
// one file finished — file.key, .name, .size, .type, .url
},
onUploadComplete: async (files, req) => {
// a request's file(s) finished
},
},
})Which hook fires on which path. Read this before wiring alerting, billing, or webhooks on top of them — the gaps are structural, not bugs:
| Route | onBeforeUpload | onFileUploaded | onUploadComplete |
|---|---|---|---|
POST /presign | yes | no | no |
POST /multipart/init | yes | no | no |
POST /multipart/complete | no | yes | yes |
POST /files/:provider/transfer | no | yes | no |
onBeforeUploadis an admission gate, not a completion signal. It runs during metadata validation on/presignand/multipart/init, after themaxFileSizeandallowedTypeschecks. Returningfalseresponds403 Upload rejectedand nothing is presigned.onFileUploadedfires once per file on the two server-side-completion paths only:/multipart/complete(the server just finished the S3 multipart upload) and/files/:provider/transfer(the server just finished streaming a cloud-drive file into S3). In both cases the server can actually see the finished object.onUploadCompletefires only on/multipart/complete, always with a single-element array. The server completes one file per request and has no cross-file batching concept. For a true "the whole batch is done" signal, use the client-sideonUploadCompleteprop instead — that one sees the entire selection.- Client-direct presigned PUTs fire no server hook at all.
POST /presignonly hands the browser a URL; the bytes then go straight to S3 and the server never observes completion. If you need server visibility into that path, use the client-sideonUploadCompleteprop, or pointprocessingEndpointat an SSE route so the client tells your server when it's done. - On the multipart-complete path,
file.typeis always''— the declared MIME type isn't retained server-side once the upload completes.
A hook that throws after a successful upload is reported through
onError and swallowed, never re-coded as a 500. The object is already
durably in S3, so a 500 would only tell the client to retry something that
already succeeded.
Observability
The onError seam
Every error path in the handler — 500s, invalid upload tokens, OAuth and token-exchange failures, failed drive-transfer aborts, health-check storage failures — routes through one logger:
createUpupHandler({
// ...storage, uploadTokenSecret
onError: event => {
// event: route, method, status, code, message, requestId,
// error: { name, message, stack }
myLogger.error('upup-server', event)
},
})If you don't supply onError, the default writes one structured line via
console.error('[upup:server]', JSON.stringify(event)) — error visibility
is on out of the box, not something you wire up after your first incident.
Pass a no-op to silence it.
Redaction guarantee. An error event is only ever built from a static
route string, the request method, the HTTP status, a machine code, a
generic message, and the caught error's name / message / stack.
Request bodies, drive tokens, uploadTokenSecret, S3 credentials,
signatures, and Authorization headers are never put into an event.
That contract is enforced structurally, not by convention: every event
passes through a scrubber at the single reporting seam before it reaches
your logger, so even an error whose message accidentally interpolated a
credential is cleaned. It rewrites Authorization header dumps, bare
Bearer tokens, the SigV4 x-amz-signature / x-amz-credential /
x-amz-security-token params, and AWS access-key ids to [REDACTED].
It's deliberately conservative — ordinary stack frames, file paths, and
function names survive intact. Treat it as defense in depth, not licence to
build an event out of a secret.
Request IDs
Every response the handler produces carries an x-upup-request-id header,
and the same value appears as event.requestId in your onError events.
That's the join key between a client-reported failure and your server logs —
health responses included; there are no exceptions to the contract.
The /health endpoint
curl https://yourapp.com/api/upup/health{
"status": "ok",
"checks": { "config": "ok", "storage": "ok" },
"summary": {
"storageType": "aws",
"anonymousUploads": false,
"anonymousDrives": false,
"driveProviders": 2,
"uploadTokenTtlSeconds": 3600
}
}It's unauthenticated by design — the check runs before config.auth, so
uptime and deploy probes work without credentials — and always responds
200. The status field carries the real signal, so an orchestrator won't
restart a container over a transient S3 blip.
checks.configisokwhenstorage.bucket,storage.region, and a valid-lengthuploadTokenSecretare all present.checks.storageis a cheap bucket-head probe (no listing, no transfer), cached for 30 seconds so repeated polling doesn't hammer S3. A failed probe is also reported throughonError.summaryis labels, flags, and counts only — never a secret value.
To catch cross-instance secret drift after a rolling deploy, opt into a fingerprint:
createUpupHandler({
// ...storage, uploadTokenSecret
health: { exposeSecretFingerprint: true },
})That adds uploadTokenFingerprint — the first 8 hex characters of
SHA-256(uploadTokenSecret). Two instances showing different fingerprints
are running different secrets, which breaks multipart uploads that start on
one and continue on another. It's a one-way hash, not the secret. Default is
off.
Full request/response shapes for every route: Server HTTP API. For client-side error wiring, see Error monitoring.
Limits
maxFileSize
Enforced on both upload paths, and on the drive path it's enforced twice:
POST /presignandPOST /multipart/initreject a declared size over the limit with413 File too largebefore anything is signed.POST /files/:provider/transferfast-rejects the drive-declared size with413, then enforces the cap again against the bytes actually streamed. A file that lies about its size is aborted mid-transfer and leaves nothing behind in the bucket — the streamed-byte check is the authoritative one.
allowedTypes
An allowlist of MIME types; 415 File type not allowed otherwise. One shared
policy across the upload and drive-transfer paths:
- Omitted or empty → every type passes.
- An
image/*entry honours the wildcard. - An absent or empty type does not match a non-empty allowlist. A file with no declared MIME type is rejected, never silently waved through.
These are server-side policy. The client-side maxFileSize / file-type props
give the user a fast local error; the server checks are what actually hold,
since a client can be bypassed.
The drive-transfer memory bound
When the server pulls a file out of a cloud drive and pushes it to S3, its memory envelope is fixed at 5 MiB regardless of file size. A file whose size the drive reports as 5 MiB or less goes through as a single PUT (one buffered body); everything else — including anything the drive reports no size for — streams through bounded 5 MiB multipart parts, one part in memory at a time.
This cutoff is not configurable, deliberately. The old
multipartThreshold knob was removed because raising it reintroduced
unbounded buffering — a memory-safety bound must not be something an
integrator can raise away. There is no replacement setting; a 5 GB drive file
and a 5 MB one cost the server the same memory.