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/upload-blob-metadata.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 40 additions & 4 deletions packages/web/server-functions/src/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
}
Expand All @@ -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: () => {
Expand All @@ -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 });
}
};
}
Expand Down
77 changes: 71 additions & 6 deletions packages/web/test/server/server-functions-uploads.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,29 +167,50 @@ 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 {
transport.restore();
}
});

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,
Expand Down Expand Up @@ -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<string, any>;
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" });
});
});
Loading