Heximon Logo
Recipes

Upload a File

Accept a multipart/form-data upload validated by Route.files(), read it with readValidatedFormData(), and stream it into the abstract BlobStore DI token — swappable for filesystem, S3, or R2 with no controller change.

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(...):

src/uploads/upload.api.ts
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 }),
  },
}) {}
src/uploads/upload.schema.ts
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:

src/uploads/uploads.controller.ts
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:

src/uploads/uploads.module.ts
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

terminal
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:

src/uploads/uploads.module.ts
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 FormData encoding, and the maxBytes-vs-body-guard ordering.
  • Blob Storage — the full BlobStore port (streaming, byte ranges), all four drivers, and the SignedUrlBlobStore capability for direct-to-storage presigned URLs.
  • the gap's storage & blob adapters example — this upload feature alongside presigned-URL issuance and an env-driven Storage binding, with an in-process test proving a streamed round-trip.
Copyright © 2026