Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/server-function-uploads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

With `enableRichArguments()`, a server function can take files and blobs anywhere in its arguments. Calls like `upload(file, { tags })`, `upload({ file })` and `upload(formData, 1)` used to fail before sending. They now send multipart form data, with each file as its own part instead of encoded inside the arguments.
2 changes: 1 addition & 1 deletion documentation/solid-2.0/10-server-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ One architectural fact worth stating, because the two directive levels land on o

The package resolves to a client entry in the browser and a server entry elsewhere.

**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s **data address** — `<endpoint>/data/<id>`, the id in a path segment — with no per-call header of their own (a read is exactly its url to caches and `<link rel="preload">`). The data address is the scripted transport’s own path, where answers are the codec’s; the bare `<endpoint>/<id>` address (a reference’s `.url`, what renders into form actions) answers plain HTTP. Two paths because the two caller kinds get differently shaped answers and shared caches key on the URL: with one shape per path, a cached answer can only ever be replayed to the caller kind it was made for. (A third caller kind, `live`'s loop, gets a third path — `<endpoint>/live/<id>`, answered as an event stream; same rule.) **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles, a top-level `undefined` — except in a bound call whose trailing argument is a body-like object (FormData, URLSearchParams, File, Blob, ...), where the leading arguments ride `?args=` and `undefined` is sent as `null` as in the action url; a trailing string is not a body in this sense, so `fn(1, undefined, "str")` goes through the codec) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing; a `live` source's response — requested at the live address — is the same stream framed as server-sent events (see `live(fn)` under the extension surface). (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s **data address** — `<endpoint>/data/<id>`, the id in a path segment — with no per-call header of their own (a read is exactly its url to caches and `<link rel="preload">`). The data address is the scripted transport’s own path, where answers are the codec’s; the bare `<endpoint>/<id>` address (a reference’s `.url`, what renders into form actions) answers plain HTTP. Two paths because the two caller kinds get differently shaped answers and shared caches key on the URL: with one shape per path, a cached answer can only ever be replayed to the caller kind it was made for. (A third caller kind, `live`'s loop, gets a third path — `<endpoint>/live/<id>`, answered as an event stream; same rule.) **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles, a top-level `undefined` — except in a bound call whose trailing argument is a body-like object (FormData, URLSearchParams, File, Blob, ...), where the leading arguments ride `?args=` and `undefined` is sent as `null` as in the action url; a trailing string is not a body in this sense, so `fn(1, undefined, "str")` goes through the codec) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. With the codec on, files and blobs can sit anywhere in the arguments: next to other arguments, or inside arrays, objects, maps, sets and `FormData` entries. Such a call sends multipart form data, with the codec output in an `args` field and each file as it is in its own `file<index>` part (`X-Server-Function-Format: 10`). A file used twice is sent once and arrives as one value, a `Blob` arrives as a `File` named `blob`, and `bodySizeLimit` bounds the whole body. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing; a `live` source's response — requested at the live address — is the same stream framed as server-sent events (see `live(fn)` under the extension surface). (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)

**Server:** `configureServerFunctionsServer({ endpoint?, codec?, provideEvent?, wrapInvocation?, collectFlightData?, transformResult?, transformDirectResult? })` plus the web-standard HTTP handler:

Expand Down
18 changes: 15 additions & 3 deletions packages/web/server-functions/src/argument-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,24 @@ function encodeRefusal(name) {

let encodeRefusals;

/** The configured codec options with encode-side refusals ahead of every plugin. */
export function argumentEncodeCodec(codec) {
/**
* The configured codec options with encode-side refusals ahead of every
* plugin. `extra` holds plugins for one call (the upload serializer), which
* go first.
*/
export function argumentEncodeCodec(codec, extra = []) {
if (!encodeRefusals) encodeRefusals = [encodeRefusal("Response"), encodeRefusal("Request")];
const refusals = encodeRefusals;
const refusals = extra.length ? [...extra, ...encodeRefusals] : encodeRefusals;
return {
...codec,
plugins: codec && codec.plugins ? [...refusals, ...codec.plugins] : refusals
};
}

/**
* Decode options from `argumentDecodeCodec` with plugins for one request
* (the upload deserializer) ahead of the resolved list.
*/
export function withArgumentPlugins(argumentCodec, extra) {
return { ...argumentCodec, [RESOLVED_PLUGINS]: [...extra, ...argumentCodec[RESOLVED_PLUGINS]] };
}
18 changes: 12 additions & 6 deletions packages/web/server-functions/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,9 +307,11 @@ export interface ServerFunctionsClientConfig {
* Encoder for argument lists JSON can't carry faithfully. JSON-safe args
* always go as plain JSON (no codec in the bundle); anything else throws
* unless this is set. Installed by `enableRichArguments()` from the
* rich-args entry — set directly only for custom wire encodings.
* rich-args entry — set directly only for custom wire encodings. A
* `FormData` answer is sent as an upload: the codec output in its `args`
* field and each file in a `file<index>` part.
*/
serializeArgs?(args: unknown[]): string | Promise<string>;
serializeArgs?(args: unknown[]): string | FormData | Promise<string | FormData>;
}

/** Wire-state transitions a live call's iterable can report. */
Expand Down Expand Up @@ -434,7 +436,7 @@ function serializeArguments(args) {
"Server function arguments are sent as JSON by default and these " +
"arguments are not JSON-serializable. Call enableRichArguments() " +
'(from "@solidjs/web/server-functions/rich-args") once at startup ' +
"to send Dates, Maps, Sets, typed arrays, etc. through the codec — " +
"to send Dates, Maps, Sets, typed arrays, files, etc. through the codec — " +
"or pass a single Blob/FormData/File argument, which has a native " +
"HTTP encoding."
);
Expand Down Expand Up @@ -760,16 +762,20 @@ async function initializeResponse(base, id, options, args, meta) {
}
}
// Everything else needs the codec, which is opt-in (enableRichArguments).
// The encoder answers `FormData` when the arguments hold files (see
// uploads.js). The browser sets that body's multipart content type.
const body = await serializeArguments(args);
const upload = body instanceof FormData;
return createRequest(
base,
id,
{
...options,
body: await serializeArguments(args),
body,
headers: {
...options.headers,
"Content-Type": "text/plain",
[BODY_FORMAT_HEADER]: BodyFormat.Serialized
...(!upload && { "Content-Type": "text/plain" }),
[BODY_FORMAT_HEADER]: upload ? BodyFormat.SerializedFormData : BodyFormat.Serialized
}
},
meta
Expand Down
15 changes: 12 additions & 3 deletions packages/web/server-functions/src/rich-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
// results as plain JSON and only rich results wake the decode half.
import { getServerFunctionsCodec, serializeString } from "./shared.js";
import { configureServerFunctionsClient } from "./client.js";
import { argumentEncodeCodec } from "./argument-codec.js"; /**
import { argumentEncodeCodec } from "./argument-codec.js";
import { createUploadBody, createUploadSerializer } from "./uploads.js"; /**
* Opt-in codec encoding for server-function ARGUMENTS. By default the client
* sends argument lists as plain JSON (no serializer in the bundle) and
* throws on values JSON can't carry faithfully. Call once at startup to
Expand All @@ -20,12 +21,20 @@ import { argumentEncodeCodec } from "./argument-codec.js"; /**
* the decode half responses already need). The handler accepts both
* encodings unconditionally. `Response` and `Request` values are refused
* (the call rejects before anything is sent): the handler never decodes
* them from arguments.
* them from arguments. Files and blobs can sit anywhere in the arguments:
* the call then sends multipart form data with each file as its own part.
*/
export function enableRichArguments(): void;

export function enableRichArguments() {
configureServerFunctionsClient({
serializeArgs: args => serializeString(args, argumentEncodeCodec(getServerFunctionsCodec()))
serializeArgs: async args => {
const files = [];
const serialized = await serializeString(
args,
argumentEncodeCodec(getServerFunctionsCodec(), [createUploadSerializer(files)])
);
return files.length ? await createUploadBody(serialized, files) : serialized;
}
});
}
26 changes: 23 additions & 3 deletions packages/web/server-functions/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ import {
argumentDecodeCodec,
namesRefusedType,
refusedArgument,
refusedArgumentError
refusedArgumentError,
withArgumentPlugins
} from "./argument-codec.js";
import { UPLOAD_ARGS_PART, createUploadDeserializer } from "./uploads.js";
import {
BODY_FORMAT_HEADER,
BodyFormat,
Expand Down Expand Up @@ -1942,7 +1944,9 @@ async function parseArguments(request, url, scripted, codec, buffered) {
// the body and never put arguments in the url.
const bodyFormat = request.method === "POST" ? request.headers.get(BODY_FORMAT_HEADER) : null;
const args = url.searchParams.get("args");
if (args && (!scripted || request.method === "GET" || bodyFormat !== BodyFormat.Serialized)) {
const codecBody =
bodyFormat === BodyFormat.Serialized || bodyFormat === BodyFormat.SerializedFormData;
if (args && (!scripted || request.method === "GET" || !codecBody)) {
// framed codec output (from the client runtime) or plain JSON (from
// integrations building urls by hand). Anything that is not an argument
// array is a malformed request, which dispatch answers as one: the query
Expand Down Expand Up @@ -2005,6 +2009,22 @@ async function parseArguments(request, url, scripted, codec, buffered) {
let argumentCodec = argumentCodecFor(codec);
if (argumentCodec instanceof Promise) argumentCodec = await argumentCodec;
decoded = await decodeArguments(wire => extractBody(body, argumentCodec, wire), buffered);
} else if (bodyFormat === BodyFormat.SerializedFormData) {
// A codec argument list with its files as form parts (see uploads.js).
// The upload plugin is made for this request only, so a file index
// means something here and nowhere else.
const form = await body.formData();
const encoded = form.get(UPLOAD_ARGS_PART);
if (typeof encoded !== "string") {
throw new TypeError("Malformed server function upload");
}
let argumentCodec = argumentCodecFor(codec);
if (argumentCodec instanceof Promise) argumentCodec = await argumentCodec;
const uploadCodec = withArgumentPlugins(argumentCodec, [createUploadDeserializer(form)]);
decoded = await decodeArguments(
wire => deserializeStream(new Response(encoded), uploadCodec, wire),
encoded
);
} else {
decoded = await extractBody(body, codec);
}
Expand All @@ -2013,7 +2033,7 @@ async function parseArguments(request, url, scripted, codec, buffered) {
// must not be the uncapped alternative (#3119). Either way the payload
// is spread into the call, so anything but an array is malformed — a
// 400, not a range error out of the function.
if (bodyFormat === BodyFormat.Serialized || bodyFormat === BodyFormat.Json) {
if (codecBody || bodyFormat === BodyFormat.Json) {
if (bodyFormat === BodyFormat.Json) assertDecodeDepth(decoded);
if (!Array.isArray(decoded)) {
throw new TypeError("Server function arguments must encode an array");
Expand Down
8 changes: 7 additions & 1 deletion packages/web/server-functions/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -972,7 +972,13 @@ export const BodyFormat = {
* on it from a refusal answered by something else. Decoding falls through
* to `undefined`, which is what a peer predating the tag reads too.
*/
Void: "9"
Void: "9",
/**
* Multipart form data for a codec argument list that holds files: the codec
* output in one field and each file in its own part (see uploads.js).
* Request leg only.
*/
SerializedFormData: "10"
};

// Nesting deeper than this is not JSON-safe. The guard itself walks an
Expand Down
Loading
Loading