Skip to content
Merged
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 (still `X-Server-Function-Format: 0`; the `multipart/form-data` content type marks the upload). 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]] };
}
13 changes: 9 additions & 4 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,6 +762,10 @@ 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). No content type is set here: fetch gives a string body
// `text/plain` and a form its multipart boundary, which is how the handler
// tells the two apart.
return createRequest(
base,
id,
Expand All @@ -768,7 +774,6 @@ async function initializeResponse(base, id, options, args, meta) {
body: await serializeArguments(args),
headers: {
...options.headers,
"Content-Type": "text/plain",
[BODY_FORMAT_HEADER]: BodyFormat.Serialized
}
},
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;
}
});
}
24 changes: 22 additions & 2 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 @@ -2004,7 +2006,25 @@ async function parseArguments(request, url, scripted, codec, buffered) {
if (bodyFormat === BodyFormat.Serialized) {
let argumentCodec = argumentCodecFor(codec);
if (argumentCodec instanceof Promise) argumentCodec = await argumentCodec;
decoded = await decodeArguments(wire => extractBody(body, argumentCodec, wire), buffered);
if ((request.headers.get("content-type") || "").startsWith("multipart/form-data")) {
// A codec argument list with its files as form parts (see uploads.js).
// The tag stays `Serialized` so the client runtime pays nothing for
// uploads; the multipart content type is what marks one. 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");
}
const uploadCodec = withArgumentPlugins(argumentCodec, [createUploadDeserializer(form)]);
decoded = await decodeArguments(
wire => deserializeStream(new Response(encoded), uploadCodec, wire),
encoded
);
} else {
decoded = await decodeArguments(wire => extractBody(body, argumentCodec, wire), buffered);
}
} else {
decoded = await extractBody(body, codec);
}
Expand Down
108 changes: 108 additions & 0 deletions packages/web/server-functions/src/uploads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// @ts-nocheck
// Files and blobs in server function arguments, on the codec path.
//
// The client sends a call whose arguments hold files as multipart form data:
// - the codec output in the `UPLOAD_ARGS_PART` field;
// - each file in its own `UPLOAD_FILE_PART<index>` part, as it is, without
// base64.
// The codec output holds only each file's index. The request keeps the
// `Serialized` tag; its multipart content type is what marks an upload.
//
// A seroval plugin made for the call finds the files wherever the codec
// meets one: arguments, arrays, objects, maps, sets, and `FormData` entries.
// The server decodes with a plugin made for the request, which turns each
// index back into its part. Any other body decodes without that plugin, so
// an index in a plain codec body fails to decode.
//
// Ported from SolidStart (solidjs/solid-start#2359).

export const UPLOAD_ARGS_PART = "args";
export const UPLOAD_FILE_PART = "file";

const UPLOAD_TAG = "solid/server-function-upload";

/**
* True for a file or a blob. One from another frame is an instance of that
* frame's `Blob`, so its shape tells instead.
*/
function isBlob(value) {
if (typeof Blob !== "undefined" && value instanceof Blob) return true;
return (
typeof value === "object" &&
value !== null &&
typeof value.size === "number" &&
typeof value.type === "string" &&
typeof value.arrayBuffer === "function" &&
typeof value.slice === "function"
);
}

function malformed() {
return new TypeError("Malformed server function upload");
}

/**
* The client half. Each file or blob in the arguments serializes as its
* index in `files`. A file that appears twice is pushed once, so it arrives
* as one value.
*/
export function createUploadSerializer(files) {
const indexOf = value => {
const index = files.indexOf(value);
return index === -1 ? files.push(value) - 1 : index;
};
return {
tag: UPLOAD_TAG,
test: isBlob,
parse: {
sync: (value, ctx) => ({ i: ctx.parse(indexOf(value)) }),
async: async (value, ctx) => ({ i: await ctx.parse(indexOf(value)) }),
stream: (value, ctx) => ({ i: ctx.parse(indexOf(value)) })
},
serialize: (node, ctx) => ctx.serialize(node.i),
deserialize: () => {
throw malformed();
}
};
}

/**
* The server half, made for one request. Each index turns back into its
* part of `form`. A bad index or a missing part fails the decode.
*/
export function createUploadDeserializer(form) {
const refuse = () => {
throw malformed();
};
return {
tag: UPLOAD_TAG,
test: () => false,
parse: { sync: refuse, async: refuse, stream: refuse },
serialize: refuse,
deserialize: (node, ctx) => {
const index = ctx.deserialize(node.i);
if (typeof index !== "number" || !Number.isSafeInteger(index) || index < 0) refuse();
const part = form.get(UPLOAD_FILE_PART + index);
if (!isBlob(part)) refuse();
return part;
}
};
}

/**
* The request body for a call with files. `FormData` only takes blobs of its
* own frame, so a blob from another frame is copied first.
*/
export async function createUploadBody(serialized, files) {
const form = new FormData();
form.set(UPLOAD_ARGS_PART, serialized);
const blobs = await Promise.all(
files.map(async file =>
file instanceof Blob ? file : new Blob([await file.arrayBuffer()], { type: file.type })
)
);
for (let i = 0; i < blobs.length; i++) {
form.set(UPLOAD_FILE_PART + i, blobs[i], files[i].name ?? "blob");
}
return form;
}
Loading
Loading