uploads
Avatars, signed invoices, CSV imports: sooner or later an app takes files from people. @kitr/uploads does the whole round trip. The browser checks size and type before sending and shows progress. The server checks again, stores the bytes through a driver (disk by default, or your own for S3 and R2) and answers with a file record you save in your database.
It runs on both sides: @kitr/uploads and @kitr/uploads/react in the browser, @kitr/uploads/server on the server. It handles files people send to your app. To read and write files on the user's own device, use files instead.
This guide follows Acme's order screen at app.acme.com, where Grace attaches the signed invoice PDF to order ord_1042.
Install
kitr add uploadsFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
What kitr add does:
- Adds the server package first if the project does not have it yet.
- Installs
@kitr/uploads(you use it through@kitr/uploads,@kitr/uploads/reactand@kitr/uploads/server). - Adds
() => import('@kitr/uploads/provider')toprovidersinkitrrc.ts. - Adds
'@kitr/uploads'topackagesinkitrrc.ts. - Writes
config/uploads.ts(shown under Configure). An existing file is left alone. - Adds
import { mountUploads } from '@kitr/uploads/server'andmountUploads(server)toserver/main.tswhen that file exists. This registersPOST /api/uploadsto receive files andGET /api/uploads/**to serve them, and raises the server's body limit on that path to matchmaxSize. - Regenerates
config/index.ts, the config list the standalone server imports. - Adds a
## kitr: uploadssection toAGENTS.md. In a React app it says to upload withuseUpload()and check files withcheckFile(file, rules); in a server-only app it says to callctx.app.container.use('uploads').receive(event)in a controller.
kitr add uploads asks no questions. Pass --dry-run to print the changes first, or --no-install to skip the package manager run. No kit includes uploads.
kitr remove uploads removes the provider, the package entry, the dependency, config/uploads.ts and the AGENTS.md section. It leaves the mountUploads line in server/main.ts and the files under storage/uploads.
Configure
kitr add uploads writes this file:
// config/uploads.ts
import { defineConfig } from '@kitr/uploads'
export default defineConfig({
maxSize: 10 * 1024 * 1024,
accept: ['image/*', 'application/pdf'],
// Files go to storage/uploads and are served by mountUploads() under publicPath. Pass a driver for S3/R2.
publicPath: '/api/uploads',
})
| Option | Default | Description |
|---|---|---|
maxSize |
10485760 (10 MiB) |
Largest file in bytes. Checked per file. |
accept |
anything | Allowed types: exact MIME types (application/pdf), prefixes (image/*) or extensions (.pdf, case-insensitive). |
maxFiles |
1 |
Most files in one request. |
driver |
disk, under storage/uploads |
Where bytes go. See Drivers. |
publicPath |
none | URL prefix for the url of each stored file, such as /api/uploads. Without it (and without a driver url), files have no url. |
Uploads adds no environment variables. The disk driver's directory is relative to the process's working directory.
Raise the server's body limit
The server refuses any request body over its limit, 1 MiB by default, before uploads sees it. mountUploads(server) raises the limit for its own path: it adds maxSize × maxFiles, plus 64 KiB per file for the multipart framing, to the server's bodyLimits under /api/uploads. With the generated config, a 3 MB invoice posted to /api/uploads reaches the upload rules, and an 11 MB one gets the uploads size message.
Your own upload routes, such as a controller at /api/orders/:orderId/invoice, keep the server's 1 MiB limit, and a larger file gets 413 PAYLOAD_TOO_LARGE. Raise the limit for that path prefix in config/server.ts. The longest matching prefix wins:
// config/server.ts
import { defineConfig } from '@kitr/server'
export default defineConfig({
healthPath: '/healthz',
bodyLimits: { '/api/orders': 11 * 1024 * 1024 },
})
Upload your first file
With kitr add uploads done, the server already accepts files at POST /api/uploads. Set publicPath: '/api/uploads' as shown under Configure, so the returned url works. Then add a component that sends a file:
// app/components/orders/invoice-upload.tsx
import { useUpload } from '@kitr/uploads/react'
export function InvoiceUpload() {
const { send, progress, uploading, error, files } = useUpload({
accept: ['application/pdf'],
maxSize: 10 * 1024 * 1024,
})
return (
<div>
<input type="file" accept="application/pdf" onChange={(e) => send(e.target.files).catch(() => {})} />
{uploading ? <progress value={progress} max={100} /> : null}
{files[0] ? <a href={files[0].url}>{files[0].name}</a> : null}
{error ? <p role="alert">{error.message}</p> : null}
</div>
)
}
Grace picks invoice-1042.pdf. The hook checks it against the rules, posts it as multipart/form-data to /api/uploads, and updates progress from 0 to 100 as the bytes go out. The server checks the rules again, writes the file to storage/uploads/<id>/invoice-1042.pdf, and answers:
{
"files": [
{
"id": "72eb7790-7c62-4c37-9167-d9989b7ac10f",
"key": "72eb7790-7c62-4c37-9167-d9989b7ac10f/invoice-1042.pdf",
"name": "invoice-1042.pdf",
"size": 48213,
"type": "application/pdf",
"url": "/api/uploads/72eb7790-7c62-4c37-9167-d9989b7ac10f/invoice-1042.pdf"
}
]
}
files in the hook holds that array, so the link appears. send also returns it. It rethrows failures, which is why the onChange above ends with .catch(() => {}): the hook already shows the error in error.
Track progress and cancel
In a component, useUpload(options) returns the state and three functions:
| Field | Description |
|---|---|
send(input) |
Takes a FileList, an array of File, one File or null. Checks, uploads, resolves with the stored files. null or an empty list resolves with []. |
cancel() |
Aborts the request in flight. error becomes Upload cancelled. |
reset() |
Clears files, progress and error. |
files |
The stored files from the last successful upload. |
progress |
Percent sent, 0 to 100. |
uploading |
true while the request is in flight. |
error |
The last failure, or null. |
A progress indicator with a cancel button:
// app/components/orders/attachment-upload.tsx
import { formatBytes } from '@kitr/uploads'
import { useUpload } from '@kitr/uploads/react'
const MAX = 25 * 1024 * 1024
export function AttachmentUpload() {
const upload = useUpload({ maxSize: MAX, maxFiles: 3 })
return (
<div>
<input type="file" multiple onChange={(e) => upload.send(e.target.files).catch(() => {})} />
<p>Up to 3 files, {formatBytes(MAX)} each.</p>
{/* => "Up to 3 files, 25.0 MB each." */}
{upload.uploading ? (
<>
<progress value={upload.progress} max={100} />
<button type="button" onClick={upload.cancel}>Cancel</button>
</>
) : null}
</div>
)
}
Calling send again while an upload runs aborts the first one. maxFiles in the hook's options is sent nowhere and checked nowhere in the browser; the server enforces it.
Upload without React
upload(files, options) from @kitr/uploads is the same request without the hook. Use it in a store, an action or plain script code:
// app/lib/orders/attach-invoice.ts
import { upload, UploadError } from '@kitr/uploads'
export async function attachInvoice(orderId: string, file: File) {
const controller = new AbortController()
try {
const [stored] = await upload(file, {
url: `/api/orders/${orderId}/invoice`,
field: 'invoice',
fields: { orderId },
headers: { 'x-csrf-token': document.querySelector<HTMLMetaElement>('meta[name=csrf]')?.content ?? '' },
onProgress: ({ loaded, total, percent }) => console.log(`${loaded}/${total} (${percent}%)`),
signal: controller.signal,
})
return stored
} catch (error) {
if (error instanceof UploadError) console.warn(error.status, error.message)
throw error
}
}
| Option | Default | Description |
|---|---|---|
url |
'/api/uploads' |
The endpoint to post to. |
field |
'file' |
The form field the files go in. The server reads the same field. |
fields |
none | Extra form fields sent before the files. |
headers |
none | Extra request headers, such as a CSRF token. |
onProgress |
none | Called with { loaded, total, percent } as bytes go out. |
signal |
none | An AbortSignal that cancels the upload. |
credentials |
true |
Send cookies with the request. false omits them. |
In a browser, upload uses XMLHttpRequest, the only browser API that reports upload progress. Where there is no XMLHttpRequest (Node, a test), it uses fetch and onProgress is not called. It resolves with the response's files array, or with the body itself wrapped in an array when the endpoint returns one file record.
Check a file before sending
checkFile(file, rules) runs the browser half of the rules and returns a message, or null when the file passes. useUpload calls it for you; call it yourself to show the problem as soon as a file is picked:
// app/lib/orders/check-invoice.ts
import { checkFile } from '@kitr/uploads'
const scan = new File([new Uint8Array(12 * 1024 * 1024)], 'scan.png', { type: 'image/png' })
checkFile(scan, { maxSize: 10 * 1024 * 1024 })
// => 'scan.png is larger than 10.0 MB'
checkFile(new File(['png'], 'logo.png', { type: 'image/png' }), { accept: ['application/pdf'] })
// => 'logo.png is not an accepted type'
checkFile(new File(['%PDF-1.7'], 'invoice-1042.pdf', { type: 'application/pdf' }), { accept: ['.pdf'] })
// => null
checkFile uses a 10 MiB maxSize when you pass none, like the server. It does not check maxFiles. Import the rules from one place so both sides agree, as shown in Share rules between client and server.
accepts(type, name, accept) is the type test on its own, and formatBytes(bytes) formats sizes for hints:
// app/lib/orders/accept-hint.ts
import { accepts, formatBytes } from '@kitr/uploads'
accepts('image/png', 'logo.png', ['image/*'])
// => true
accepts('application/octet-stream', 'Invoice.PDF', ['.pdf'])
// => true
accepts('application/pdf', 'invoice.pdf.exe', ['.pdf'])
// => false
formatBytes(2048)
// => '2 KB'
formatBytes(10 * 1024 * 1024)
// => '10.0 MB'
Share rules between client and server
The browser check is for people; the server check is for safety. Keep both from drifting by putting the rules in one module that a component and a controller both import:
// app/lib/orders/invoice-rules.ts
import type { UploadRules } from '@kitr/uploads'
export const invoiceRules: UploadRules = {
maxSize: 10 * 1024 * 1024,
accept: ['application/pdf', '.pdf'],
maxFiles: 1,
}
// app/components/orders/invoice-field.tsx
import { useUpload } from '@kitr/uploads/react'
import { invoiceRules } from '#app/lib/orders/invoice-rules'
export function InvoiceField({ orderId }: { orderId: string }) {
const { send, error } = useUpload({ ...invoiceRules, url: `/api/orders/${orderId}/invoice` })
return (
<label>
Signed invoice
<input type="file" accept=".pdf" onChange={(e) => send(e.target.files).catch(() => {})} />
{error ? <span role="alert">{error.message}</span> : null}
</label>
)
}
The controller that receives it is in the next section.
Warning Never rely on the browser check alone. Anyone can post to the endpoint directly, with any size and any declared type. The server's
receiveis the check that counts.
Receive files in a controller
mountUploads gives you one general endpoint. For files that belong to something, such as an order's invoice, receive them in your own api controller. On the server, useUploads(ctx.app) returns the service the provider bound, and receive(event, rules) reads the multipart body, applies the rules and stores the files:
// server/controllers/order-invoices-controller.ts
import type { HttpContext } from '@kitr/api'
import { useUploads } from '@kitr/uploads/server'
import { invoiceRules } from '#app/lib/orders/invoice-rules'
export default class OrderInvoicesController {
async store({ app, event, params }: HttpContext<{ orderId: string }>) {
const [invoice] = await useUploads(app).receive(event, { ...invoiceRules, field: 'file' })
// ... save invoice.key and invoice.name on order params.orderId in your database
return { files: [invoice] }
}
}
// server/routes.ts
import { defineRoutes } from '@kitr/api'
import OrderInvoicesController from '#server/controllers/order-invoices-controller'
export const routes = defineRoutes((router) =>
router.post('/api/orders/:orderId/invoice', [OrderInvoicesController, 'store']),
)
Rules passed to receive replace the config's rules one by one: maxSize, accept, maxFiles, and field (default 'file'). Anything you leave out comes from config/uploads.ts. Every file is checked before the next is stored, so a batch can be partly stored when its second file breaks a rule; check sizes and types in the browser to make that rare.
When a rule fails, receive throws a VALIDATION error, and the response is:
{
"code": "VALIDATION",
"message": "Validation failed",
"status": 422,
"fieldErrors": { "file": ["notes.txt is not an accepted type"] }
}
The fieldErrors key is the field name, so a form can show it under the input with that name. Don't give the route a body schema in static validation: that reads the body as JSON or form data before receive can.
Store the record in your database
receive and store return an UploadedFile. Save key, which is how you read or delete the file later, and name for display. Don't save url; build it from key when you render, so a later change of publicPath or driver does not break old rows.
| Field | Example | Description |
|---|---|---|
id |
'72eb7790-...' |
A random UUID, the first segment of key. |
key |
'72eb7790-.../invoice-1042.pdf' |
The storage key: the id, a slash, and the name made safe. |
name |
'invoice 1042.pdf' |
The original name, unchanged. Escape it when you render it. |
size |
48213 |
Bytes. |
type |
'application/pdf' |
The type the browser declared. Not checked against the bytes. |
url |
'/api/uploads/72eb.../invoice-1042.pdf' |
From the driver's url(key), else publicPath + key, else absent. |
The safe name keeps letters, digits, ., _ and -, turns slashes into _ and anything else into -, and is cut to 120 characters. ../../etc/passwd becomes .._.._etc_passwd, inside its own id folder.
Store and read files from server code
On the server, store writes bytes you already have, such as a generated PDF or an import from another system. read and remove complete the set:
// server/jobs/archive-invoice.ts
import type { KitrApp } from 'kitr/app'
import { useUploads } from '@kitr/uploads/server'
export async function archiveInvoice(app: KitrApp, pdf: Uint8Array) {
const uploads = useUploads(app)
const stored = await uploads.store({ name: 'Q3 report.pdf', type: 'application/pdf', data: pdf })
// => { id: '788c...', key: '788c.../Q3-report.pdf', name: 'Q3 report.pdf', size: 48213, type: 'application/pdf', url: '/api/uploads/788c.../Q3-report.pdf' }
const file = await uploads.read(stored.key)
// => { data: Uint8Array(48213), type: 'application/pdf' }
await uploads.remove(stored.key)
await uploads.read(stored.key)
// => undefined
return stored
}
store does not check the rules; they are for files people send. The provider binds uploads on the server only, so useUploads in browser code throws [kitr] No binding named "uploads". In a controller, ctx.app.container.use('uploads') returns the same service.
Serve files safely
mountUploads(server) answers GET /api/uploads/<key> with the stored bytes and the headers from servedFileHeaders. The stored type is whatever the uploader's browser declared, so the route never trusts it: images, audio, video, PDF and plain text are served inline, and everything else, SVG and HTML included, is served as a download with application/octet-stream. Every response carries nosniff and a sandboxing content security policy, so an uploaded file cannot run script as your site.
servedFileHeaders(type, name) from @kitr/uploads/server returns those headers, for routes you write yourself. For an HTML upload, they are:
// server/check_headers.ts
import { servedFileHeaders } from '@kitr/uploads/server'
servedFileHeaders('text/html', 'page.html')
// => {
// 'content-type': 'application/octet-stream',
// 'content-disposition': 'attachment; filename="page.html"',
// 'x-content-type-options': 'nosniff',
// 'content-security-policy': "default-src 'none'; img-src 'self'; media-src 'self'; sandbox",
// 'cache-control': 'public, max-age=31536000, immutable',
// }
uploads.mount(server, path) on a service from createUploads registers the same two routes, with the same headers and body limit. See Use the service without the provider.
Every file under the route is public to anyone with its key. Keys contain a random UUID, so they cannot be guessed, but anyone you share a link with can pass it on. For private files, such as invoices, don't mount a public GET. Serve them from a controller that checks the user first, as below.
Serve private files
Read the file in a controller that checks access, and answer with the safe headers:
// server/controllers/order-invoices-controller.ts
import type { HttpContext } from '@kitr/api'
import { KitrError } from 'kitr/app'
import { servedFileHeaders, useUploads } from '@kitr/uploads/server'
export default class OrderInvoicesController {
async show({ app, params, locals }: HttpContext<{ orderId: string }>) {
if (!locals.user) throw KitrError.unauthorized()
// ... look up the order and its invoice key in your database, and check the user's team
const key = '72eb7790-7c62-4c37-9167-d9989b7ac10f/invoice-1042.pdf'
const file = await useUploads(app).read(key)
if (!file) throw KitrError.notFound('Invoice')
return new Response(file.data as BodyInit, {
headers: { ...servedFileHeaders(file.type, 'invoice-1042.pdf'), 'cache-control': 'private, no-store' },
})
}
}
locals.user is set by the auth middleware. Override cache-control, because the default lets shared caches keep the file for a year.
Show field errors in a form
A server rejection reaches the browser as an UploadError. Its status is the HTTP status and its body is the parsed response, so field messages are at body.fieldErrors:
// app/components/orders/invoice-error.tsx
import { UploadError } from '@kitr/uploads'
export function InvoiceError({ error }: { error: Error | null }) {
if (!error) return null
if (error instanceof UploadError && error.status === 422) {
const fields = (error.body as { fieldErrors?: Record<string, string[]> }).fieldErrors
return <p role="alert">{fields?.file?.[0] ?? error.message}</p>
}
if (error instanceof UploadError && error.status === 413) return <p role="alert">That file is too large.</p>
return <p role="alert">{error.message}</p>
}
A failure of the browser check in useUpload is a plain Error with the checkFile message, not an UploadError, and no request is sent.
Drivers
A driver decides where bytes live. Two ship with kitr; any other storage is a driver you write (see Extend).
| Driver | Import | Use it for |
|---|---|---|
| Disk | createDiskUploadsDriver |
The default. One server with a persistent disk. |
| Memory | createMemoryUploadsDriver |
Tests and demos. Files vanish on restart. |
| unstorage | createUnstorageUploadsDriver |
Several servers or serverless hosts: S3, R2, Azure Blob, Vercel Blob, Netlify Blobs through unstorage. |
| Anything else | your own UploadsDriver |
A storage service unstorage has no driver for, or signed URLs from a CDN. |
Disk
The default when driver is not set. Nothing to install.
// config/uploads.ts
import { defineConfig } from '@kitr/uploads'
import { createDiskUploadsDriver } from '@kitr/uploads/server'
export default defineConfig({
driver: createDiskUploadsDriver({ dir: '/var/lib/acme/uploads', publicPath: '/api/uploads' }),
maxSize: 10 * 1024 * 1024,
accept: ['image/png', 'image/jpeg', 'application/pdf'],
})
| Option | Default | Description |
|---|---|---|
dir |
'storage/uploads' |
Directory, relative to the working directory or absolute. Created on first write. |
publicPath |
none | Gives the driver a url(key) that returns publicPath/key. |
Each file is written next to a <file>.meta.json sidecar that keeps its type and name. Keys that resolve outside dir, or that name a sidecar, throw FORBIDDEN (Bad upload key). A file without a sidecar is read back as application/octet-stream. The disk driver loads node:fs only when it runs, so importing it in config/uploads.ts keeps it out of the browser bundle.
Warning On hosts with an ephemeral filesystem (most containers, serverless platforms), files under
storage/uploadsare lost on every deploy or restart. Mount a persistent volume atdir, or use an object storage driver.
Warning With more than one server instance, each instance has its own disk: a file Ada uploads through instance A is a 404 when Grace's request reaches instance B. Use one shared volume for every instance, or the unstorage driver with object storage.
unstorage
createUnstorageUploadsDriver stores files through any unstorage instance, so every server instance reads and writes the same bucket. Install unstorage and the client its driver needs. For S3 or Cloudflare R2, the s3 driver needs no extra package:
pnpm add unstorage
// config/uploads.ts
import { defineConfig } from '@kitr/uploads'
import { createUnstorageUploadsDriver } from '@kitr/uploads/server'
import { createStorage } from 'unstorage'
import s3Driver from 'unstorage/drivers/s3'
const bucket = createStorage({
driver: s3Driver({
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
endpoint: process.env.S3_ENDPOINT!,
bucket: 'acme-uploads',
region: 'auto',
}),
})
export default defineConfig({
driver: createUnstorageUploadsDriver(bucket, { publicPath: '/api/uploads' }),
maxSize: 10 * 1024 * 1024,
accept: ['image/png', 'image/jpeg', 'application/pdf'],
})
Add the credentials to .env:
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com
| Option | Default | Description |
|---|---|---|
base |
'uploads' |
Second argument, { base }. Files live under <base>:files:, their type and name under <base>:meta:. |
publicPath |
none | Gives the driver a url(key) that returns publicPath/key. |
Files are read back through the kitr route (GET /api/uploads/:key), so the bucket can stay private. Keys with an empty segment or .. throw FORBIDDEN (Bad upload key). See the unstorage S3 driver docs for its options.
Memory
// config/uploads.ts
import { defineConfig } from '@kitr/uploads'
import { createMemoryUploadsDriver } from '@kitr/uploads/server'
export default defineConfig({
driver: createMemoryUploadsDriver(),
publicPath: '/api/uploads',
})
The memory driver has no url, so publicPath builds the URLs. Use it in tests (see Testing).
Use the service without the provider
createUploads(config) builds the service on its own. Use it when a driver imports a server-only SDK you don't want in config/uploads.ts (which a React app bundles for the browser too), or for a second set of rules with its own route:
// server/avatars.ts
import { createDiskUploadsDriver, createUploads } from '@kitr/uploads/server'
export const avatars = createUploads({
driver: createDiskUploadsDriver({ dir: 'storage/avatars', publicPath: '/api/avatars' }),
maxSize: 2 * 1024 * 1024,
accept: ['image/png', 'image/jpeg', 'image/webp'],
})
// server/main.ts
import { createServer } from '@kitr/server'
import { config } from '../config'
import rc from '../kitrrc'
import { avatars } from './avatars'
export const server = createServer({ providers: rc.providers, config })
// POST /api/avatars receives, GET /api/avatars/** serves with servedFileHeaders.
avatars.mount(server, '/api/avatars')
mount receives with the service's own rules and serves with the safe headers. In components, point the hook at it: useUpload({ url: '/api/avatars', accept: ['image/png', 'image/jpeg', 'image/webp'] }).
Errors
The server throws kitr errors, so they reach the browser in the usual JSON shape. upload() and useUpload turn any non-2xx response into an UploadError.
| Code / message | When | What to do |
|---|---|---|
VALIDATION, Expected multipart/form-data |
The request is not a multipart form. | Send with upload() or a FormData body. |
VALIDATION, No file sent |
The form has no file in the expected field. | Match field on both sides (default file). |
VALIDATION, At most 1 file(s) |
More files than maxFiles. |
Raise maxFiles, or send fewer. |
VALIDATION, invoice.pdf is larger than 10485760 bytes |
A file is over maxSize. |
Show the limit with formatBytes. |
VALIDATION, notes.txt is not an accepted type |
A file fails accept. |
Pick another file, or widen accept. |
PAYLOAD_TOO_LARGE (413) |
The whole body is over the server's limit for that path. mountUploads sets its own path's limit from maxSize. |
For your own routes, add the path to bodyLimits in config/server.ts. |
NOT_FOUND, File not found |
GET for a key that does not exist. |
Check the stored key. |
FORBIDDEN, Bad upload key |
Disk driver: the key leaves dir or names a sidecar. |
Use keys from store or receive only. |
UploadError, status 0, Network error during upload |
The request never completed. | Retry when online. |
UploadError, status 0, Upload cancelled |
cancel() or the signal aborted it. |
Nothing; the user cancelled. |
UploadError, Upload failed (502) |
A non-JSON error response, such as a proxy page. | Check the proxy or server logs. |
Testing
Use the memory driver and call the server directly with server.fetch; no port, no disk. This test posts Grace's invoice through the real route and checks the rules:
// test/uploads.test.ts
import { silentLogger } from '@kitr/logger'
import { createServer } from '@kitr/server'
import { createMemoryUploadsDriver, createUploads } from '@kitr/uploads/server'
import { describe, expect, it } from 'vitest'
function setup() {
const uploads = createUploads({
driver: createMemoryUploadsDriver(),
accept: ['application/pdf'],
maxSize: 1024,
publicPath: '/api/uploads',
})
const server = createServer({ logger: silentLogger })
uploads.mount(server)
return { uploads, server }
}
function post(server: { fetch(request: Request): Promise<Response> }, file: File) {
const form = new FormData()
form.append('file', file)
return server.fetch(new Request('http://acme.test/api/uploads', { method: 'POST', body: form }))
}
describe('invoice uploads', () => {
it('stores a PDF and serves it back', async () => {
const { uploads, server } = setup()
const res = await post(server, new File(['%PDF-1.7'], 'invoice 1042.pdf', { type: 'application/pdf' }))
expect(res.status).toBe(200)
const { files } = (await res.json()) as { files: Array<{ key: string; name: string; size: number; url: string }> }
expect(files[0]).toMatchObject({ name: 'invoice 1042.pdf', size: 8 })
expect(files[0]!.key).toMatch(/\/invoice-1042\.pdf$/)
expect(files[0]!.url).toBe(`/api/uploads/${files[0]!.key}`)
const served = await server.fetch(new Request(`http://acme.test${files[0]!.url}`))
expect(served.headers.get('content-type')).toBe('application/pdf')
expect(await served.text()).toBe('%PDF-1.7')
expect(await uploads.read(files[0]!.key)).toBeDefined()
})
it('rejects a file of the wrong type with a field error', async () => {
const { server } = setup()
const res = await post(server, new File(['hi'], 'notes.txt', { type: 'text/plain' }))
expect(res.status).toBe(422)
expect(await res.json()).toMatchObject({
code: 'VALIDATION',
fieldErrors: { file: ['notes.txt is not an accepted type'] },
})
})
})
To test code that calls useUploads(app), boot an app with the provider and a memory driver from testing:
// server/jobs/archive-invoice.test.ts
import { createTestApp } from '@kitr/testing'
import { uploadsProvider } from '@kitr/uploads/provider'
import { createMemoryUploadsDriver, useUploads } from '@kitr/uploads/server'
import { expect, it } from 'vitest'
it('stores the generated report', async () => {
const app = await createTestApp({
providers: [uploadsProvider],
config: { uploads: { driver: createMemoryUploadsDriver() } },
})
const stored = await useUploads(app).store({
name: 'Q3 report.pdf',
type: 'application/pdf',
data: new TextEncoder().encode('%PDF'),
})
expect(stored).toMatchObject({ name: 'Q3 report.pdf', size: 4 })
expect(stored.key).toMatch(/\/Q3-report\.pdf$/)
})
On the client, upload() falls back to fetch when there is no XMLHttpRequest, so a Node test can replace globalThis.fetch with (url, init) => server.fetch(new Request('http://acme.test' + url, init)) and call upload() against the same server.
Extend
Any storage is a driver: an object with put, get, delete and, optionally, url. Keys are relative paths such as 72eb.../invoice-1042.pdf that the driver owns.
// node_modules/@kitr/uploads/dist/server.d.ts (excerpt)
export interface UploadsDriver {
name: string
put(key: string, data: Uint8Array, meta: { type: string; name: string }): Promise<void>
get(key: string): Promise<{ data: Uint8Array; type: string } | undefined>
delete(key: string): Promise<void>
url?(key: string): string | Promise<string>
}
An S3 driver with the AWS SDK. It also works with Cloudflare R2 and other S3-compatible stores through endpoint:
pnpm add @aws-sdk/client-s3
// server/uploads/s3-driver.ts
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import type { UploadsDriver } from '@kitr/uploads/server'
export function createS3UploadsDriver(options: { bucket: string; endpoint?: string; publicUrl?: string }): UploadsDriver {
const s3 = new S3Client({ endpoint: options.endpoint, region: process.env.AWS_REGION ?? 'auto' })
return {
name: 's3',
async put(key, data, meta) {
await s3.send(new PutObjectCommand({ Bucket: options.bucket, Key: key, Body: data, ContentType: meta.type }))
},
async get(key) {
try {
const object = await s3.send(new GetObjectCommand({ Bucket: options.bucket, Key: key }))
const data = await object.Body!.transformToByteArray()
return { data, type: object.ContentType ?? 'application/octet-stream' }
} catch (error) {
if ((error as { name?: string }).name === 'NoSuchKey') return undefined
throw error
}
},
async delete(key) {
await s3.send(new DeleteObjectCommand({ Bucket: options.bucket, Key: key }))
},
url: options.publicUrl ? (key) => `${options.publicUrl}/${key}` : undefined,
}
}
Register it with a service created in server code, so the SDK stays out of the browser bundle:
// server/uploads/index.ts
import { createUploads } from '@kitr/uploads/server'
import { createS3UploadsDriver } from './s3-driver'
export const uploads = createUploads({
driver: createS3UploadsDriver({
bucket: 'acme-uploads',
endpoint: process.env.S3_ENDPOINT,
publicUrl: 'https://files.acme.com',
}),
maxSize: 10 * 1024 * 1024,
accept: ['image/png', 'image/jpeg', 'application/pdf'],
})
Then call uploads.mount(server) in server/main.ts in place of mountUploads(server). With url set, stored files link straight to the bucket's public domain, and the GET route is only a fallback. For a server-only app, you can pass the driver in config/uploads.ts instead and keep the provider. See UploadsDriver in the API reference.
Next steps
- API reference: every export of
@kitr/uploads,@kitr/uploads/react,@kitr/uploads/serverand@kitr/uploads/provider. - server:
createServer,bodyLimit,bodyLimitsand the error shape. - api: controllers and routes for files that belong to a record.
- forms: show
fieldErrorsunder the file input. - MDN on FormData: what
upload()sends. - Run on more than one instance: shared files, cache, queue and sockets.