Skip to content
Open
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/server-function-uploads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/start": minor
---

Let server functions take files and blobs anywhere in their arguments, not only as 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.
12 changes: 12 additions & 0 deletions apps/tests/src/e2e/server-function.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ test.describe("server-function", () => {
await page.goto("http://localhost:3000/server-function-blob");
await expect(page.locator("#server-fn-test")).toContainText('{"result":true}');
});
test("should send files next to other arguments", async ({ page }) => {
await page.goto("http://localhost:3000/server-function-upload");
await expect(page.locator("#server-fn-test")).toContainText(
JSON.stringify({
name: "hello-world.txt",
text: "Hello, World!",
tags: ["a", "b"],
attachments: ["one", "two"],
avatar: "avatar.png:png",
}),
);
});
test("should remove exports for non-function values when top-level use server is used", async ({
page,
}) => {
Expand Down
36 changes: 36 additions & 0 deletions apps/tests/src/routes/server-function-upload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { createEffect, createSignal } from "solid-js";

async function upload(file: File, meta: { tags: string[]; attachments: Blob[] }, form: FormData) {
"use server";
const attachments = await Promise.all(meta.attachments.map(blob => blob.text()));
const avatar = form.get("avatar") as File;
return {
name: file.name,
text: await file.text(),
tags: meta.tags,
attachments,
avatar: `${avatar.name}:${await avatar.text()}`,
};
}

export default function App() {
const [output, setOutput] = createSignal<unknown>();

createEffect(async () => {
const form = new FormData();
form.set("avatar", new File(["png"], "avatar.png", { type: "image/png" }));
setOutput(
await upload(
new File(["Hello, World!"], "hello-world.txt"),
{ tags: ["a", "b"], attachments: [new Blob(["one"]), new Blob(["two"])] },
form,
),
);
});

return (
<main>
<span id="server-fn-test">{JSON.stringify(output())}</span>
</main>
);
}
21 changes: 19 additions & 2 deletions packages/start/src/fns/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
serializeToJSONString,
} from "./serialization.ts";
import { BODY_FORMAT_KEY, BodyFormat, extractBody, getHeadersAndBody } from "./shared.ts";
import { appendUploads, ARGS_PART, createUploadSerializer } from "./uploads.ts";

let INSTANCE = 0;

Expand Down Expand Up @@ -55,10 +56,26 @@ async function initializeResponse(
}
}
// Fallback to seroval
const files: Blob[] = [];
// TODO(Alexis): move to serializeToJSONStream
const serialized = await serializeToJSONString(args, [createUploadSerializer(files)]);
if (files.length > 0) {
// Files go as form parts, so they are sent as they are instead of encoded.
const formData = new FormData();
formData.set(ARGS_PART, serialized);
await appendUploads(formData, files);
return createRequest(base, id, instance, {
...options,
body: formData,
headers: {
...options.headers,
[BODY_FORMAT_KEY]: BodyFormat.SerovalFormData,
},
});
}
return createRequest(base, id, instance, {
...options,
// TODO(Alexis): move to serializeToJSONStream
body: await serializeToJSONString(args),
body: serialized,
// duplex: 'half',
// body: serializeToJSONStream(args),
headers: {
Expand Down
2 changes: 1 addition & 1 deletion packages/start/src/fns/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ async function runServerFunction(h3Event: H3Event) {
if (request.method === "POST" && request.body !== null) {
const bodyFormat = request.headers.get(BODY_FORMAT_KEY);
const decoded = await extractBody("", false, request.clone());
if (bodyFormat === BodyFormat.Seroval) {
if (bodyFormat === BodyFormat.Seroval || bodyFormat === BodyFormat.SerovalFormData) {
parsed = decoded as any[];
} else {
parsed.push(decoded);
Expand Down
34 changes: 26 additions & 8 deletions packages/start/src/fns/serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
fromCrossJSON,
getCrossReferenceHeader,
isStream,
type Plugin,
type SerovalNode,
toCrossJSONStream,
} from "seroval";
Expand Down Expand Up @@ -47,6 +48,14 @@ const DEFAULT_PLUGINS = [
* rather than a runtime option.
*/
const PLUGINS = [...DEFAULT_PLUGINS, ...userPlugins];

/**
* Plugins for one call, such as the ones that carry files as form parts. They
* come first, so the values they handle never reach the other plugins.
*/
function withPlugins(extra: Plugin<any, any>[] | undefined) {
return extra?.length ? [...extra, ...PLUGINS] : PLUGINS;
}
const MAX_SERIALIZATION_DEPTH_LIMIT = 64;
const DISABLED_FEATURES = Feature.RegExp;

Expand Down Expand Up @@ -144,13 +153,13 @@ export function serializeToJSStream(id: string, value: any) {
});
}

export function serializeToJSONStream(value: any) {
export function serializeToJSONStream(value: any, plugins?: Plugin<any, any>[]) {
return new ReadableStream({
start(controller) {
toCrossJSONStream(value, {
disabledFeatures: SERIALIZE_DISABLED_FEATURES,
depthLimit: MAX_SERIALIZATION_DEPTH_LIMIT,
plugins: PLUGINS,
plugins: withPlugins(plugins),
onParse(node) {
enqueueChunk(controller, JSON.stringify(node));
},
Expand Down Expand Up @@ -197,6 +206,11 @@ export interface SerovalChunkReaderOptions {
maxChunkSize?: number;
}

export interface DeserializeJSONStreamOptions extends SerovalChunkReaderOptions {
/** Plugins for this body only, tried before the others. */
plugins?: Plugin<any, any>[];
}

export class SerovalChunkReader {
reader: ReadableStreamDefaultReader<Uint8Array>;
done = false;
Expand Down Expand Up @@ -368,19 +382,22 @@ function createEndOfStreamError() {
return new Error("Server function stream ended unexpectedly.");
}

export async function serializeToJSONString(value: any) {
const response = new Response(serializeToJSONStream(value));
export async function serializeToJSONString(value: any, plugins?: Plugin<any, any>[]) {
const response = new Response(serializeToJSONStream(value, plugins));
return await response.text();
}

export async function deserializeFromJSONString(json: string) {
export async function deserializeFromJSONString(
json: string,
options?: DeserializeJSONStreamOptions,
) {
const blob = new Response(json);
return await deserializeJSONStream(blob);
return await deserializeJSONStream(blob, options);
}

export async function deserializeJSONStream(
response: Response | Request,
options?: SerovalChunkReaderOptions,
options?: DeserializeJSONStreamOptions,
) {
if (!response.body) {
throw new Error("missing body");
Expand All @@ -392,13 +409,14 @@ export async function deserializeJSONStream(
});
if (!result.done) {
const refs = new DecodedRefs();
const plugins = withPlugins(options?.plugins);

function interpretChunk(chunk: string): unknown {
const value = fromCrossJSON(JSON.parse(chunk) as SerovalNode, {
refs,
disabledFeatures: DISABLED_FEATURES,
depthLimit: MAX_SERIALIZATION_DEPTH_LIMIT,
plugins: PLUGINS,
plugins,
});
return value;
}
Expand Down
16 changes: 16 additions & 0 deletions packages/start/src/fns/shared.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
deserializeFromJSONString,
deserializeJSONStream,
deserializeJSStream,
MAX_REQUEST_CHUNK_SIZE,
} from "./serialization.ts";
import { ARGS_PART, createUploadDeserializer } from "./uploads.ts";

export const BODY_FORMAT_KEY = "X-Start-Type";

Expand All @@ -17,6 +19,8 @@ export const enum BodyFormat {
File = "5",
ArrayBuffer = "6",
Uint8Array = "7",
/** Serialized arguments with their files as parts of multipart form data. */
SerovalFormData = "8",
}

export function getHeadersAndBody(body: any):
Expand Down Expand Up @@ -100,6 +104,18 @@ export async function extractBody(instance: string, client: boolean, source: Req
clone,
client ? undefined : { maxChunkSize: MAX_REQUEST_CHUNK_SIZE },
);
case startType === BodyFormat.SerovalFormData: {
// Only a client sends files, so this is always read on the server.
const formData = await clone.formData();
const args = formData.get(ARGS_PART);
if (typeof args !== "string") {
throw new Error("Malformed server function upload.");
}
return await deserializeFromJSONString(args, {
maxChunkSize: MAX_REQUEST_CHUNK_SIZE,
plugins: [createUploadDeserializer(formData)],
});
}
case startType === BodyFormat.String:
return await clone.text();
case startType === BodyFormat.File: {
Expand Down
Loading
Loading