Skip to content

Commit 45cb05c

Browse files
lxsmnsycclaude
andcommitted
feat: send files anywhere in server function arguments
A file only reached a server function inside a `FormData` that was the only argument. Anywhere else, including as the only argument, the call failed with `SerovalUnsupportedTypeError`, because the serializer has no plugin for files. When the arguments hold files, the client now sends multipart form data: the serialized arguments in one field, and each file in its own part, with the `x-serialized-form` header. A seroval plugin for the call replaces each file with its part's index, wherever seroval finds it. On the server, a plugin created for that request turns the indexes back into the parts, and rejects a missing part or a bad index. Other bodies are read without it, so an index means nothing there. Calls without files are sent as before. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent e2008e2 commit 45cb05c

8 files changed

Lines changed: 410 additions & 16 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/start": minor
3+
---
4+
5+
Let server functions take files and blobs anywhere in their arguments, not only inside a `FormData` that is the only argument. A call like `upload(file, { tags })`, or one with files in arrays, objects, maps, sets or form data, sends them as parts of multipart form data next to the serialized arguments, and the server function gets them as `File` values. Calls without files are sent as before.

‎apps/tests/cypress/e2e/server-function.cy.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ describe("server-function", () => {
2020
cy.get("#server-fn-test").contains('{"serverFnWithNpmModule":[2,4,6]}');
2121
});
2222

