Hi, Devlin. Ten years of fullstack, currently in Norway, currently nursing a strong opinion that file uploads should not be the part of your codebase that decides which runtime you can deploy to. And yet, for most of my career, it has been. A multer-shaped object here, a busboy stream there, a /tmp/uploads directory we're not allowed to talk about in serverless. You know the script.
DaloyJS treats uploads the way the modern web platform does: you get back a File (which is a Blob with a name), which is the same shape on Node, Bun, Deno, and Cloudflare Workers. Two helpers, fileField() and multipartObject(): give you per-file size caps, MIME allowlists, filename predicates, strict field validation, and OpenAPI binary schemas, without ever stepping outside the standard. This post is the tour.
The pain you're here to fix
# Symptoms that your upload code grew into a small framework of its own:
#
# - You wrote a "uploadHandler" wrapper that buffers the whole file into a
# Buffer, then re-emits it as a stream to S3. Memory usage during deploys
# tracks how many users hit "send" simultaneously.
#
# - Your MIME check is a regex against the filename extension. Someone
# uploaded "invoice.png.exe" and you only found out from a security audit.
#
# - The image-only endpoint accepts video/quicktime because nobody added
# the allowlist to the new route last quarter. Discovered when storage
# alerts fired at 02:13 on a Tuesday.
#
# - You ported the API to Cloudflare Workers and the entire upload
# subsystem broke because it relied on a Node-only multipart parser.
four classic upload sins · all preventableTS · UTF-8 · LF
What most Node code looks like today
// The pattern most Node frameworks teach. It looks reasonable until
// you try to run it on an edge runtime, or someone uploads a 4 GB MOV.
import multer from "multer";
import express from "express";
const upload = multer({
dest: "/tmp/uploads", // disk! on a stateless deploy!
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (!file.mimetype.startsWith("image/")) return cb(new Error("nope"));
cb(null, true);
},
});
const app = express();
app.post("/avatars", upload.single("file"), async (req, res) => {
// req.file is a multer-shaped object. Not a standard Web File.
// To get it to S3 you fs.createReadStream(req.file.path) - disk roundtrip.
// To get it OUT of /tmp you remember to delete it (or you don't, and
// /tmp fills up on every cold start).
});
disk roundtrip · framework-specific shape · no edge runtime supportTS · UTF-8 · LF
Three structural problems live in that snippet: the file goes to disk (you have no disk on Workers), the shape req.file is a multer thing rather than a Web File, and the validation rules are scattered across a config object, a fileFilter callback, and a downstream try/catch. The DaloyJS version collapses all of it into one schema:
The two helpers, doing everything
// src/routes/avatars.ts, the DaloyJS version.
// Two helpers do all the work, both Standard Schema compatible.
import { z } from "zod";
import { App, fileField, multipartObject } from "@daloyjs/core";
const AvatarUpload = multipartObject(
{
file: fileField({
maxBytes: 5 * 1024 * 1024, // 5 MB hard cap
accept: ["image/png", "image/jpeg", "image/webp"],
filename: (name) => /\.(png|jpe?g|webp)$/i.test(name),
}),
alt: z.string().min(1).max(140), // <- non-file fields use Zod (or any
// Standard-Schema-compatible lib)
isPrimary: z.enum(["true", "false"]).default("false").transform((v) => v === "true"), // z.coerce.boolean() would turn "false" into true
},
{ strict: true }, // reject unknown form fields
);
// bodyLimitBytes defaults to 1 MiB for the WHOLE request, so raise it above
// the per-file cap or a 2 MB avatar gets a 413 before fileField() ever runs.
const app = new App({ bodyLimitBytes: 6 * 1024 * 1024 });
app.route({
method: "POST",
path: "/avatars",
operationId: "uploadAvatar",
tags: ["Avatars"],
request: { body: AvatarUpload },
responses: {
201: {
description: "Avatar uploaded",
body: z.object({ url: z.url() }),
},
},
handler: async ({ body }) => {
// body.file is a Web File / Blob - every runtime has it.
// body.alt is a string. body.isPrimary is a real boolean.
const url = await uploadToObjectStorage({
stream: body.file.stream(), // <- hand it on as a stream (the body was read up to bodyLimitBytes)
type: body.file.type,
size: body.file.size,
name: body.file.name ?? "avatar",
});
return { status: 201, body: { url } };
},
});
multipartObject + fileField · streams everywhere · zero framework lock-inTS · UTF-8 · LF
Worth pointing at three things in that snippet:
1File fields and form fields coexist
fileField() validates uploads; z.string(), z.enum(), and anything else Standard-Schema-shaped validate text fields. One multipartObject() wraps both. You don't split your validation between "the upload library" and "the validation library".
2strict: true rejects unknown fields
Extra form fields are usually either a misconfigured frontend (silent bugs) or someone fishing for parser behaviour. Reject them. The framework returns RFC 9457 422 with a problem+json body, same shape as every other validation error in the app.
3body.file is a real Web File
body.file is a Blob & { name? }, which is the standard interop type. file.stream() is a ReadableStream. file.arrayBuffer() and file.text() work too if you really must. Same code path on every runtime.
The validation error you get for free
# Send a too-big WebP that's secretly a video disguised as one:
curl -sS -X POST http://localhost:3000/avatars \
-F file=@./vacation.mov;type=video/quicktime \
-F alt="My profile picture" -F isPrimary=true | jq .
# HTTP/1.1 422 Unprocessable Entity
# Content-Type: application/problem+json
{
"type": "https://daloyjs.dev/errors/validation",
"title": "Request validation failed",
"status": 422,
"detail": "Invalid body",
"errors": [
{ "path": "file", "message": "File type \"video/quicktime\" not in accept list: image/png, image/jpeg, image/webp" }
]
}
# fileField() stops at the first failing rule (size, then type, then
# filename), so you get one entry per field, not one per rule.
# Zero handler code involved. The framework rejected the request before
# a single byte hit your business logic. Memory-safe by construction.
application/problem+json · zero bytes through your handlerTS · UTF-8 · LF
Notice that the rejection happens before the handler runs. By the time your business logic gets called, the file has already passed its size cap and its MIME check. That's the difference between a memory exhaustion bug and a 422 response. For the long version of why that error shape is what it is, see the RFC 9457 errors post.
fileField(): every option, in one screen
@daloyjs/core · multipart.ts
// fileField(options), every knob, in one place.
fileField({
maxBytes: 10 * 1024 * 1024, // hard cap on file.size
accept: ["image/*", "application/pdf"], // exact or "type/*" wildcard
filename: (name) => name.length <= 200, // your custom predicate
magicBytes: true, // sniff PNG/JPEG/GIF/WebP/PDF/ZIP/GZIP
// signatures derived from accept
rejectScriptableImages: true, // refuse SVG/MVG/PostScript (on by
// default whenever magicBytes is set)
optional: true, // accept null/undefined as well
format: "binary", // OpenAPI hint, default "binary"
});
// Wildcard matching is lower-cased before comparison, so:
// accept: ["IMAGE/PNG"] ✓ matches image/png
// accept: ["image/*"] ✓ matches image/jpeg, image/webp, image/svg+xml
// accept: ["*/*"] ✓ matches anything (use sparingly; mostly for tests)
seven knobs · no hidden behaviorTS · UTF-8 · LF
Arrays of files
// Update (1.5.4): the multipart parser keeps ONE value per field name, so
// <input type="file" multiple /> sending three parts named "images" only
// hands you the last one. Give each file its own field name instead, and
// cap the count with the app-level multipart limits.
import { z } from "zod";
import { App, fileField, multipartObject } from "@daloyjs/core";
const image = () => fileField({ maxBytes: 20 * 1024 * 1024, accept: ["image/*"] });
const optionalImage = () =>
fileField({ maxBytes: 20 * 1024 * 1024, accept: ["image/*"], optional: true });
const GalleryUpload = multipartObject(
{
image0: image(), // at least one
image1: optionalImage(),
image2: optionalImage(),
albumId: z.uuid(),
},
{ strict: true },
);
// Count caps live on the app, next to bodyLimitBytes:
const app = new App({
bodyLimitBytes: 64 * 1024 * 1024,
multipart: { maxFiles: 3, maxFields: 10 },
});
// In your handler:
// for (const img of [body.image0, body.image1, body.image2]) {
// if (img) await uploadToObjectStorage({ stream: img.stream(), ... });
// }
one field name per file · per-file rules still applyTS · UTF-8 · LF
Streaming, on every runtime
This is the bit that pays for the abstraction. Because body.file is a Web Blob, you get file.stream() for free, a ReadableStream with the exact same shape on Node, Bun, Deno, and Workers Edge. Four ways to use it, same handler code on all of them:
// Streaming is the whole point. file.stream() returns a Web standard
// ReadableStream - the same shape on Node, Bun, Deno, Cloudflare
// and Workers. Pipe it to any compatible writer:
// 1) Cloudflare R2 (Workers binding):
await env.AVATARS.put(key, body.file.stream(), {
httpMetadata: { contentType: body.file.type },
});
// 2) AWS S3 v3 SDK on Node/Bun:
import { Upload } from "@aws-sdk/lib-storage";
await new Upload({
client: s3,
params: {
Bucket: "avatars",
Key: key,
Body: body.file.stream(),
ContentType: body.file.type,
ContentLength: body.file.size,
},
}).done();
// 3) Disk on a long-lived Node instance:
import { Writable } from "node:stream";
import { createWriteStream } from "node:fs";
await body.file.stream().pipeTo(
Writable.toWeb(createWriteStream("/var/data/" + key)),
);
// 4) Forward to another service over HTTP as a stream:
await fetch(`${UPSTREAM}/store/${key}`, {
method: "PUT",
headers: { "content-type": body.file.type, "content-length": String(body.file.size) },
body: body.file.stream(),
// @ts-expect-error half-duplex hint - Node 18+ needs this for streaming bodies
duplex: "half",
});
R2 · S3 · disk · upstream HTTP, same Web ReadableStreamTS · UTF-8 · LF
The runtime-portability story for uploads is the same story as for everything else in this stack, see the five-runtimes verification post, but it's especially noticeable here because uploads are the one feature most Node frameworks accidentally pin you to Node for.
The OpenAPI shape this emits
generated/openapi.json · /avatars
// generated/openapi.json, what the spec looks like for the AvatarUpload route.
// Notice the "multipart/form-data" content type and the "binary" format hint
// on the file field. Every OpenAPI tool understands this shape; the x-accept
// and x-max-bytes extensions carry the fileField() constraints along.
{
"paths": {
"/avatars": {
"post": {
"operationId": "uploadAvatar",
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": ["file", "alt"],
"additionalProperties": false,
"properties": {
"file": {
"type": "string",
"format": "binary",
"x-accept": ["image/png", "image/jpeg", "image/webp"],
"x-max-bytes": 5242880
},
"alt": { "type": "string", "minLength": 1, "maxLength": 140 },
"isPrimary": { "type": "boolean", "default": false }
}
}
}
}
},
"responses": { "201": { /* ... */ } }
}
}
}
}
// In Scalar's /docs UI the "Try it" panel renders a real file picker.
// In the Hey API generated SDK, uploadAvatar({ body: { file, alt, isPrimary } })
// is typed against the browser File type. No surprise stringification.
multipart/form-data · type: string + format: binary · x-accept + x-max-bytesTS · UTF-8 · LF
That means three nice things, automatically: the Scalar /docs UI shows a real file picker in the "Try it" panel. The Hey API generated SDK exposes a typed function whose body.file is a browser File; and any third-party tool that grok'd OpenAPI 3.1 (Postman, Bruno, Speakeasy) renders the upload correctly without help.
Testing without a server
FormData and File are standard globals on Node 18+, Bun, and every other runtime DaloyJS ships an adapter for. Combine them with app.request() to test the entire upload path in-process, without opening a port or relying on a temporary directory:
// tests/avatars.test.ts, test the upload end-to-end without a port.
// FormData and File are part of Node 18+ (and every other modern runtime),
// so this works in your existing node:test setup.
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../src/build-app.ts";
test("uploadAvatar accepts a PNG and returns a URL", async () => {
const app = buildApp();
const png = new File(
[new Uint8Array([0x89, 0x50, 0x4e, 0x47])], // a 4-byte "PNG"
"me.png",
{ type: "image/png" },
);
const fd = new FormData();
fd.set("file", png);
fd.set("alt", "Me at the fjord");
fd.set("isPrimary", "true");
const res = await app.request("/avatars", { method: "POST", body: fd });
assert.equal(res.status, 201);
const body = await res.json();
assert.match(body.url, /^https?:\/\//);
});
test("uploadAvatar rejects a too-big or wrong-type file as RFC 9457 422", async () => {
// buildApp() sets bodyLimitBytes above 6 MB. With the 1 MiB default this
// request is a 413 from the body limit, not a 422 from fileField().
const app = buildApp();
const oversize = new File([new Uint8Array(6 * 1024 * 1024)], "huge.png", { type: "image/png" });
const fd = new FormData();
fd.set("file", oversize);
fd.set("alt", "n/a");
const res = await app.request("/avatars", { method: "POST", body: fd });
assert.equal(res.status, 422);
assert.equal(res.headers.get("content-type"), "application/problem+json");
});
node:test + FormData + app.request() · runs in millisecondsTS · UTF-8 · LF
The production checklist
# A small checklist that has saved me from production-fire postmortems:
# 1) Always set maxBytes per field. The framework's bodyLimitBytes is the
# OUTER limit on the whole request; fileField.maxBytes is per file.
# Both should be set. The smaller one wins for a single-file upload.
# 2) Always set accept. "We'll figure it out later" is how you end up
# hosting random EXEs and DMGs for free. Make the allowlist
# aggressively narrow; expand only when you have a real use case.
# 3) Use strict: true on multipartObject. Extra form fields are almost
# always a misconfigured frontend or an enumeration attempt.
# 4) Trust file.type for routing decisions; DO NOT trust it as a security
# boundary on its own. Turn on fileField({ magicBytes: true }) so the
# signature is sniffed before the handler runs (it also refuses SVG and
# other scriptable images by default). (For static-only uploads to
# a CDN bucket, the MIME allowlist is enough.)
# 5) Stream. Never new Uint8Array(await file.arrayBuffer()) unless you're
# SURE the file is small. That line is the single biggest cause of
# "why does my Workers script run out of memory at 25 MB?" in the wild.
# 6) Pick a deterministic key. file.name comes from the client. Use a
# crypto.randomUUID() and store the original name as metadata instead.
six rules · paste into your team's runbookTS · UTF-8 · LF
Keep uploads portable
File uploads stop being a special case the moment you commit to two things: validate the file the same way you validate any other field, and never leave the Web standard File shape. fileField() handles the first. The rest of the framework handles the second. You get streaming on every runtime, OpenAPI binary schemas without extra work, RFC 9457 problem+json on every rejection, and an in-process test path that doesn't require touching disk. The "upload subsystem" folder you keep meaning to refactor, you might not need it anymore.
For more pieces in the same vein, the middleware post covers where to put auth around upload endpoints. The secure-by-default post covers the body limits you get for free, and the rate-limit middleware you add in one line. The bookstore tutorial is the broader starter walkthrough.
Devlin