alepha@docs:~/docs/framework/guides/server$
cat 3-file-upload.md | pretty
5 min read
Last commit:

#File Upload

Alepha handles multipart/form-data uploads through two body schema types. Multipart parsing is built into AlephaServer and active by default.

Which one you declare decides how the bytes reach your handler, and it is the only decision that really matters here:

Schema Handler receives Bytes are Use when
z.file() a FileLike held until the handler returns you need to read the content more than once, or need its size up front
z.stream() the part, consumed once passed through as they arrive the payload is large, or you are forwarding it somewhere else

z.file() is the convenient one; z.stream() is the one that does not put the payload in memory.

#Defining Upload Endpoints

Use z.file() in a body schema. When the body contains a file field, the action automatically expects multipart/form-data:

typescript
 1import { z } from "alepha"; 2import { $action } from "alepha/server"; 3import { $storage } from "alepha/api/files"; 4  5class UploadController { 6  uploads = $storage(); 7  8  upload = $action({ 9    method: "POST",10    path: "/upload",11    schema: {12      body: z.object({13        file: z.file(),14        description: z.text().optional(),15      }),16      response: z.object({ id: z.text() }),17    },18    handler: async ({ body, user }) => {19      const stored = await this.uploads.upload(body.file, { user });20      return { id: stored.id };21    },22  });23}

#File Object

A z.file() field arrives as a FileLike:

typescript
 1interface FileLike { 2  name: string; // Original filename 3  type: string; // MIME type (e.g. "image/png") 4  size: number; // Size in bytes 5  lastModified: number; // Timestamp in milliseconds 6  7  stream(): StreamLike; // Read as stream 8  arrayBuffer(): Promise<ArrayBuffer>; // Read into memory 9  text(): Promise<string>; // Read as text10}

A z.file() field is materialised before your handler runs. FileLike promises to be readable more than once - you can call text() and then arrayBuffer() - and honouring that means keeping the bytes. They are held in memory, not written to a temporary file, and they are released when the request ends. Nothing persists unless you store it; see Permanent Storage.

That is what the per-file ceiling is protecting, and why a route expecting large payloads should declare z.stream() instead.

FileLike is a minimal interface inspired by the Web File API. It allows to use browser input file directly without mapping!

#Streaming Large Uploads

z.stream() hands the bytes over as they arrive, so memory stays flat no matter how large the payload is:

typescript
 1import { z } from "alepha"; 2import { $action } from "alepha/server"; 3  4class ArchiveController { 5  receive = $action({ 6    method: "POST", 7    path: "/archive", 8    schema: { 9      body: z.object({10        file: z.stream({ maxBytes: 500_000_000 }),11      }),12      response: z.object({ bytes: z.integer() }),13    },14    handler: async ({ body }) => {15      let bytes = 0;16      for await (const chunk of body.file.data) {17        bytes += chunk.length;18      }19      return { bytes };20    },21  });22}

Three consequences worth knowing before you reach for it:

  • The bytes can be read once. There is no going back for a second pass.
  • size is 0. The length is not known until the stream has been read, and it is not guessed.
  • Parsing stops at the streamed part. Whatever follows it in the message is never read, because the handler - not the parser - is driving. A client that wants other fields honoured must send them before the file. This is inherent to streaming, not a limitation of the parser.

Because the handler pulls the bytes, nothing is consumed before $secure has run. On the z.file() path the body is read first, so an unauthenticated caller can spend the budget - see Multipart for what that means when raising a limit.

#Size Limits

Defaults, applied to every route:

Limit Default Counts
One file 5 MB that part's content
Whole request 10 MB every part's content, plus the preamble and every part's headers
Parts per request 10 every part - text fields as well as files

The last two columns are the ones that surprise. The request budget bounds reading, not delivering: a sender that never emits a boundary costs exactly as much as one that sends content, so the bytes walked past are billed too. And a form with three text fields and eight files is eleven parts, not eight.

A route raises its own ceiling by declaring it, in bytes:

typescript
1body: z.object({2  video: z.file({ maxBytes: 50_000_000 }),3});

And the framework's own upload route takes its ceiling from the $storage bucket the bytes are headed for - which is declared in megabytes:

typescript
1uploads = $storage({ maxSize: 100 });

The two units differ on purpose, and the unit is in each name rather than only in the docs. Multipart explains how the three levels resolve and how to add your own.

A bucket that declares no maxSize gets 10 MB, the documented $storage default - the transport honours it rather than falling back to the 5 MB application-wide figure.

A file refused for its size answers 413, whichever layer notices: the transport before the bytes land, or the bucket while they stream past. A file refused for its MIME type answers 400 - it would not be accepted at any size.

#Mixed Fields

Combine file fields with regular form fields in the same schema. Non-file fields are extracted from the form data and decoded according to their schema type:

typescript
1schema: {2  body: z.object({3    avatar: z.file(),4    username: z.text(),5    bio: z.text().optional(),6  }),7}

Fields the schema does not declare are skipped rather than refused: the body is shaped by the route, and a client sending extra parts is not an error the route has an opinion about.

#Permanent Storage

An uploaded file lives only for the request. To keep it, store it with $storage:

typescript
 1import type { FileLike } from "alepha"; 2import { $storage } from "alepha/api/files"; 3  4class FileService { 5  uploads = $storage(); 6  7  async store(file: FileLike): Promise<string> { 8    const stored = await this.uploads.upload(file); 9    return stored.id;10  }11}

upload() returns the files row - hand .id to GET /api/files/:id, and persist it in your own tables. Backends: local filesystem, S3-compatible services and Cloudflare R2. See File Storage for constraints, TTL and querying.