23+
it("should send files next to other arguments", () => {
24+
cy.visit("/server-function-upload");
25+
cy.get("#server-fn-test").contains(
26+
JSON.stringify({
27+
name: "hello-world.txt",
28+
text: "Hello, World!",
29+
tags: ["a", "b"],
30+
attachments: ["one", "two"],
31+
avatar: "avatar.png:png"
32+
})
33+
);
34+
});
35+
2336
it("should have isServer true in the server function - toplevel", () => {
2437
cy.visit("/is-server-toplevel");
2538
cy.get("#server-fn-test").contains('{"serverFnWithIsServer":true}');
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { createEffect, createSignal } from "solid-js";
2+
3+
async function upload(file: File, meta: { tags: string[]; attachments: Blob[] }, form: FormData) {
4+
"use server";
5+
const attachments = await Promise.all(meta.attachments.map(blob => blob.text()));
6+
const avatar = form.get("avatar") as File;
7+
return {
8+
name: file.name,
9+
text: await file.text(),
10+
tags: meta.tags,
11+
attachments,
12+
avatar: `${avatar.name}:${await avatar.text()}`
13+
};
14+
}
15+
16+
export default function App() {
17+
const [output, setOutput] = createSignal<unknown>();
18+
19+
createEffect(async () => {
20+
const form = new FormData();
21+
form.set("avatar", new File(["png"], "avatar.png", { type: "image/png" }));
22+
setOutput(
23+
await upload(
24+
new File(["Hello, World!"], "hello-world.txt"),
25+
{ tags: ["a", "b"], attachments: [new Blob(["one"]), new Blob(["two"])] },
26+
form
27+
)
28+
);
29+
});
30+
31+
return (
32+
<main>
33+
<span id="server-fn-test">{JSON.stringify(output())}</span>
34+
</main>
35+
);
36+
}

‎packages/start/src/runtime/serialization.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
fromCrossJSON,
66
getCrossReferenceHeader,
77
isStream,
8+
type Plugin,
89
type SerovalJSON,
910
type SerovalNode,
1011
toCrossJSONStream,
@@ -69,6 +70,14 @@ function createChunk(data: string): Uint8Array {
6970
return chunk;
7071
}
7172

73+
/**
74+
* Plugins for one call, such as the ones that carry files as form parts. They
75+
* come first, so the values they handle never reach the other plugins.
76+
*/
77+
function withPlugins(extra: Plugin<any, any>[] | undefined) {
78+
return extra?.length ? [...extra, ...DEFAULT_PLUGINS] : DEFAULT_PLUGINS;
79+
}
80+
7281
export function serializeToJSStream(id: string, value: any) {
7382
return new ReadableStream({
7483
start(controller) {
@@ -204,11 +213,11 @@ class SerovalChunkReader {
204213
}
205214
}
206215

207-
export async function serializeToJSONString(value: any) {
216+
export async function serializeToJSONString(value: any, plugins?: Plugin<any, any>[]) {
208217
// const response = new Response(serializeToJSONStream(value));
209218
// return await response.text();
210219
return JSON.stringify(await toJSONAsync(value, {
211-
plugins: DEFAULT_PLUGINS,
220+
plugins: withPlugins(plugins),
212221
depthLimit: MAX_SERIALIZATION_DEPTH_LIMIT,
213222
disabledFeatures: DISABLED_FEATURES,
214223
}));
@@ -275,14 +284,14 @@ function createEndOfStreamError() {
275284
* is used because it exposes its refs, and it reads the same nodes `toJSONAsync`
276285
* emits.
277286
*/
278-
export async function deserializeFromJSONString(json: string) {
287+
export async function deserializeFromJSONString(json: string, plugins?: Plugin<any, any>[]) {
279288
const source = JSON.parse(json) as SerovalJSON;
280289
const refs = new DecodedRefs();
281290
let value: unknown;
282291
try {
283292
value = fromCrossJSON(source.t, {
284293
refs,
285-
plugins: DEFAULT_PLUGINS,
294+
plugins: withPlugins(plugins),
286295
disabledFeatures: DISABLED_FEATURES,
287296
});
288297
} catch (error) {

‎packages/start/src/runtime/server-handler.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import serverFnManifest from "solidstart:server-fn-manifest";
2121
import { setThrownCacheControl, withDefaultCacheControl } from "./cache-control";
2222
import { isCrossSiteRequest } from "./cross-site";
2323
import { deserializeFromJSONString, serializeToJSONStream, serializeToJSStream } from "./serialization";
24+
import { readUploadArgs, UPLOAD_HEADER } from "./uploads";
2425

2526
async function handleServerFunction(h3Event: HTTPEvent) {
2627
let response: Awaited<ReturnType<typeof runServerFunction>>;
@@ -120,7 +121,9 @@ async function runServerFunction(h3Event: HTTPEvent) {
120121
const tmpReq = isH3EventBodyStreamLocked
121122
? request
122123
: new Request(request, { ...request, body: requestBody });
123-
if (request.headers.get('x-serialized')) {
124+
if (request.headers.get(UPLOAD_HEADER)) {
125+
parsed = (await readUploadArgs(await tmpReq.formData())) as any[];
126+
} else if (request.headers.get('x-serialized')) {
124127
parsed = await deserializeFromJSONString(await tmpReq.text()) as any[];
125128
} else if (
126129
contentType?.startsWith("multipart/form-data") ||

‎packages/start/src/runtime/server-runtime.ts‎

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
deserializeJSStream,
66
serializeToJSONString,
77
} from "./serialization";
8+
import { appendUploads, ARGS_PART, createUploadSerializer, UPLOAD_HEADER } from "./uploads";
89

910
let INSTANCE = 0;
1011

@@ -25,6 +26,42 @@ function createRequest(
2526
});
2627
}
2728

29+
async function createSerializedRequest(
30+
base: string,
31+
id: string,
32+
instance: string,
33+
options: RequestInit,
34+
args: any[],
35+
) {
36+
const files: Blob[] = [];
37+
const serialized = await serializeToJSONString(args, [createUploadSerializer(files)]);
38+
if (files.length > 0) {
39+
// Files go as form parts, so they are sent as they are instead of encoded.
40+
const formData = new FormData();
41+
formData.set(ARGS_PART, serialized);
42+
await appendUploads(formData, files);
43+
return createRequest(base, id, instance, {
44+
...options,
45+
body: formData,
46+
headers: {
47+
...options.headers,
48+
[UPLOAD_HEADER]: "true",
49+
},
50+
});
51+
}
52+
return createRequest(base, id, instance, {
53+
...options,
54+
body: serialized,
55+
// duplex: 'half',
56+
// body: serializeToJSONStream(args),
57+
headers: {
58+
...options.headers,
59+
"x-serialized": "true",
60+
"Content-Type": "text/plain"
61+
},
62+
});
63+
}
64+
2865
async function fetchServerFunction(
2966
base: string,
3067
id: string,
@@ -45,17 +82,7 @@ async function fetchServerFunction(
4582
"Content-Type": "application/x-www-form-urlencoded",
4683
},
4784
})
48-
: createRequest(base, id, instance, {
49-
...options,
50-
body: await serializeToJSONString(args),
51-
// duplex: 'half',
52-
// body: serializeToJSONStream(args),
53-
headers: {
54-
...options.headers,
55-
"x-serialized": "true",
56-
"Content-Type": "text/plain"
57-
},
58-
}));
85+
: createSerializedRequest(base, id, instance, options, args));
5986

6087
if (
6188
response.headers.has("Location") ||
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { createApp, toWebHandler } from "vinxi/http";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { serializeToJSONString } from "./serialization";
4+
import {
5+
ARGS_PART,
6+
createUploadSerializer,
7+
FILE_PART,
8+
readUploadArgs,
9+
UPLOAD_HEADER
10+
} from "./uploads";
11+
12+
const serverFn = vi.hoisted(() => ({
13+
args: [] as unknown[]
14+
}));
15+
16+
vi.mock("solidstart:server-fn-manifest", () => ({
17+
default: {
18+
fn: {
19+
functionName: "fn",
20+
importer: async () => ({
21+
fn: (...args: unknown[]) => {
22+
serverFn.args = args;
23+
return "done";
24+
}
25+
})
26+
}
27+
}
28+
}));
29+
vi.mock("../server/handler", () => ({ getExpectedRedirectStatus: () => 302 }));
30+
vi.mock("../server/pageEvent", () => ({ createPageEvent: async () => {} }));
31+
32+
const { default: serverHandler } = await import("./server-handler");
33+
const { createServerReference } = await import("./server-runtime");
34+
const handle = toWebHandler(createApp().use(serverHandler));
35+
36+
let sent: Request | undefined;
37+
38+
beforeEach(() => {
39+
serverFn.args = [];
40+
sent = undefined;
41+
vi.stubEnv("SERVER_BASE_URL", "http://localhost:3000");
42+
vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
43+
sent = new Request(url, init);
44+
return handle(sent.clone());
45+
});
46+
});
47+
48+
/** Calls the server function through the client, and returns what it received. */
49+
async function roundTrip(...args: unknown[]) {
50+
const fn = createServerReference(() => {}, "fn", "fn") as unknown as (
51+
...args: unknown[]
52+
) => Promise<unknown>;
53+
expect(await fn(...args)).toBe("done");
54+
return serverFn.args;
55+
}
56+
57+
describe("server function uploads", () => {
58+
it("sends a file as the only argument", async () => {
59+
const [received] = await roundTrip(new File(["hello"], "hello.txt", { type: "text/plain" }));
60+
61+
expect(sent!.headers.get(UPLOAD_HEADER)).toBe("true");
62+
expect(received).toBeInstanceOf(File);
63+
expect((received as File).name).toBe("hello.txt");
64+
expect((received as File).type).toBe("text/plain");
65+
expect(await (received as File).text()).toBe("hello");
66+
});
67+
68+
it("sends files next to other arguments and inside them", async () => {
69+
const file = new File(["a"], "a.txt");
70+
const form = new FormData();
71+
form.set("avatar", new File(["png"], "avatar.png"));
72+
73+
const [first, meta, received] = await roundTrip(
74+
file,
75+
{ tags: ["x"], list: [new Blob(["b"])], map: new Map([["c", new File(["c"], "c.txt")]]) },
76+
form
77+
);
78+
79+
expect(await (first as File).text()).toBe("a");
80+
const value = meta as { tags: string[]; list: Blob[]; map: Map<string, File> };
81+
expect(value.tags).toEqual(["x"]);
82+
expect(await value.list[0]!.text()).toBe("b");
83+
expect(await value.map.get("c")!.text()).toBe("c");
84+
expect(await ((received as FormData).get("avatar") as File).text()).toBe("png");
85+
});
86+
87+
it("sends a file used twice once, and keeps it the same value", async () => {
88+
const file = new File(["once"], "once.txt");
89+
90+
const [first, second] = await roundTrip(file, { again: file });
91+
92+
const form = await sent!.clone().formData();
93+
expect(form.has(`${FILE_PART}0`)).toBe(true);
94+
expect(form.has(`${FILE_PART}1`)).toBe(false);
95+
expect((second as { again: File }).again).toBe(first);
96+
});
97+
98+
it("copies a blob from another frame", async () => {
99+
const source = new Blob(["other"], { type: "text/plain" });
100+
// Not an instance of this realm's Blob, as one from another frame isn't.
101+
const foreign = {
102+
size: source.size,
103+
type: source.type,
104+
arrayBuffer: () => source.arrayBuffer(),
105+
slice: () => source.slice()
106+
};
107+
108+
const [received] = await roundTrip(foreign, 1);
109+
110+
expect(await (received as Blob).text()).toBe("other");
111+
});
112+
113+
it("keeps calls without files as serialized text", async () => {
114+
const [value, count] = await roundTrip({ a: 1 }, 2);
115+
116+
expect(sent!.headers.get("x-serialized")).toBe("true");
117+
expect(sent!.headers.get(UPLOAD_HEADER)).toBeNull();
118+
expect(value).toEqual({ a: 1 });
119+
expect(count).toBe(2);
120+
});
121+
122+
it("rejects an upload whose part is missing", async () => {
123+
const form = new FormData();
124+
form.set(
125+
ARGS_PART,
126+
await serializeToJSONString([new File(["x"], "x.txt")], [createUploadSerializer([])])
127+
);
128+
129+
await expect(readUploadArgs(form)).rejects.toThrow();
130+
});
131+
132+
it("rejects an upload without its arguments", async () => {
133+
const form = new FormData();
134+
form.set(`${FILE_PART}0`, new File(["x"], "x.txt"));
135+
136+
await expect(readUploadArgs(form)).rejects.toThrow("Malformed server function upload.");
137+
});
138+
139+
it("does not read upload indexes from a body that is not multipart", async () => {
140+
const { deserializeFromJSONString } = await import("./serialization");
141+
const body = await serializeToJSONString(
142+
[new File(["x"], "x.txt")],
143+
[createUploadSerializer([])]
144+
);
145+
146+
// A plain body is read without the upload plugin, so the index has no meaning there.
147+
await expect(deserializeFromJSONString(body)).rejects.toThrow();
148+
});
149+
});

0 commit comments

Comments
 (0)