diff --git a/.changeset/server-function-uploads.md b/.changeset/server-function-uploads.md new file mode 100644 index 000000000..3e895ca11 --- /dev/null +++ b/.changeset/server-function-uploads.md @@ -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. diff --git a/documentation/solid-2.0/10-server-functions.md b/documentation/solid-2.0/10-server-functions.md index 4e8f381db..9306a47f7 100644 --- a/documentation/solid-2.0/10-server-functions.md +++ b/documentation/solid-2.0/10-server-functions.md @@ -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** — `/data/`, the id in a path segment — with no per-call header of their own (a read is exactly its url to caches and ``). The data address is the scripted transport’s own path, where answers are the codec’s; the bare `/` 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 — `/live/`, 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** — `/data/`, the id in a path segment — with no per-call header of their own (a read is exactly its url to caches and ``). The data address is the scripted transport’s own path, where answers are the codec’s; the bare `/` 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 — `/live/`, 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` 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: diff --git a/packages/web/server-functions/src/argument-codec.ts b/packages/web/server-functions/src/argument-codec.ts index 7c20882d1..f71403c9f 100644 --- a/packages/web/server-functions/src/argument-codec.ts +++ b/packages/web/server-functions/src/argument-codec.ts @@ -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]] }; +} diff --git a/packages/web/server-functions/src/client.ts b/packages/web/server-functions/src/client.ts index 17b5c2929..476a1f3d0 100644 --- a/packages/web/server-functions/src/client.ts +++ b/packages/web/server-functions/src/client.ts @@ -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` part. */ - serializeArgs?(args: unknown[]): string | Promise; + serializeArgs?(args: unknown[]): string | FormData | Promise; } /** Wire-state transitions a live call's iterable can report. */ @@ -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." ); @@ -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 diff --git a/packages/web/server-functions/src/rich-args.ts b/packages/web/server-functions/src/rich-args.ts index 09ff9c2bc..45b12fcc6 100644 --- a/packages/web/server-functions/src/rich-args.ts +++ b/packages/web/server-functions/src/rich-args.ts @@ -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 @@ -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; + } }); } diff --git a/packages/web/server-functions/src/server.ts b/packages/web/server-functions/src/server.ts index 3ec6c2631..c38980820 100644 --- a/packages/web/server-functions/src/server.ts +++ b/packages/web/server-functions/src/server.ts @@ -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, @@ -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 @@ -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); } @@ -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"); diff --git a/packages/web/server-functions/src/shared.ts b/packages/web/server-functions/src/shared.ts index 7234c5835..4ecf35366 100644 --- a/packages/web/server-functions/src/shared.ts +++ b/packages/web/server-functions/src/shared.ts @@ -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 diff --git a/packages/web/server-functions/src/uploads.ts b/packages/web/server-functions/src/uploads.ts new file mode 100644 index 000000000..f2cdf054f --- /dev/null +++ b/packages/web/server-functions/src/uploads.ts @@ -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` part, as it is, without +// base64. +// The codec output holds only each file's index. `BodyFormat.SerializedFormData` +// marks the request. +// +// 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; +} diff --git a/packages/web/test/server/server-functions-format-version-skew.spec.tsx b/packages/web/test/server/server-functions-format-version-skew.spec.tsx index 0e9b6f77f..429bb13a4 100644 --- a/packages/web/test/server/server-functions-format-version-skew.spec.tsx +++ b/packages/web/test/server/server-functions-format-version-skew.spec.tsx @@ -40,7 +40,7 @@ import * as devServer from "../../server-functions/dist/server.dev.js"; const RequestContext = Symbol.for("solid.RequestContext"); const BODY_FORMAT_HEADER = "X-Server-Function-Format"; /** The next tag the runtime ships — this build has no case for it. */ -const FUTURE_FORMAT = "10"; +const FUTURE_FORMAT = "11"; beforeAll(() => { (globalThis as any)[RequestContext] = new AsyncLocalStorage(); diff --git a/packages/web/test/server/server-functions-uploads.spec.tsx b/packages/web/test/server/server-functions-uploads.spec.tsx new file mode 100644 index 000000000..e906e23a8 --- /dev/null +++ b/packages/web/test/server/server-functions-uploads.spec.tsx @@ -0,0 +1,315 @@ +/** + * Files and blobs anywhere in the arguments of a codec call. With + * `enableRichArguments()`, a call whose arguments hold files sends multipart + * form data: the codec output in the `args` field and each file in its own + * `file` part. The handler decodes `args` with a plugin made for that + * request, which turns each index back into its part. + * + * Ported from SolidStart (solidjs/solid-start#2359). Like the other + * server-function specs, these run against the built bundles. + */ +import { AsyncLocalStorage } from "node:async_hooks"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + handleServerFunctionRequest, + registerServerFunction +} from "@solidjs/web/server-functions/server"; +import { createServerReference } from "@solidjs/web/server-functions/client"; +// @ts-ignore — the rich-args dist has no adjacent type declarations. +import { enableRichArguments } from "../../server-functions/dist/rich-args.js"; + +const RequestContext = Symbol.for("solid.RequestContext"); +const BODY_FORMAT_HEADER = "X-Server-Function-Format"; +const SERIALIZED_FORMAT = "0"; +const UPLOAD_FORMAT = "10"; + +beforeAll(() => { + (globalThis as any)[RequestContext] = new AsyncLocalStorage(); + enableRichArguments(); +}); + +afterAll(() => { + delete (globalThis as any)[RequestContext]; +}); + +/** Routes the client's fetch into the built handler and keeps each request. */ +function connectTransport() { + const original = globalThis.fetch; + const requests: Request[] = []; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const request = + input instanceof Request + ? input + : new Request(new URL(input.toString(), "https://app.example"), init); + request.headers.set("Sec-Fetch-Site", "same-origin"); + requests.push(request.clone()); + return handleServerFunctionRequest(request); + }) as typeof fetch; + return { + requests, + restore() { + globalThis.fetch = original; + } + }; +} + +/** A request to the same address as `captured`, with `form` as its body. */ +function resend(captured: Request, form: FormData | string, format = UPLOAD_FORMAT) { + const headers: Record = { + [BODY_FORMAT_HEADER]: format, + "Sec-Fetch-Site": "same-origin" + }; + if (typeof form === "string") headers["Content-Type"] = "text/plain"; + return handleServerFunctionRequest( + new Request(captured.url, { method: "POST", body: form, headers }) + ); +} + +const describeFile = async (value: unknown) => + value instanceof File + ? { file: value.name, type: value.type, text: await value.text() } + : { notAFile: String(value) }; + +describe("files in codec arguments", () => { + it("sends a file next to other arguments as its own form part", async () => { + registerServerFunction("upload-meta", async (file: File, meta: { tags: string[] }) => ({ + ...(await describeFile(file)), + tags: meta.tags + })); + const transport = connectTransport(); + try { + const call = createServerReference("upload-meta"); + const result = await call(new File(["hello"], "a.txt", { type: "text/plain" }), { + tags: ["x"] + }); + expect(result).toEqual({ file: "a.txt", type: "text/plain", text: "hello", tags: ["x"] }); + const [request] = transport.requests; + expect(request.headers.get(BODY_FORMAT_HEADER)).toBe(UPLOAD_FORMAT); + expect(request.headers.get("content-type")).toMatch(/^multipart\/form-data/); + const form = await request.formData(); + expect(typeof form.get("args")).toBe("string"); + // The bytes go in the part as they are, not base64 inside the codec output. + expect(await (form.get("file0") as File).text()).toBe("hello"); + expect(form.get("args")).not.toContain("hello"); + } finally { + transport.restore(); + } + }); + + it("finds files in arrays, objects, maps, sets and FormData entries", async () => { + registerServerFunction( + "upload-nested", + async ( + list: File[], + record: { doc: { file: File } }, + map: Map, + set: Set, + form: FormData, + n: number + ) => ({ + list: await Promise.all(list.map(describeFile)), + record: await describeFile(record.doc.file), + map: await describeFile(map.get("k")), + set: await describeFile([...set][0]), + form: await describeFile(form.get("doc")), + field: form.get("name"), + n + }) + ); + const transport = connectTransport(); + try { + const call = createServerReference("upload-nested"); + const f = (text: string) => new File([text], `${text}.txt`, { type: "text/plain" }); + const form = new FormData(); + form.set("doc", f("form")); + form.set("name", "plain field"); + const result = await call( + [f("a"), f("b")], + { doc: { file: f("record") } }, + new Map([["k", f("map")]]), + new Set([f("set")]), + form, + 7 + ); + expect(result).toEqual({ + list: [ + { file: "a.txt", type: "text/plain", text: "a" }, + { file: "b.txt", type: "text/plain", text: "b" } + ], + record: { file: "record.txt", type: "text/plain", text: "record" }, + map: { file: "map.txt", type: "text/plain", text: "map" }, + set: { file: "set.txt", type: "text/plain", text: "set" }, + form: { file: "form.txt", type: "text/plain", text: "form" }, + field: "plain field", + n: 7 + }); + const sent = await transport.requests[0].formData(); + expect([...sent.keys()].filter(key => key.startsWith("file"))).toHaveLength(6); + } finally { + transport.restore(); + } + }); + + it("sends a file used twice once, and it arrives as one value", async () => { + registerServerFunction("upload-twice", async (a: File, holder: { again: File }) => ({ + same: a === holder.again, + text: await a.text() + })); + const transport = connectTransport(); + try { + const call = createServerReference("upload-twice"); + const file = new File(["once"], "once.txt"); + expect(await call(file, { again: file })).toEqual({ same: true, text: "once" }); + const sent = await transport.requests[0].formData(); + expect(sent.has("file0")).toBe(true); + expect(sent.has("file1")).toBe(false); + } finally { + transport.restore(); + } + }); + + it("keeps a blob's type, and the blob arrives as a File named blob", async () => { + registerServerFunction("upload-blob", async (blob: Blob, label: string) => ({ + isFile: blob instanceof File, + name: (blob as File).name, + type: blob.type, + text: await blob.text(), + label + })); + const transport = connectTransport(); + try { + const call = createServerReference("upload-blob"); + expect(await call(new Blob(["bytes"], { type: "application/x-thing" }), "l")).toEqual({ + isFile: true, + name: "blob", + type: "application/x-thing", + text: "bytes", + label: "l" + }); + } finally { + transport.restore(); + } + }); + + it("copies a blob from another frame, found by its shape", async () => { + registerServerFunction("upload-foreign", async (holder: { blob: Blob }) => ({ + type: holder.blob.type, + text: await holder.blob.text() + })); + const transport = connectTransport(); + try { + const call = createServerReference("upload-foreign"); + // Not an instance of this realm's Blob, as a blob from an iframe is not. + const real = new Blob(["far"], { type: "text/x-far" }); + const foreign = { + size: real.size, + type: real.type, + arrayBuffer: () => real.arrayBuffer(), + slice: (...args: any[]) => real.slice(...args) + }; + expect(await call({ blob: foreign })).toEqual({ type: "text/x-far", text: "far" }); + expect(transport.requests[0].headers.get(BODY_FORMAT_HEADER)).toBe(UPLOAD_FORMAT); + } finally { + transport.restore(); + } + }); + + it("sends a call without files as codec text, as before", async () => { + registerServerFunction("upload-none", async (date: Date) => date.toISOString()); + const transport = connectTransport(); + try { + const call = createServerReference("upload-none"); + expect(await call(new Date(0))).toBe("1970-01-01T00:00:00.000Z"); + expect(transport.requests[0].headers.get(BODY_FORMAT_HEADER)).toBe(SERIALIZED_FORMAT); + } finally { + transport.restore(); + } + }); + + it("still refuses a Response next to a file, before sending", async () => { + registerServerFunction("upload-refused", async () => "ran"); + const transport = connectTransport(); + try { + const call = createServerReference("upload-refused"); + await expect(call(new File(["x"], "x.txt"), new Response("no"))).rejects.toThrow( + /cannot carry Response or Request/ + ); + expect(transport.requests).toHaveLength(0); + } finally { + transport.restore(); + } + }); + + it("applies bodySizeLimit to the whole upload", async () => { + let ran = 0; + registerServerFunction("upload-limit", async () => ++ran); + const transport = connectTransport(); + try { + const call = createServerReference("upload-limit"); + expect(await call(new File(["x".repeat(4096)], "big.txt"), 1)).toBe(1); + } finally { + transport.restore(); + } + // The same upload as bytes. Refusing an in-memory FormData body part way + // leaves undici writing to a closed stream, which is a test artifact. + const sent = transport.requests[0]; + const response = await handleServerFunctionRequest( + new Request(sent.url, { + method: "POST", + body: await sent.arrayBuffer(), + headers: sent.headers + }), + { bodySizeLimit: 1024 } + ); + expect(response.status).toBe(413); + expect(ran).toBe(1); + }); +}); + +describe("malformed uploads", () => { + // A real upload body, captured, so each case changes one thing about it. + async function capture() { + let ran = 0; + registerServerFunction("upload-target", async (file: File, n: number) => { + ran++; + return [await file.text(), n]; + }); + const transport = connectTransport(); + try { + const call = createServerReference("upload-target"); + expect(await call(new File(["ok"], "ok.txt"), 1)).toEqual(["ok", 1]); + } finally { + transport.restore(); + } + const request = transport.requests[0]; + return { request, form: await request.clone().formData(), ran: () => ran }; + } + + it("refuses a body without the args field", async () => { + const { request, form, ran } = await capture(); + form.delete("args"); + expect((await resend(request, form)).status).toBe(400); + expect(ran()).toBe(1); + }); + + it("refuses an index with no part", async () => { + const { request, form, ran } = await capture(); + form.delete("file0"); + expect((await resend(request, form)).status).toBe(400); + expect(ran()).toBe(1); + }); + + it("refuses a part that is not a file", async () => { + const { request, form, ran } = await capture(); + form.set("file0", "just text"); + expect((await resend(request, form)).status).toBe(400); + expect(ran()).toBe(1); + }); + + it("refuses a file index in a plain codec body", async () => { + const { request, form, ran } = await capture(); + const args = form.get("args") as string; + expect((await resend(request, args, SERIALIZED_FORMAT)).status).toBe(400); + expect(ran()).toBe(1); + }); +});