Multipart File Upload In Actions
Design Log #131 — Multipart File Upload in Actions
Written for AI agents. See Log Methodology Note below for details.
Background
Jay actions (makeJayAction, makeJayStream) currently accept JSON-only input. The client serializes with JSON.stringify(), sets Content-Type: application/json, and the server body parser calls JSON.parse(). There is no path for binary data.
The AIditor plugin (golf project DL#08) needs to upload screenshots, video frames, and file attachments as part of agent task submissions. Today this is handled by a separate HTTP server on port 8787 — the goal is to eliminate that and route everything through Jay actions.
Related design logs: #63 (server actions), #129 (streaming actions), #130 (plugin routes).
Problem
An AIditor "submit task" action needs to receive:
- Screenshots (PNG, ~100KB–2MB each)
- Video recordings or frame sequences
- Text metadata (annotations, notes, target selectors)
Binary files can't go through JSON.stringify(). The options are base64 encoding (33% size overhead, memory pressure for large files) or proper multipart form handling.
Design
Approach: FormData-aware actions
Add multipart/form-data support to the existing action pipeline. Actions opt in by declaring file fields. The framework handles parsing on the server and FormData construction on the client.
Action Declaration
export const submitTask = makeJayAction('aiditor.submitTask')
.withFiles() // marks this action as accepting files
.withHandler(async (input: SubmitTaskInput, ...services) => {
// input.screenshots is JayFile[]
// input.notes is string (text field)
});
withFiles() on the builder sets a flag (acceptsFiles: true) on the action definition. No schema needed — the handler's input type declares which fields are files.
File Type
/** A file received by a multipart action */
interface JayFile {
/** Original filename */
name: string;
/** MIME type */
type: string;
/** File size in bytes */
size: number;
/** Absolute path to the temp file on disk */
path: string;
}
Files are written to disk by the parser (not buffered in memory). The handler receives paths, not buffers. Temp files are cleaned up after the handler returns.
Client Side
When calling an action marked with acceptsFiles, the client caller detects File or Blob instances in the input and builds FormData instead of JSON:
// Client build transform produces:
const submitTask = createActionCaller<SubmitTaskInput, Result>('aiditor.submitTask', 'POST', {
acceptsFiles: true,
});
// When called:
submitTask({
notes: 'Fix the header alignment',
screenshots: [file1, file2], // File objects from input or canvas
});
The caller:
- Creates a
FormDataobject - Appends
File/Blobvalues as file fields - Appends non-file values as a single
_jsonfield (JSON.stringifyof the remaining data) - Omits
Content-Typeheader (browser sets it with boundary)
Server Side
The body parser middleware (actionBodyParser in action-router.ts) checks Content-Type:
application/json→ existing JSON parse path (unchanged)multipart/form-data→ parse withbusboy(streaming multipart parser, no dependencies beyond Node built-ins since Node 18+)- File fields → write to temp dir, produce
JayFileobjects _jsonfield → parse as JSON, merge with file fields- Result:
req.body = { ...jsonFields, ...fileFields }
- File fields → write to temp dir, produce
Stream Actions with Files
makeJayStream can also use .withFiles(). The input parsing is the same — multipart for the request, NDJSON for the response stream. This is the AIditor's primary use case: upload screenshots, stream agent output back.
Questions
Q: Why not just base64 encode?
A: Screenshots are typically 500KB–2MB. Base64 adds 33% overhead. For video frames (multiple images), this compounds quickly. Multipart streaming writes directly to disk — constant memory regardless of file size.
Q: Why
busboyand notmulterorformidable?A:
busboyis the lowest-level streaming parser.multerwrapsbusboywith Express middleware conventions we don't need (we have our own middleware).formidableis larger.busboyhas zero dependencies and is battle-tested (used internally bymulter,express-fileupload, etc.). Alternatively, since Node 18+ includesBlobandFormDatanatively, we could explore using the built-inRequestAPI, but Express doesn't expose it.Q: Where do temp files go?
A:
{buildFolder}/.tmp/actions/— cleaned up after handler returns. The build folder is already gitignored and cleaned byyarn clean.Q: Does the build transform need changes?
A: Yes. The client-side replacement of
makeJayActionmust pass{ acceptsFiles: true }tocreateActionCallerwhen the action uses.withFiles(). This is a flag on the action definition that the compiler reads.Q: What about file size limits?
A: Default 10MB per file, configurable via
withFiles({ maxFileSize: ... }). The parser rejects files exceeding the limit before writing them fully to disk.
Implementation Plan
Phase 1: Server-side multipart parsing
File: dev-server/lib/action-router.ts
Update
actionBodyParser()(lines 213–251):- Check
Content-Typeheader - If
multipart/form-data: parse withbusboy- File fields → write to
{buildFolder}/.tmp/actions/{requestId}/ _jsonfield → parse as JSON- Merge into
req.body
- File fields → write to
- If
application/json: existing path (unchanged) - After handler completes: delete temp directory
- Check
Add
JayFiletype export tostack-server-runtimeAdd cleanup logic: wrap action execution in try/finally that removes the temp dir
Phase 2: Action builder withFiles()
File: full-stack-component/lib/jay-action-builder.ts
- Add
withFiles(options?)method toJayActionBuilderandJayStreamActionBuilder - Sets
acceptsFiles: trueon the action definition - Optional:
maxFileSize,maxFilesconstraints JayActionDefinitionandJayStreamActionDefinitiongetacceptsFiles?: boolean
Phase 3: Client-side FormData caller
File: stack-client-runtime/lib/action-caller.ts
- Update
createActionCallersignature to acceptoptions?: { acceptsFiles?: boolean } - When
acceptsFilesand input containsFile/Blobvalues:- Build
FormDatawith file fields +_jsonfield for text data - Don't set
Content-Type(browser auto-sets with boundary)
- Build
- Same for
createStreamCaller
Phase 4: Build transform
Update the compiler's client-side action replacement to pass acceptsFiles flag through to createActionCaller/createStreamCaller.
Key Files
| Purpose | File |
|---|---|
| Body parser middleware | dev-server/lib/action-router.ts (lines 213–251) |
| Action router handler | dev-server/lib/action-router.ts (lines 50–181) |
| Action builder | full-stack-component/lib/jay-action-builder.ts |
| Action registry | stack-server-runtime/lib/action-registry.ts |
| Client action caller | stack-client-runtime/lib/action-caller.ts |
| Client stream caller | stack-client-runtime/lib/action-caller.ts (lines 224–342) |
| Build transform | compiler/lib/ (action replacement) |
Trade-offs
- Opt-in via
withFiles(): Actions without files remain pure JSON — no parsing overhead, no temp file cleanup. The default path is unchanged. - Disk-based temp files: Avoids buffering large files in memory. Cost: filesystem I/O and cleanup responsibility.
busboydependency: Adds one dependency to the dev server. Alternative: hand-roll multipart parsing (not worth it).busboyis ~300 lines, zero deps._jsonconvention: Text fields ride alongside files in a single_jsonFormData field rather than individual form fields. Simpler parsing — one JSON blob plus N files — and preserves the existing typed input model.
Verification Criteria
- Existing JSON-only actions work identically (no regression)
- An action with
.withFiles()receivesJayFile[]with valid temp paths - Files are cleaned up after handler returns (success or error)
- A stream action with
.withFiles()can receive files and stream output - Client correctly switches between JSON and FormData based on
acceptsFiles - Files exceeding size limit are rejected with an ActionError before handler runs
Implementation Results
Phase 1: Server-side multipart parsing — completed
actionBodyParser()detectsContent-Type: multipart/form-dataand parses withbusboy- Files written to
{buildFolder}/.tmp/actions/{requestId}/temp directory _jsonfield parsed and merged with file fields intoreq.body- Cleanup via
cleanupTempDir()in action router after handler completes (try/finally pattern) - Actions without
.withFiles()reject multipart requests withFILES_NOT_ACCEPTEDerror actionBodyParseraccepts optionalregistryparameter for testing with isolated registries
Phase 2: Action builder withFiles() — completed
JayFileandFileUploadOptionstypes exported from@jay-framework/fullstack-component.withFiles(options?)added to bothJayActionBuilderandJayStreamBuilderacceptsFilesandfileOptionscarried through toJayActionDefinition/JayStreamActionDefinition- Registry stores
acceptsFilesflag on registered actions
Phase 3: Client-side FormData caller — completed
buildFormData()helper:File/Blobvalues become file fields, rest serialized as_jsonfieldhasFiles()detectsFile/Blobin input (including inside arrays)createActionCallerandcreateStreamCalleraccept{ acceptsFiles }option- When
acceptsFilesand input has files: omitsContent-Typeheader, sendsFormData - When no files present: falls through to JSON (even for
acceptsFilesactions)
Phase 4: Build transform — completed
extractActionFromExpression()detects.withFiles()in builder chain, setsacceptsFilesonActionMetadatatransformActionImports()passes{ acceptsFiles: true }to generatedcreateActionCaller/createStreamCallercalls- Virtual module strategy in
jayStackCompiler()plugin does the same
Tests — 16/16 passing (action-router), 28/28 passing (transform)
4 new e2e tests in dev-server/test/action-router.test.ts:
- File upload with JayFile — handler receives temp path, reads content, temp cleaned up after
- Reject multipart on non-file actions — 400 FILES_NOT_ACCEPTED
- JSON fallback on file actions — withFiles() actions still accept JSON
- Streaming action with file upload — multipart in, NDJSON out
4 new unit tests in compiler-jay-stack/test/transform-action-imports.test.ts:
- Extract withFiles flag from makeJayAction
- Extract withFiles flag from makeJayStream
- Transform withFiles action to createActionCaller with acceptsFiles option
- Transform withFiles stream to createStreamCaller with acceptsFiles option
Deviations from design
Race condition fix:
Promise.all(pendingWrites). The original design didn't account for busboy'scloseevent firing before file write streams finish. AddedpendingWritespromise array — each file write registers a promise, andbb.on('close')waits forPromise.all(pendingWrites)before resolving.actionBodyParseracceptsregistryoption. The original design assumed the globalactionRegistry. Tests need isolated registries, so the body parser accepts an optionalregistryparameter (defaults to global).fileOptionsaccessed via(action as any).fileOptions. TheRegisteredActionBaseinterface only storesacceptsFiles(boolean). ThefileOptions(maxFileSize, maxFiles) are read from the action definition at parse time via the registry entry. SinceRegisteredActiondoesn't exposefileOptions, the body parser casts to access it. This could be cleaned up by addingfileOptionsto the registered type.
Files changed
| File | Change |
|---|---|
full-stack-component/lib/jay-action-builder.ts |
JayFile, FileUploadOptions types; .withFiles() on both builders; acceptsFiles/fileOptions on definitions |
stack-server-runtime/lib/action-registry.ts |
acceptsFiles on RegisteredActionBase; carried in register()/registerStream() |
dev-server/lib/action-router.ts |
parseMultipart() with busboy; cleanupTempDir(); multipart detection in body parser; temp cleanup in router |
dev-server/package.json |
Added busboy + @types/busboy dependencies |
stack-client-runtime/lib/action-caller.ts |
buildFormData(), hasFiles(); acceptsFiles option on both callers |
compiler-jay-stack/lib/transform-action-imports.ts |
.withFiles() detection in builder chain; acceptsFiles on ActionMetadata |
compiler-jay-stack/lib/index.ts |
acceptsFiles in virtual module generation |
agent-kit-template/plugin/actions-guide.md |
Documentation for .withFiles(), JayFile, FileUploadOptions |
examples/jay-stack/fake-shop/src/actions/upload.actions.ts |
Example upload + streaming upload actions |
examples/jay-stack/fake-shop/src/pages/upload/ |
Example upload page (contract, jay-html, page.ts) |
Dev-server upload limits removed (2026-05)
Deviation from original Q5: Upload size/count limits and FileUploadOptions were removed from the dev-server and action builder. .withFiles() is a boolean opt-in only; limits can be reintroduced later if a production deployment surface needs them.
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.