From e4afb976b837a9870d7a9c570749db9d8b0e940d Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Mon, 2 Nov 2026 16:56:48 +0800 Subject: [PATCH] fix(web): keep file and blob metadata in server function uploads A multipart part always arrives as a file, with `application/octet-stream` for an empty type, a `lastModified` of the time the server parsed it, and `%22` in the name decoded to `"`. The upload node now carries the type, and for a file the name and `lastModified`, and the server rebuilds the value from them over the part's bytes. A node with only the index, from an older client, still reads as the part. Ports solidjs/solid-start#2370. Co-Authored-By: Claude Opus 5.5 --- .changeset/upload-blob-metadata.md | 5 ++ packages/web/server-functions/src/uploads.ts | 44 ++++++++++- .../server/server-functions-uploads.spec.tsx | 77 +++++++++++++++++-- 3 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 .changeset/upload-blob-metadata.md diff --git a/.changeset/upload-blob-metadata.md b/.changeset/upload-blob-metadata.md new file mode 100644 index 0000000000..88b257983d --- /dev/null +++ b/.changeset/upload-blob-metadata.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Keep the metadata of files and blobs sent to server functions with `enableRichArguments()`. A blob arrives as a `Blob` instead of a `File` named `blob`, an empty type stays empty instead of `application/octet-stream`, and a file keeps its exact name and its `lastModified`. The server still reads uploads from clients that send only the part index. diff --git a/packages/web/server-functions/src/uploads.ts b/packages/web/server-functions/src/uploads.ts index 41a69bdcfb..4c21f00813 100644 --- a/packages/web/server-functions/src/uploads.ts +++ b/packages/web/server-functions/src/uploads.ts @@ -14,6 +14,13 @@ // index back into its part. Any other body decodes without that plugin, so // an index in a plain codec body fails to decode. // +// A multipart part always arrives as a file: an empty type comes back as +// `application/octet-stream`, `lastModified` is the time the server parsed +// it, and some characters in the name are escaped. So the node also holds +// the type, and for a file the name and `lastModified`, and the server +// rebuilds the value from them. Clients before these fields send only the +// index, which reads as the part. +// // Ported from SolidStart (solidjs/solid-start#2359). export const UPLOAD_ARGS_PART = "args"; @@ -37,6 +44,10 @@ function isBlob(value) { ); } +function isFile(value) { + return typeof value.name === "string" && typeof value.lastModified === "number"; +} + function malformed() { return new TypeError("Malformed server function upload"); } @@ -51,13 +62,29 @@ export function createUploadSerializer(files) { const index = files.indexOf(value); return index === -1 ? files.push(value) - 1 : index; }; + // The fields are plain values, so the sync parser works for every mode. + const parse = (value, ctx) => { + const node = { i: ctx.parse(indexOf(value)), t: ctx.parse(value.type) }; + if (isFile(value)) { + node.n = ctx.parse(value.name); + node.l = ctx.parse(value.lastModified); + } + return node; + }; 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)) }) + sync: parse, + async: async (value, ctx) => { + const node = { i: await ctx.parse(indexOf(value)), t: await ctx.parse(value.type) }; + if (isFile(value)) { + node.n = await ctx.parse(value.name); + node.l = await ctx.parse(value.lastModified); + } + return node; + }, + stream: parse }, serialize: (node, ctx) => ctx.serialize(node.i), deserialize: () => { @@ -84,7 +111,16 @@ export function createUploadDeserializer(form) { if (typeof index !== "number" || !Number.isSafeInteger(index) || index < 0) refuse(); const part = form.get(UPLOAD_FILE_PART + index); if (!isBlob(part)) refuse(); - return part; + // A client before the metadata fields sends only the index. + if (node.t === undefined) return part; + const type = ctx.deserialize(node.t); + if (typeof type !== "string") refuse(); + // The new blob or file refers to the bytes of the part instead of copying them. + if (node.n === undefined) return new Blob([part], { type }); + const name = ctx.deserialize(node.n); + const lastModified = node.l === undefined ? undefined : ctx.deserialize(node.l); + if (typeof name !== "string" || typeof lastModified !== "number") refuse(); + return new File([part], name, { type, lastModified }); } }; } diff --git a/packages/web/test/server/server-functions-uploads.spec.tsx b/packages/web/test/server/server-functions-uploads.spec.tsx index c60c51bb0d..c9f452f127 100644 --- a/packages/web/test/server/server-functions-uploads.spec.tsx +++ b/packages/web/test/server/server-functions-uploads.spec.tsx @@ -167,22 +167,27 @@ describe("files in codec arguments", () => { } }); - it("keeps a blob's type, and the blob arrives as a File named blob", async () => { - registerServerFunction("upload-blob", async (blob: Blob, label: string) => ({ + it("keeps a blob a blob, with its type", async () => { + registerServerFunction("upload-blob", async (blob: Blob, empty: Blob, label: string) => ({ isFile: blob instanceof File, - name: (blob as File).name, type: blob.type, text: await blob.text(), + emptyIsFile: empty instanceof File, + emptyType: empty.type, 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", + // Every part arrives as a file, and an empty type as application/octet-stream. + expect( + await call(new Blob(["bytes"], { type: "application/x-thing" }), new Blob(["raw"]), "l") + ).toEqual({ + isFile: false, type: "application/x-thing", text: "bytes", + emptyIsFile: false, + emptyType: "", label: "l" }); } finally { @@ -190,6 +195,22 @@ describe("files in codec arguments", () => { } }); + it("keeps the exact name and the lastModified of a file", async () => { + registerServerFunction("upload-file-meta", async (file: File, _n: number) => ({ + name: file.name, + lastModified: file.lastModified + })); + const transport = connectTransport(); + try { + const call = createServerReference("upload-file-meta"); + // The form encoder escapes a quote as %22, and the parser decodes every %22. + const file = new File(["x"], 'a"b%22.txt', { type: "text/plain", lastModified: 1234 }); + expect(await call(file, 1)).toEqual({ name: 'a"b%22.txt', lastModified: 1234 }); + } 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, @@ -312,3 +333,47 @@ describe("malformed uploads", () => { expect(ran()).toBe(1); }); }); + +describe("uploads from older clients", () => { + it("reads a node with only the index as the part", async () => { + let received: { name: string; text: string } | undefined; + registerServerFunction("upload-legacy", async (file: File, _n: number) => { + received = { name: file.name, text: await file.text() }; + return "ok"; + }); + const transport = connectTransport(); + try { + const call = createServerReference("upload-legacy"); + expect(await call(new File(["old"], "old.txt", { type: "text/plain" }), 1)).toBe("ok"); + } finally { + transport.restore(); + } + const request = transport.requests[0]; + const form = await request.clone().formData(); + // Drop the metadata fields from the upload node, as older clients never wrote them. + let stripped = 0; + const strip = (value: unknown) => { + if (typeof value !== "object" || value === null) return; + const node = value as Record; + if (node.c === "solid/server-function-upload" && node.s) { + for (const key of ["t", "n", "l"]) { + if (key in node.s) { + delete node.s[key]; + stripped++; + } + } + } + for (const key in node) strip(node[key]); + }; + // The args part is one frame: `;0x` and the byte length in 8 hex digits, `;`, then JSON. + const args = JSON.parse((form.get("args") as string).slice(12)); + strip(args); + expect(stripped).toBe(3); + const json = JSON.stringify(args); + const length = new TextEncoder().encode(json).length.toString(16).padStart(8, "0"); + form.set("args", `;0x${length};${json}`); + received = undefined; + expect((await resend(request, form)).status).toBe(200); + expect(received).toEqual({ name: "old.txt", text: "old" }); + }); +});