diff --git a/.changeset/upload-blob-metadata.md b/.changeset/upload-blob-metadata.md new file mode 100644 index 000000000..3e2532125 --- /dev/null +++ b/.changeset/upload-blob-metadata.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Keep the metadata of files and blobs sent to server functions. 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/start/src/runtime/uploads.test.ts b/packages/start/src/runtime/uploads.test.ts index e8c85c475..e235716e4 100644 --- a/packages/start/src/runtime/uploads.test.ts +++ b/packages/start/src/runtime/uploads.test.ts @@ -1,3 +1,4 @@ +import { createPlugin, type SerovalNode } from "seroval"; import { createApp, toWebHandler } from "vinxi/http"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { serializeToJSONString } from "./serialization"; @@ -95,6 +96,59 @@ describe("server function uploads", () => { expect((second as { again: File }).again).toBe(first); }); + it("keeps the type of a blob", async () => { + const [received] = await roundTrip(new Blob(["{}"], { type: "application/json" })); + + expect(received).toBeInstanceOf(Blob); + expect((received as Blob).type).toBe("application/json"); + expect(await (received as Blob).text()).toBe("{}"); + }); + + it("keeps a blob a blob, with its empty type", async () => { + const [received] = await roundTrip(new Blob(["raw"])); + + // Every part arrives as a file, and an empty type as application/octet-stream. + expect(received).toBeInstanceOf(Blob); + expect(received).not.toBeInstanceOf(File); + expect((received as Blob).type).toBe(""); + expect(await (received as Blob).text()).toBe("raw"); + }); + + it("keeps the name and lastModified of a file", async () => { + // 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 }); + + const [received] = await roundTrip(file); + + expect((received as File).name).toBe('a"b%22.txt'); + expect((received as File).lastModified).toBe(1234); + }); + + it("reads an upload from a client that sends only the index", async () => { + // The plugin of clients before the metadata fields. + const legacy = createPlugin({ + tag: "solid-start/upload", + test: value => value instanceof Blob, + parse: { + sync: (_value, ctx) => ({ i: ctx.parse(0) }), + async: async (_value, ctx) => ({ i: await ctx.parse(0) }), + stream: (_value, ctx) => ({ i: ctx.parse(0) }) + }, + serialize: () => "", + deserialize: () => { + throw new Error("unused"); + } + }); + const form = new FormData(); + form.set(ARGS_PART, await serializeToJSONString([new File(["x"], "x.txt")], [legacy])); + form.set(`${FILE_PART}0`, new File(["x"], "x.txt")); + + const [received] = (await readUploadArgs(form)) as [File]; + + expect(received.name).toBe("x.txt"); + expect(await received.text()).toBe("x"); + }); + it("copies a blob from another frame", async () => { const source = new Blob(["other"], { type: "text/plain" }); // Not an instance of this realm's Blob, as one from another frame isn't. diff --git a/packages/start/src/runtime/uploads.ts b/packages/start/src/runtime/uploads.ts index 65dc2f5e6..73588facc 100644 --- a/packages/start/src/runtime/uploads.ts +++ b/packages/start/src/runtime/uploads.ts @@ -47,7 +47,14 @@ const UPLOAD_TAG = "solid-start/upload"; * bodies for a while, because an open tab can call a newer server. */ -type UploadNode = { i: SerovalNode }; +/** + * The index of the part, and what a multipart part does not keep: the type + * (an empty one arrives as `application/octet-stream`), whether the value was + * a blob or a file (every part arrives as a file), and the file's name (some + * characters are escaped) and `lastModified`. Clients before these fields + * send only the index. + */ +type UploadNode = { i: SerovalNode; t?: SerovalNode; n?: SerovalNode; l?: SerovalNode }; /** * True for a file or a blob. One from another frame is an instance of that @@ -70,20 +77,43 @@ function malformed(): Error { return new Error("Malformed server function upload."); } +function isFile(value: Blob): value is File { + const file = value as Partial; + return typeof file.name === "string" && typeof file.lastModified === "number"; +} + /** * Serializes each file or blob in the arguments as its index in `files`, so * the call can send it as a form part. Seroval calls this wherever it finds * one: in arrays, objects, maps, sets and form data. */ export function createUploadSerializer(files: Blob[]): Plugin { - const parse = (value: Blob) => files.push(value) - 1; + // The fields are plain values, so the sync parser works for every mode. + const parse = (value: Blob, ctx: { parse(value: unknown): SerovalNode }): UploadNode => { + const node: UploadNode = { i: ctx.parse(files.push(value) - 1), t: ctx.parse(value.type) }; + if (isFile(value)) { + node.n = ctx.parse(value.name); + node.l = ctx.parse(value.lastModified); + } + return node; + }; return createPlugin({ tag: UPLOAD_TAG, test: isBlob, parse: { - sync: (value, ctx) => ({ i: ctx.parse(parse(value)) }), - async: async (value, ctx) => ({ i: await ctx.parse(parse(value)) }), - stream: (value, ctx) => ({ i: ctx.parse(parse(value)) }) + sync: parse, + async: async (value, ctx) => { + const node: UploadNode = { + i: await ctx.parse(files.push(value) - 1), + 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: () => { @@ -122,7 +152,16 @@ export function createUploadDeserializer(form: FormData): Plugin