Skip to content

feat: send files anywhere in server function arguments - #2359

Open
lxsmnsyc wants to merge 1 commit into
mainfrom
feat/server-function-file-uploads
Open

lxsmnsyc wants to merge 1 commit into
mainfrom
feat/server-function-file-uploads

Conversation

@lxsmnsyc

@lxsmnsyc lxsmnsyc commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Problem

A file or a blob only reaches a server function as its only argument. Anywhere else, the call fails before it's sent:

await upload(file, { tags: ["a"] });   // SerovalUnsupportedTypeError
await upload({ file });                // SerovalUnsupportedTypeError
await upload(formData, 1);             // SerovalUnsupportedTypeError

Calls with more than one argument, or with a file inside a value, go through seroval. Start's plugin list has no plugin for Blob or File.

Change

When the arguments hold files, the client sends multipart form data:

  • The serialized arguments go in the args field. Each file goes in its own file<i> part, so it's sent as it is, without base64.
  • The new BodyFormat.SerovalFormData ("8") marks the request.

The files are found by a seroval plugin created for the call (createUploadSerializer). It replaces each file or blob with the index of its part wherever seroval finds it: arguments, arrays, objects, Map, Set, and FormData entries.

  • A file that appears twice is sent once and arrives as the same value.
  • A blob from another frame is detected by its shape and copied, because FormData only takes its own realm's blobs.

On the server, extractBody reads the form and deserializes args with the usual chunk size limit and a plugin created for that request (createUploadDeserializer), which turns each index back into its part.

  • A missing part, a bad index, or a missing args field is rejected.
  • Other bodies are read without that plugin, so an index in a plain seroval body fails to deserialize instead of meaning anything.

Calls without files are sent exactly as before, and the single-argument fast paths (FormData, File, Blob, …) are unchanged. serializeToJSONString, serializeToJSONStream and deserializeJSONStream take per-call plugins, tried before the built-in and user plugins.

This follows the upload design of brumate's server functions. The difference is that a seroval plugin finds the files, instead of a walk over arrays and plain objects.

Limits

  • The whole body is read with request.formData(), as the existing single-FormData path does. There is no total size limit.
  • Files arrive as File values. A Blob comes back as a File named blob, with its type kept.
  • GET server functions put their arguments in the URL, so they still can't carry files.

Note for later

uploads.ts has a comment comparing this with seroval's binary mode (in development on seroval's binary-mode branch):

  • Binary mode would be better for the arguments in general. It's one byte format, sends raw buffer bytes, and validates its input.
  • Form parts are still better for files. BlobPlugin and FilePlugin read the whole file with arrayBuffer() before sending, and sending binary output as it's produced needs a streamed request body (duplex: "half"), which not every browser supports. A File in FormData is read by the browser as it sends.

A migration can move the arguments to binary mode and keep files as parts. Servers should accept both formats for a while.

Verification

  • 10 new unit tests in fns/uploads.spec.ts. They do a full client-to-extractBody round trip with: a file plus metadata, files in arrays/maps/sets, FormData as one of several arguments, a file used twice, a blob's type, a cross-realm blob, a call without files staying as text, and the three rejections.
  • New server-function-upload route plus a Playwright test. It sends a file, an object holding blobs, and a FormData with a file in one call. The test fails without this change.
  • packages/start: 276 tests, typecheck, build, format check.
  • apps/tests: unit, Playwright default (45), bundled-dev (45) and base (3) all pass.

The 1.x version is #2360.

🤖 Generated with Claude Code

A file or a blob only reached a server function as its only argument. Next
to other arguments, or inside an object, the call failed with
`SerovalUnsupportedTypeError`, because the serializer has no plugin for
them.

When the arguments hold files, the client now sends multipart form data:
the serialized arguments in one field, and each file in its own part. A
seroval plugin for the call replaces each file with its part's index,
wherever seroval finds it. On the server, a plugin created for that
request turns the indexes back into the parts, and rejects a missing part
or a bad index. Other bodies are read without it, so an index means
nothing there. Calls without files are sent as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 03d48c2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@solidjs/start Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@netlify

netlify Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for solid-start-landing-page ready!

Name Link
🔨 Latest commit 03d48c2
🔍 Latest deploy log https://app.netlify.com/projects/solid-start-landing-page/deploys/6ad822ba5320dc4d5d1f8390
😎 Deploy Preview https://deploy-preview-2359--solid-start-landing-page.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@pkg-pr-new

pkg-pr-new Bot commented Oct 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@solidjs/start@2359

commit: 03d48c2

lxsmnsyc added a commit to lxsmnsyc/solid that referenced this pull request Oct 21, 2026
With enableRichArguments(), a call whose arguments hold files or blobs
used to fail before sending, unless the file was the only argument or
the last one. Ports solidjs/solid-start#2359 to the core transport.

- The rich-args encoder finds each file with a seroval plugin made for
  the call, wherever the codec meets it: arguments, arrays, objects,
  maps, sets and FormData entries.
- A call with files sends multipart form data. The codec output goes in
  the `args` field and each file goes as it is in a `file<index>` part.
- The new BodyFormat.SerializedFormData ("10") marks the request.
- The server decodes `args` with a plugin made for that request, which
  turns each index back into its part. Other bodies decode without it.
- A missing `args` field, a bad index or a missing part is refused with
  400. bodySizeLimit bounds the whole body.
- Default JSON mode is unchanged. Upload code ships only in the
  rich-args entry.
- The version skew spec moves its unknown tag from "10" to "11".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ryansolid added a commit to solidjs/solid that referenced this pull request Oct 21, 2026
Ports solidjs/solid-start#2359 (from #4177 by @lxsmnsyc), with uploads kept off the core client request path: they keep the Serialized tag and the handler spots them by their multipart content type.

Co-authored-by: Alexis H. Munsayac <alexis.munsayac@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant