Upload a File
Accept a file over HTTP and put it somewhere durable, without coupling the controller to where. A route
declares its file fields with .files({...}); the server validates and hands you a typed File; you
stream it into the abstract BlobStore port, which a module binds to memory today and S3 or R2 later
with no other change.
1. Declare the upload contract
.files({...}) marks a route multipart/form-data and records each field's constraints — maxBytes,
accept (an exact type, a type/* wildcard, or a .extension). A scalar field like caption is still
validated by .body(...):
import { Contract, Route } from "@heximon/contract";
import { uploadFields, uploadResult } from "./upload.schema";
export class UploadsApi extends Contract({
prefix: "/api/uploads",
routes: {
create: Route.post("/")
.files({ file: { maxBytes: 524_288, accept: ["image/*", "application/pdf", ".txt"] } })
.body(uploadFields)
.responses({ 201: uploadResult }),
},
}) {}
import { z } from "zod";
export const uploadFields = z.object({
caption: z.string().max(280).optional(),
});
export const uploadResult = z.object({
key: z.string(),
filename: z.string(),
contentType: z.string(),
size: z.number().int(),
caption: z.string().optional(),
});
2. Stream the file into a BlobStore
Each declared field arrives as a web File, so it pipes straight into BlobStore.put() via
file.stream() — the whole body is never buffered in your handler:
import type { BlobStore } from "@heximon/blob";
import type { Action, Controller } from "@heximon/http";
import type { UploadsApi } from "./upload.api";
export class UploadsController implements Controller<UploadsApi> {
public constructor(private readonly blobs: BlobStore) {}
public async create(action: Action<UploadsApi, "create">) {
const { fields, files } = await action.request.readValidatedFormData();
const file = files.file; // typed `File`
const key = crypto.randomUUID();
await this.blobs.put(key, file.stream(), {
contentType: file.type,
size: file.size,
custom: { filename: file.name },
});
return action.respond(201, {
key,
filename: file.name,
contentType: file.type,
size: file.size,
caption: fields.caption,
});
}
}
A disallowed content type or a missing required file fails with a 400; a file over maxBytes fails
with a 413 — both enforced by readValidatedFormData() before your handler body runs.
3. Bind the BlobStore
BlobStore is an abstract DI token. MemoryBlobStore extends it and is the only satisfier in scope, so a
bare provider resolves the token — no compiler plugin, no boot seam. Start with the in-memory driver, which
needs no setup and is ideal for tests:
import { MemoryBlobStore } from "@heximon/blob/memory";
import { Module } from "@heximon/runtime";
import { UploadsController } from "./uploads.controller";
export class UploadsModule extends Module({
providers: [MemoryBlobStore],
http: { controllers: [UploadsController] },
}) {}
4. Run it
curl -s -X POST localhost:3024/api/uploads \
-F 'file=@./photo.jpg;type=image/jpeg' \
-F 'caption=Team offsite'
# → 201 { "key": "…", "filename": "photo.jpg", "contentType": "image/jpeg", "size": 48213, "caption": "Team offsite" }
Go to production
Swap the binding, not the controller — FileSystemBlobStore, S3BlobStore, and R2BlobStore all
extend BlobStore, so any of them satisfies the same token:
import { BlobStore } from "@heximon/blob";
import { FileSystemBlobStore } from "@heximon/blob/filesystem";
import { Module } from "@heximon/runtime";
import { UploadsController } from "./uploads.controller";
export class UploadsModule extends Module({
providers: [{ provide: BlobStore, useFactory: () => new FileSystemBlobStore("/var/data/blobs") }],
http: { controllers: [UploadsController] },
}) {}
maxBytes is checked after the body is parsed. Reading multipart input buffers and parses the whole
body via the web Request.formData() before any File is available, so an over-limit file is rejected
(413) after it's in memory, not mid-stream. The default-on Content-Length body guard (1 MiB) catches
oversized requests before parsing, but only when the client sends a Content-Length — for a hard ceiling
on untrusted uploads, also cap the body at the transport or proxy layer.See also
- Validation & DTOs — the multipart
validation mechanics in full: field constraints, the self-client's automatic
FormDataencoding, and themaxBytes-vs-body-guard ordering. - Blob Storage — the full
BlobStoreport (streaming, byte ranges), all four drivers, and theSignedUrlBlobStorecapability for direct-to-storage presigned URLs. - the gap's storage & blob adapters example
— this upload feature alongside presigned-URL issuance and an env-driven
Storagebinding, with an in-process test proving a streamed round-trip.
Send an Email
Inject the abstract EmailTransport DI token, compose an EmailMessage with Mustache placeholders, and send it — with a capture transport for dev/tests and ResendTransport as the one-line production swap.
Cache an Expensive Call
Wrap a slow read with the Cache facade's getOrSet over a Storage tier, tag the entry for invalidation, and bust it after a write — MemoryStorage, Cache.invalidate, tags.