File upload server with Hono and S3
Hono is web-native: its request and response objects are the platform
Request and Response. createUpupHandler has that exact signature
already, so the Hono adapter is the thinnest of the four — it hands the
handler back unwrapped. See
Server mode setup for what each config
option does.
1. Install
pnpm add @upupjs/server2. Share one config
Author the config once and import it wherever you mount:
// 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,
},
// Required, server-only: stable, high-entropy, min 16 chars, shared
// across every instance. createUpupHandler throws without it.
uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!,
getUserId: async req => {
const session = await getSessionFromCookie(req)
return session?.userId ?? null
},
}3. Mount the routes
import { Hono } from 'hono'
import { createUpupRoutes } from '@upupjs/server/hono'
import { upupConfig } from './lib/upup-config'
const routes = createUpupRoutes(upupConfig)
const app = new Hono()
app.all('/api/upup/*', c => routes(c.req.raw))
export default appcreateUpupRoutes(config) returns a plain
(req: Request) => Promise<Response>. Hand it the raw Request off the
context (c.req.raw) and return its Response — no bridging, no
serialization, nothing to convert.
Use app.all and keep the trailing /*: the handler dispatches on the
path suffix beneath your prefix (/presign, /multipart/init,
/auth/:provider, …), and it needs GET, POST, and OPTIONS on all of
them. Then point the uploader at the same prefix, minus the wildcard:
<UpupUploader mode="server" serverUrl="/api/upup" provider="aws" />Body parsing
There is none to configure. The handler reads the request body itself off
the Web Request — do not call c.req.json() in a middleware ahead of it,
because a consumed body stream cannot be read twice and the POST routes
would fail with 400 Invalid JSON body.
The file bytes never flow through your Hono app on the presigned paths: the browser gets a signed URL and PUTs straight to storage.
Edge and non-Node runtimes
Two things to check before deploying to Workers, Deno, or Bun.
Credentials arrive per request on some runtimes. The snippet above
builds the handler once at module scope, which is right on Node, Bun, and
Deno where process.env is populated at startup. On Cloudflare Workers,
secrets are bindings on c.env and only exist inside a request, so build
the handler on first use and cache it:
import { Hono } from 'hono'
import { createUpupRoutes } from '@upupjs/server/hono'
import type { UpupServerConfig } from '@upupjs/server'
type Env = {
Bindings: {
R2_BUCKET: string
R2_ENDPOINT: string
R2_ACCESS_KEY_ID: string
R2_SECRET_ACCESS_KEY: string
UPUP_UPLOAD_TOKEN_SECRET: string
}
}
const app = new Hono<Env>()
let routes: ReturnType<typeof createUpupRoutes> | undefined
app.all('/api/upup/*', c => {
routes ??= createUpupRoutes({
storage: {
type: 'r2',
bucket: c.env.R2_BUCKET,
// R2 is account-scoped and its region is the literal 'auto'.
region: 'auto',
endpoint: c.env.R2_ENDPOINT,
accessKeyId: c.env.R2_ACCESS_KEY_ID,
secretAccessKey: c.env.R2_SECRET_ACCESS_KEY,
},
uploadTokenSecret: c.env.UPUP_UPLOAD_TOKEN_SECRET,
} satisfies UpupServerConfig)
return routes(c.req.raw)
})
export default appCaching matters for more than speed: createUpupRoutes runs the
construct-time validation (missing bucket, short uploadTokenSecret,
a non-S3 storage.type) and throwing that per request would turn a
config mistake into a stream of 500s instead of one loud boot failure.
@upupjs/server signs and completes uploads through @aws-sdk/client-s3. A
non-Node runtime therefore needs its Node-compatibility layer enabled — on
Cloudflare Workers that is the nodejs_compat flag in wrangler.toml.
Confirm it against your runtime before your first deploy.
CORS
Same-origin deployments need no CORS config. For cross-origin ones, set it
on the upup config rather than adding Hono's cors() middleware in front —
the handler attaches the headers itself, on every response including the
preflight OPTIONS (204), and two layers of CORS on one route is a
conflict waiting to happen:
export const upupConfig: UpupServerConfig = {
// ...storage, uploadTokenSecret
cors: {
allowedOrigins: ['https://app.example.com'],
allowedMethods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAgeSeconds: 600,
},
}Credentialed CORS is granted only on a concrete origin match. A config whose
allowedOrigins is just ['*'] gets public, non-credentialed CORS — the
server-mode drive client sends credentials: 'include', so cloud drives
will fail against a wildcard-only allowlist. Enumerate your real app
origins.
Verify the mount
curl http://localhost:8787/api/upup/healthA JSON body with "status": "ok" and checks.config / checks.storage
means the routes are mounted and the storage credentials resolve. The route
is unauthenticated by design and always answers 200 — read the status
field, not the HTTP code.
Related
- Server mode setup — every
createUpupHandleroption, hooks, limits, and observability. - Server auth & trust model — why
uploadTokenSecretis mandatory and what forged requests get. - Storage providers — S3, R2, MinIO, B2, Spaces, Wasabi configs, and Cloudflare R2 for the endpoint used above.