File Uploads and Artifacts
File Uploads and Artifacts: a worked guide from route registry through to a production build.
Example
Stream multipart uploads at the server boundary, enforce size and content constraints before storage, and return an opaque artifact identifier.
<form onSubmit={async (event) => {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement)) return;
await fetch('/artifacts', { method: 'POST', body: new FormData(form) });
}}>
<label htmlFor="artifact">Build artifact</label>
<input id="artifact" name="artifact" type="file" required />
<button type="submit">Upload</button>
</form>
// Server: bind the multipart input, validate it, stream to storage, then return 201.Goal and architecture
Uploads in Askr go through the same typed-client machinery as any other request: @askrjs/fetch ships a multipart() codec that types request bodies as FormData, so a file upload endpoint is defined the same way as a JSON one, just with a different codec on the request side. There's no separate 'upload manager' abstraction — you build a FormData, attach it to a post() endpoint typed with multipart(), and get back the same discriminated ClientResult as any other call.
Implementation
Define the endpoint with .body(multipart()) (or whatever your API descriptor's body method is named) and construct the FormData on the caller side with append('file', fileInput.files[0]) plus any accompanying fields. Because responses go through the codec system too, a large download can use blob() or arrayBuffer() instead of json(), and a chunked upload-progress or download stream can use stream() to get a raw ReadableStream<Uint8Array> rather than buffering the whole payload in memory. On the server side, read the incoming multipart/form-data body off the standard Request object inside your Handler.
Failure states
Enforce size and type limits before the request leaves the browser where you can — the client doesn't reject oversized files for you, so check file.size and file.type against your limits and surface a specific message rather than letting a large upload fail with a generic transport error partway through. On the server, a request that exceeds your platform's body-size limit or has an unexpected content type should return a typed error response (context.badRequest(...) or a codec-declared 413/415 in .errors({...})) that the client can decode, not a raw 500. An upload usually won't get auto-retried, but not because it's a special-cased exclusion — POST (the method most uploads use) simply isn't in the retry middleware's default eligible-methods list. If you build an upload endpoint on a retry-eligible method like PUT with a buffered (non-streaming) FormData body, it would actually be retried; only a genuinely streaming body is permanently excluded, since the middleware can't safely replay a stream it's already consumed. Either way, plan for a failed upload needing its own explicit retry action.
Verification
Test the multipart codec path with a small in-memory File/Blob and assert the server handler receives the expected fields and file bytes, rather than mocking FormData construction away. Cover the size-limit and wrong-content-type rejection paths explicitly, since those are the failure modes users hit most often in practice. For large-file or streaming paths, verify backpressure behavior with a stream that yields chunks slowly, confirming the upload doesn't buffer the entire file into memory before sending.