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/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.
54 changes: 54 additions & 0 deletions packages/start/src/runtime/uploads.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<Blob, { i: SerovalNode }>({
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.
Expand Down
51 changes: 45 additions & 6 deletions packages/start/src/runtime/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<File>;
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<Blob, UploadNode> {
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<Blob, UploadNode>({
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: () => {
Expand Down Expand Up @@ -122,7 +152,16 @@ export function createUploadDeserializer(form: FormData): Plugin<Blob, UploadNod
}
const part = form.get(`${FILE_PART}${index}`);
if (!(part instanceof Blob)) throw malformed();
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") throw malformed();
// 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") throw malformed();
return new File([part], name, { type, lastModified });
}
});
}
Expand Down
Loading