From a91ce5f36e63da39a1e93131f4037a72239e773c Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sun, 4 Jan 2026 08:53:42 +0800 Subject: [PATCH 01/15] add seroval json mode --- packages/start/src/config/index.ts | 5 + packages/start/src/server/serialization.ts | 253 ++++++++++++++++++ .../src/server/server-functions-handler.ts | 155 ++++------- packages/start/src/server/server-runtime.ts | 183 +++---------- 4 files changed, 345 insertions(+), 251 deletions(-) create mode 100644 packages/start/src/server/serialization.ts diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 4b1c82179..869aef0cf 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -21,6 +21,10 @@ export interface SolidStartOptions { routeDir?: string; extensions?: string[]; middleware?: string; + serialization?: { + // This only matters for server function responses + mode?: 'js' | 'json'; + }; } const absolute = (path: string, root: string) => @@ -131,6 +135,7 @@ export function solidStart(options?: SolidStartOptions): Array { "import.meta.env.START_APP_ENTRY": JSON.stringify(appEntryPath), "import.meta.env.START_CLIENT_ENTRY": JSON.stringify(handlers.client), "import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay), + "import.meta.env.SEROVAL_MODE": JSON.stringify(start.serialization?.mode || 'json'), }, builder: { sharedPlugins: true, diff --git a/packages/start/src/server/serialization.ts b/packages/start/src/server/serialization.ts new file mode 100644 index 000000000..c157d157e --- /dev/null +++ b/packages/start/src/server/serialization.ts @@ -0,0 +1,253 @@ +import { + crossSerializeStream, + deserialize, + Feature, + fromCrossJSON, + getCrossReferenceHeader, + type SerovalNode, + toCrossJSONStream, +} from "seroval"; +import { + AbortSignalPlugin, + CustomEventPlugin, + DOMExceptionPlugin, + EventPlugin, + FormDataPlugin, + HeadersPlugin, + ReadableStreamPlugin, + RequestPlugin, + ResponsePlugin, + URLPlugin, + URLSearchParamsPlugin, +} from "seroval-plugins/web"; + +// TODO(Alexis): if we can, allow providing an option to extend these. +const DEFAULT_PLUGINS = [ + AbortSignalPlugin, + CustomEventPlugin, + DOMExceptionPlugin, + EventPlugin, + FormDataPlugin, + HeadersPlugin, + ReadableStreamPlugin, + RequestPlugin, + ResponsePlugin, + URLSearchParamsPlugin, + URLPlugin, +]; +const MAX_SERIALIZATION_DEPTH_LIMIT = 64; +const DISABLED_FEATURES = Feature.RegExp; + +/** + * Alexis: + * + * A "chunk" is a piece of data emitted by the streaming serializer. + * Each chunk is represented by a 32-bit value (encoded in hexadecimal), + * followed by the encoded string (8-bit representation). This format + * is important so we know how much of the chunk being streamed we + * are expecting before parsing the entire string data. + * + * This is sort of a bootleg "multipart/form-data" except it's bad at + * handling File/Blob LOL + * + * The format is as follows: + * ;0xFFFFFFFF; + */ +function createChunk(data: string): Uint8Array { + const encodeData = new TextEncoder().encode(data); + const bytes = encodeData.length; + const baseHex = bytes.toString(16); + const totalHex = "00000000".substring(0, 8 - baseHex.length) + baseHex; // 32-bit + const head = new TextEncoder().encode(`;0x${totalHex};`); + + const chunk = new Uint8Array(12 + bytes); + chunk.set(head); + chunk.set(encodeData, 12); + return chunk; +} + +export function serializeToJSStream(id: string, value: any) { + return new ReadableStream({ + start(controller) { + crossSerializeStream(value, { + scopeId: id, + plugins: DEFAULT_PLUGINS, + onSerialize(data: string, initial: boolean) { + controller.enqueue( + createChunk( + initial ? `(${getCrossReferenceHeader(id)},${data})` : data, + ), + ); + }, + onDone() { + controller.close(); + }, + onError(error: any) { + controller.error(error); + }, + }); + }, + }); +} + +export function serializeToJSONStream(value: any) { + return new ReadableStream({ + start(controller) { + toCrossJSONStream(value, { + disabledFeatures: DISABLED_FEATURES, + depthLimit: MAX_SERIALIZATION_DEPTH_LIMIT, + plugins: DEFAULT_PLUGINS, + onParse(node) { + controller.enqueue(createChunk(JSON.stringify(node))); + }, + onDone() { + controller.close(); + }, + onError(error) { + controller.error(error); + }, + }); + }, + }); +} + +class SerovalChunkReader { + reader: ReadableStreamDefaultReader; + buffer: Uint8Array; + done: boolean; + constructor(stream: ReadableStream) { + this.reader = stream.getReader(); + this.buffer = new Uint8Array(0); + this.done = false; + } + + async readChunk() { + // if there's no chunk, read again + const chunk = await this.reader.read(); + if (!chunk.done) { + // repopulate the buffer + const newBuffer = new Uint8Array(this.buffer.length + chunk.value.length); + newBuffer.set(this.buffer); + newBuffer.set(chunk.value, this.buffer.length); + this.buffer = newBuffer; + } else { + this.done = true; + } + } + + async next(): Promise< + { done: true; value: undefined } | { done: false; value: string } + > { + // Check if the buffer is empty + if (this.buffer.length === 0) { + // if we are already done... + if (this.done) { + return { + done: true, + value: undefined, + }; + } + // Otherwise, read a new chunk + await this.readChunk(); + return await this.next(); + } + // Read the "byte header" + // The byte header tells us how big the expected data is + // so we know how much data we should wait before we + // deserialize the data + const head = new TextDecoder().decode(this.buffer.subarray(1, 11)); + const bytes = Number.parseInt(head, 16); // ;0x00000000; + // Check if the buffer has enough bytes to be parsed + while (bytes > this.buffer.length - 12) { + // If it's not enough, and the reader is done + // then the chunk is invalid. + if (this.done) { + throw new Error("Malformed server function stream."); + } + // Otherwise, we read more chunks + await this.readChunk(); + } + // Extract the exact chunk as defined by the byte header + const partial = new TextDecoder().decode( + this.buffer.subarray(12, 12 + bytes), + ); + // The rest goes to the buffer + this.buffer = this.buffer.subarray(12 + bytes); + + // Deserialize the chunk + return { + done: false, + value: partial, + }; + } + + async drain(interpret: (chunk: string) => void) { + while (true) { + const result = await this.next(); + if (result.done) { + break; + } else { + interpret(result.value); + } + } + } +} + +export async function serializeToJSONString(value: any) { + const response = new Response(serializeToJSONStream(value)); + return await response.text(); +} + +export async function deserializeFromJSONString(json: string) { + const blob = new Response(json); + return await deserializeJSONStream(blob); +} + +export async function deserializeJSONStream(response: Response | Request) { + if (!response.body) { + throw new Error("missing body"); + } + const reader = new SerovalChunkReader(response.body); + const result = await reader.next(); + if (!result.done) { + const refs = new Map(); + + function interpretChunk(chunk: string): unknown { + const value = fromCrossJSON(JSON.parse(chunk) as SerovalNode, { + refs, + disabledFeatures: DISABLED_FEATURES, + depthLimit: MAX_SERIALIZATION_DEPTH_LIMIT, + plugins: DEFAULT_PLUGINS, + }); + return value; + } + + void reader.drain(interpretChunk); + + return interpretChunk(result.value); + } + return undefined; +} + +export async function deserializeJSStream(id: string, response: Response) { + if (!response.body) { + throw new Error("missing body"); + } + const reader = new SerovalChunkReader(response.body); + + const result = await reader.next(); + + if (!result.done) { + reader.drain(deserialize).then( + () => { + // @ts-ignore + delete $R[id]; + }, + () => { + // no-op + }, + ); + return deserialize(result.value); + } + return undefined; +} diff --git a/packages/start/src/server/server-functions-handler.ts b/packages/start/src/server/server-functions-handler.ts index 160672684..e5912aaa1 100644 --- a/packages/start/src/server/server-functions-handler.ts +++ b/packages/start/src/server/server-functions-handler.ts @@ -1,74 +1,21 @@ -import { getServerFnById } from "solidstart:server-fn-manifest"; import { parseSetCookie } from "cookie-es"; import { type H3Event, parseCookies } from "h3"; -import { crossSerializeStream, fromJSON, getCrossReferenceHeader } from "seroval"; -import { - CustomEventPlugin, - DOMExceptionPlugin, - EventPlugin, - FormDataPlugin, - HeadersPlugin, - ReadableStreamPlugin, - RequestPlugin, - ResponsePlugin, - URLPlugin, - URLSearchParamsPlugin, -} from "seroval-plugins/web"; import { sharedConfig } from "solid-js"; import { renderToString } from "solid-js/web"; import { provideRequestEvent } from "solid-js/web/storage"; +import { getServerFnById } from "solidstart:server-fn-manifest"; import { getFetchEvent, mergeResponseHeaders } from "./fetchEvent.ts"; import { createPageEvent } from "./handler.ts"; +import { + deserializeFromJSONString, + deserializeJSONStream, + serializeToJSONStream, + serializeToJSStream, +} from "./serialization.ts"; import type { FetchEvent, PageEvent } from "./types.ts"; import { getExpectedRedirectStatus } from "./util.ts"; -function createChunk(data: string) { - const encodeData = new TextEncoder().encode(data); - const bytes = encodeData.length; - const baseHex = bytes.toString(16); - const totalHex = "00000000".substring(0, 8 - baseHex.length) + baseHex; // 32-bit - const head = new TextEncoder().encode(`;0x${totalHex};`); - - const chunk = new Uint8Array(12 + bytes); - chunk.set(head); - chunk.set(encodeData, 12); - return chunk; -} - -function serializeToStream(id: string, value: any) { - return new ReadableStream({ - start(controller) { - crossSerializeStream(value, { - scopeId: id, - plugins: [ - CustomEventPlugin, - DOMExceptionPlugin, - EventPlugin, - FormDataPlugin, - HeadersPlugin, - ReadableStreamPlugin, - RequestPlugin, - ResponsePlugin, - URLSearchParamsPlugin, - URLPlugin, - ], - onSerialize(data: string, initial: boolean) { - controller.enqueue( - createChunk(initial ? `(${getCrossReferenceHeader(id)},${data})` : data), - ); - }, - onDone() { - controller.close(); - }, - onError(error: any) { - controller.error(error); - }, - }); - }, - }); -} - export async function handleServerFunction(h3Event: H3Event) { const event = getFetchEvent(h3Event); const request = event.request; @@ -99,26 +46,10 @@ export async function handleServerFunction(h3Event: H3Event) { if (!instance || h3Event.method === "GET") { const args = url.searchParams.get("args"); if (args) { - const json = JSON.parse(args); - (json.t - ? (fromJSON(json, { - plugins: [ - CustomEventPlugin, - DOMExceptionPlugin, - EventPlugin, - FormDataPlugin, - HeadersPlugin, - ReadableStreamPlugin, - RequestPlugin, - ResponsePlugin, - URLSearchParamsPlugin, - URLPlugin, - ], - }) as any) - : json - ).forEach((arg: any) => { + const result = (await deserializeFromJSONString(args)) as any[]; + for (const arg of result) { parsed.push(arg); - }); + } } } if (h3Event.method === "POST") { @@ -129,21 +60,8 @@ export async function handleServerFunction(h3Event: H3Event) { contentType?.startsWith("application/x-www-form-urlencoded") ) { parsed.push(await event.request.formData()); - } else if (contentType?.startsWith("application/json")) { - parsed = fromJSON(await event.request.json(), { - plugins: [ - CustomEventPlugin, - DOMExceptionPlugin, - EventPlugin, - FormDataPlugin, - HeadersPlugin, - ReadableStreamPlugin, - RequestPlugin, - ResponsePlugin, - URLSearchParamsPlugin, - URLPlugin, - ], - }); + } else { + parsed = (await deserializeJSONStream(event.request.clone())) as any[]; } } try { @@ -178,9 +96,12 @@ export async function handleServerFunction(h3Event: H3Event) { // handle no JS success case if (!instance) return handleNoJS(result, request, parsed); - h3Event.res.headers.set("content-type", "text/javascript"); - - return serializeToStream(instance, result); + if (import.meta.env.SEROVAL_MODE === "js") { + h3Event.res.headers.set("content-type", "text/javascript"); + return serializeToJSStream(instance, result); + } + h3Event.res.headers.set("content-type", "text/plain"); + return serializeToJSONStream(result); } catch (x) { if (x instanceof Response) { if (singleFlight && instance) { @@ -189,28 +110,41 @@ export async function handleServerFunction(h3Event: H3Event) { // forward headers if ((x as any).headers) mergeResponseHeaders(h3Event, (x as any).headers); // forward non-redirect statuses - if ((x as any).status && (!instance || (x as any).status < 300 || (x as any).status >= 400)) + if ( + (x as any).status && + (!instance || (x as any).status < 300 || (x as any).status >= 400) + ) h3Event.res.status = (x as any).status; if ((x as any).customBody) { x = (x as any).customBody(); } else if ((x as any).body === undefined) x = null; h3Event.res.headers.set("X-Error", "true"); } else if (instance) { - const error = x instanceof Error ? x.message : typeof x === "string" ? x : "true"; + const error = + x instanceof Error ? x.message : typeof x === "string" ? x : "true"; h3Event.res.headers.set("X-Error", error.replace(/[\r\n]+/g, "")); } else { x = handleNoJS(x, request, parsed, true); } if (instance) { - h3Event.res.headers.set("content-type", "text/javascript"); - return serializeToStream(instance, x); + if (import.meta.env.SEROVAL_MODE === "js") { + h3Event.res.headers.set("content-type", "text/javascript"); + return serializeToJSStream(instance, x); + } + h3Event.res.headers.set("content-type", "text/plain"); + return serializeToJSONStream(x); } return x; } } -function handleNoJS(result: any, request: Request, parsed: any[], thrown?: boolean) { +function handleNoJS( + result: any, + request: Request, + parsed: any[], + thrown?: boolean, +) { const url = new URL(request.url); const isError = result instanceof Error; let statusCode = 302; @@ -220,7 +154,10 @@ function handleNoJS(result: any, request: Request, parsed: any[], thrown?: boole if (result.headers.has("Location")) { headers.set( `Location`, - new URL(result.headers.get("Location")!, url.origin + import.meta.env.BASE_URL).toString(), + new URL( + result.headers.get("Location")!, + url.origin + import.meta.env.BASE_URL, + ).toString(), ); statusCode = getExpectedRedirectStatus(result); } @@ -237,7 +174,10 @@ function handleNoJS(result: any, request: Request, parsed: any[], thrown?: boole result: isError ? result.message : result, thrown: thrown, error: isError, - input: [...parsed.slice(0, -1), [...parsed[parsed.length - 1].entries()]], + input: [ + ...parsed.slice(0, -1), + [...parsed[parsed.length - 1].entries()], + ], }), )}; Secure; HttpOnly;`, ); @@ -263,7 +203,7 @@ function createSingleFlightHeaders(sourceEvent: FetchEvent) { // useH3Internals = true; // sourceEvent.nativeEvent.node.req.headers.cookie = ""; // } - SetCookies.forEach(cookie => { + SetCookies.forEach((cookie) => { if (!cookie) return; const { maxAge, expires, name, value } = parseSetCookie(cookie); if (maxAge != null && maxAge <= 0) { @@ -284,7 +224,10 @@ function createSingleFlightHeaders(sourceEvent: FetchEvent) { return headers; } -async function handleSingleFlight(sourceEvent: FetchEvent, result: any): Promise { +async function handleSingleFlight( + sourceEvent: FetchEvent, + result: any, +): Promise { let revalidate: string[]; let url = new URL(sourceEvent.request.headers.get("referer")!).toString(); if (result instanceof Response) { diff --git a/packages/start/src/server/server-runtime.ts b/packages/start/src/server/server-runtime.ts index 8bbabe40b..d2be79ef9 100644 --- a/packages/start/src/server/server-runtime.ts +++ b/packages/start/src/server/server-runtime.ts @@ -1,122 +1,20 @@ // @ts-ignore - seroval exports issue with NodeNext -import { join } from "pathe"; -import { deserialize, toJSONAsync } from "seroval"; -import { - CustomEventPlugin, - DOMExceptionPlugin, - EventPlugin, - FormDataPlugin, - HeadersPlugin, - ReadableStreamPlugin, - RequestPlugin, - ResponsePlugin, - URLPlugin, - URLSearchParamsPlugin, -} from "seroval-plugins/web"; import { type Component } from "solid-js"; - -class SerovalChunkReader { - reader: ReadableStreamDefaultReader; - buffer: Uint8Array; - done: boolean; - constructor(stream: ReadableStream) { - this.reader = stream.getReader(); - this.buffer = new Uint8Array(0); - this.done = false; - } - - async readChunk() { - // if there's no chunk, read again - const chunk = await this.reader.read(); - if (!chunk.done) { - // repopulate the buffer - let newBuffer = new Uint8Array(this.buffer.length + chunk.value.length); - newBuffer.set(this.buffer); - newBuffer.set(chunk.value, this.buffer.length); - this.buffer = newBuffer; - } else { - this.done = true; - } - } - - async next(): Promise { - // Check if the buffer is empty - if (this.buffer.length === 0) { - // if we are already done... - if (this.done) { - return { - done: true, - value: undefined, - }; - } - // Otherwise, read a new chunk - await this.readChunk(); - return await this.next(); - } - // Read the "byte header" - // The byte header tells us how big the expected data is - // so we know how much data we should wait before we - // deserialize the data - const head = new TextDecoder().decode(this.buffer.subarray(1, 11)); - const bytes = Number.parseInt(head, 16); // ;0x00000000; - // Check if the buffer has enough bytes to be parsed - while (bytes > this.buffer.length - 12) { - // If it's not enough, and the reader is done - // then the chunk is invalid. - if (this.done) { - throw new Error("Malformed server function stream."); - } - // Otherwise, we read more chunks - await this.readChunk(); - } - // Extract the exact chunk as defined by the byte header - const partial = new TextDecoder().decode(this.buffer.subarray(12, 12 + bytes)); - // The rest goes to the buffer - this.buffer = this.buffer.subarray(12 + bytes); - - // Deserialize the chunk - return { - done: false, - value: deserialize(partial), - }; - } - - async drain() { - while (true) { - const result = await this.next(); - if (result.done) { - break; - } - } - } -} - -async function deserializeStream(id: string, response: Response) { - if (!response.body) { - throw new Error("missing body"); - } - const reader = new SerovalChunkReader(response.body); - - const result = await reader.next(); - - if (!result.done) { - reader.drain().then( - () => { - // @ts-ignore - delete $R[id]; - }, - () => { - // no-op - }, - ); - } - - return result.value; -} +import { + deserializeJSONStream, + deserializeJSStream, + serializeToJSONStream, + serializeToJSONString, +} from "./serialization"; let INSTANCE = 0; -function createRequest(base: string, id: string, instance: string, options: RequestInit) { +function createRequest( + base: string, + id: string, + instance: string, + options: RequestInit, +) { return fetch(base, { method: "POST", ...options, @@ -127,20 +25,6 @@ function createRequest(base: string, id: string, instance: string, options: Requ }, }); } - -const plugins = [ - CustomEventPlugin, - DOMExceptionPlugin, - EventPlugin, - FormDataPlugin, - HeadersPlugin, - ReadableStreamPlugin, - RequestPlugin, - ResponsePlugin, - URLSearchParamsPlugin, - URLPlugin, -]; - async function fetchServerFunction( base: string, id: string, @@ -154,15 +38,18 @@ async function fetchServerFunction( ? createRequest(base, id, instance, { ...options, body: args[0] }) : args.length === 1 && args[0] instanceof URLSearchParams ? createRequest(base, id, instance, { - ...options, - body: args[0], - headers: { ...options.headers, "Content-Type": "application/x-www-form-urlencoded" }, - }) + ...options, + body: args[0], + headers: { + ...options.headers, + "Content-Type": "application/x-www-form-urlencoded", + }, + }) : createRequest(base, id, instance, { - ...options, - body: JSON.stringify(await Promise.resolve(toJSONAsync(args, { plugins }))), - headers: { ...options.headers, "Content-Type": "application/json" }, - })); + ...options, + body: serializeToJSONStream(args), + headers: { ...options.headers, "Content-Type": "application/json" }, + })); if ( response.headers.has("Location") || @@ -172,7 +59,8 @@ async function fetchServerFunction( if (response.body) { /* @ts-ignore-next-line */ response.customBody = () => { - return deserializeStream(instance, response); + // TODO check for serialization mode + return deserializeJSStream(instance, response); }; } return response; @@ -184,8 +72,11 @@ async function fetchServerFunction( result = await response.text(); } else if (contentType && contentType.startsWith("application/json")) { result = await response.json(); + } else if (import.meta.env.SEROVAL_MODE === "js") { + // TODO check for serialization mode + result = await deserializeJSStream(instance, response); } else { - result = await deserializeStream(instance, response); + result = await deserializeJSONStream(response); } if (response.headers.has("X-Error")) { throw result; @@ -197,7 +88,8 @@ export function createServerReference(id: string) { let baseURL = import.meta.env.BASE_URL ?? "/"; if (!baseURL.endsWith("/")) baseURL += "/"; - const fn = (...args: any[]) => fetchServerFunction(`${baseURL}_server`, id, {}, args); + const fn = (...args: any[]) => + fetchServerFunction(`${baseURL}_server`, id, {}, args); return new Proxy(fn, { get(target, prop, receiver) { @@ -211,15 +103,16 @@ export function createServerReference(id: string) { const url = `${baseURL}_server?id=${encodeURIComponent(id)}`; return (options: RequestInit) => { const fn = async (...args: any[]) => { - const encodeArgs = options.method && options.method.toUpperCase() === "GET"; + const encodeArgs = + options.method && options.method.toUpperCase() === "GET"; return fetchServerFunction( encodeArgs ? url + - (args.length - ? `&args=${encodeURIComponent( - JSON.stringify(await Promise.resolve(toJSONAsync(args, { plugins }))), - )}` - : "") + (args.length + ? `&args=${encodeURIComponent( + await serializeToJSONString(args), + )}` + : "") : `${baseURL}_server`, id, options, From 7c5e6e7008a088f181f0ca97523e708bcce9fb06 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sun, 4 Jan 2026 09:00:33 +0800 Subject: [PATCH 02/15] Update server-runtime.ts --- packages/start/src/server/server-runtime.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/start/src/server/server-runtime.ts b/packages/start/src/server/server-runtime.ts index d2be79ef9..d2a51f2a7 100644 --- a/packages/start/src/server/server-runtime.ts +++ b/packages/start/src/server/server-runtime.ts @@ -59,8 +59,10 @@ async function fetchServerFunction( if (response.body) { /* @ts-ignore-next-line */ response.customBody = () => { - // TODO check for serialization mode - return deserializeJSStream(instance, response); + if (import.meta.env.SEROVAL_MODE === "js") { + return deserializeJSStream(instance, response); + } + return deserializeJSONStream(response); }; } return response; @@ -73,7 +75,6 @@ async function fetchServerFunction( } else if (contentType && contentType.startsWith("application/json")) { result = await response.json(); } else if (import.meta.env.SEROVAL_MODE === "js") { - // TODO check for serialization mode result = await deserializeJSStream(instance, response); } else { result = await deserializeJSONStream(response); From f8fcc2dd1fb4fb42fc178c76823aa5daea5e6b33 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sun, 4 Jan 2026 09:06:24 +0800 Subject: [PATCH 03/15] Create plenty-geese-enter.md --- .changeset/plenty-geese-enter.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/plenty-geese-enter.md diff --git a/.changeset/plenty-geese-enter.md b/.changeset/plenty-geese-enter.md new file mode 100644 index 000000000..27c7c5457 --- /dev/null +++ b/.changeset/plenty-geese-enter.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": minor +--- + +seroval json mode From 19308b7ca19a6159cf3e9fd43a519a6d44e5feff Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sun, 4 Jan 2026 09:43:12 +0800 Subject: [PATCH 04/15] Fix client-server comms --- .../src/server/server-functions-handler.ts | 4 ++-- packages/start/src/server/server-runtime.ts | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/start/src/server/server-functions-handler.ts b/packages/start/src/server/server-functions-handler.ts index e5912aaa1..c536b940f 100644 --- a/packages/start/src/server/server-functions-handler.ts +++ b/packages/start/src/server/server-functions-handler.ts @@ -60,7 +60,7 @@ export async function handleServerFunction(h3Event: H3Event) { contentType?.startsWith("application/x-www-form-urlencoded") ) { parsed.push(await event.request.formData()); - } else { + } else if (contentType?.startsWith('text/plain')) { parsed = (await deserializeJSONStream(event.request.clone())) as any[]; } } @@ -96,11 +96,11 @@ export async function handleServerFunction(h3Event: H3Event) { // handle no JS success case if (!instance) return handleNoJS(result, request, parsed); + h3Event.res.headers.set("x-serialized", "true"); if (import.meta.env.SEROVAL_MODE === "js") { h3Event.res.headers.set("content-type", "text/javascript"); return serializeToJSStream(instance, result); } - h3Event.res.headers.set("content-type", "text/plain"); return serializeToJSONStream(result); } catch (x) { if (x instanceof Response) { diff --git a/packages/start/src/server/server-runtime.ts b/packages/start/src/server/server-runtime.ts index d2a51f2a7..3c0f2a201 100644 --- a/packages/start/src/server/server-runtime.ts +++ b/packages/start/src/server/server-runtime.ts @@ -47,8 +47,11 @@ async function fetchServerFunction( }) : createRequest(base, id, instance, { ...options, - body: serializeToJSONStream(args), - headers: { ...options.headers, "Content-Type": "application/json" }, + // TODO(Alexis): move to serializeToJSONStream + body: await serializeToJSONString(args), + // duplex: 'half', + // body: serializeToJSONStream(args), + headers: { ...options.headers, "Content-Type": "text/plain" }, })); if ( @@ -70,14 +73,16 @@ async function fetchServerFunction( const contentType = response.headers.get("Content-Type"); let result; - if (contentType && contentType.startsWith("text/plain")) { + if (contentType?.startsWith("text/plain")) { result = await response.text(); - } else if (contentType && contentType.startsWith("application/json")) { + } else if (contentType?.startsWith("application/json")) { result = await response.json(); - } else if (import.meta.env.SEROVAL_MODE === "js") { - result = await deserializeJSStream(instance, response); - } else { - result = await deserializeJSONStream(response); + } else if (response.headers.get('x-serialized')) { + if (import.meta.env.SEROVAL_MODE === "js") { + result = await deserializeJSStream(instance, response); + } else { + result = await deserializeJSONStream(response); + } } if (response.headers.has("X-Error")) { throw result; From e291918e0f7cc1caa6ab925fbc92083f7ee5ec58 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sun, 4 Jan 2026 09:43:17 +0800 Subject: [PATCH 05/15] Add tests --- apps/tests/src/e2e/server-function.test.ts | 5 ++++ .../tests/src/routes/server-function-ping.tsx | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 apps/tests/src/routes/server-function-ping.tsx diff --git a/apps/tests/src/e2e/server-function.test.ts b/apps/tests/src/e2e/server-function.test.ts index 7a8131be9..edba86fcc 100644 --- a/apps/tests/src/e2e/server-function.test.ts +++ b/apps/tests/src/e2e/server-function.test.ts @@ -67,4 +67,9 @@ test.describe("server-function", () => { await page.goto("http://localhost:3000/generator-server-function"); await expect(page.locator("#server-fn-test")).toContainText("¡Hola, Mundo!"); }); + + test("should build with a server function ping", async ({ page }) => { + await page.goto("http://localhost:3000/server-function-ping"); + await expect(page.locator("#server-fn-test")).toContainText('{"result":true}'); + }); }); diff --git a/apps/tests/src/routes/server-function-ping.tsx b/apps/tests/src/routes/server-function-ping.tsx new file mode 100644 index 000000000..70f9d2617 --- /dev/null +++ b/apps/tests/src/routes/server-function-ping.tsx @@ -0,0 +1,23 @@ +import { createEffect, createSignal } from "solid-js"; + +async function ping(value: string) { + "use server"; + + return await Promise.resolve(value); +} + +export default function App() { + const [output, setOutput] = createSignal<{ result?: boolean }>({}); + + createEffect(async () => { + const value = `${Math.random() * 1000}`; + const result = await ping(value); + setOutput(prev => ({ ...prev, result: value === result })); + }); + + return ( +
+ {JSON.stringify(output())} +
+ ); +} From 0fa982aa45c4d6573df9f1acf590b28a97b0b627 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sun, 4 Jan 2026 10:06:47 +0800 Subject: [PATCH 06/15] Add new header --- packages/start/src/server/server-functions-handler.ts | 4 +++- packages/start/src/server/server-runtime.ts | 10 +++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/start/src/server/server-functions-handler.ts b/packages/start/src/server/server-functions-handler.ts index c536b940f..4881e8bbc 100644 --- a/packages/start/src/server/server-functions-handler.ts +++ b/packages/start/src/server/server-functions-handler.ts @@ -60,7 +60,9 @@ export async function handleServerFunction(h3Event: H3Event) { contentType?.startsWith("application/x-www-form-urlencoded") ) { parsed.push(await event.request.formData()); - } else if (contentType?.startsWith('text/plain')) { + } else if (contentType?.startsWith('application/json')) { + parsed = await event.request.json() as any[]; + } else if (request.headers.has('x-serialized')) { parsed = (await deserializeJSONStream(event.request.clone())) as any[]; } } diff --git a/packages/start/src/server/server-runtime.ts b/packages/start/src/server/server-runtime.ts index 3c0f2a201..723542a05 100644 --- a/packages/start/src/server/server-runtime.ts +++ b/packages/start/src/server/server-runtime.ts @@ -1,9 +1,9 @@ -// @ts-ignore - seroval exports issue with NodeNext + import { type Component } from "solid-js"; import { deserializeJSONStream, deserializeJSStream, - serializeToJSONStream, + // serializeToJSONStream, serializeToJSONString, } from "./serialization"; @@ -51,7 +51,11 @@ async function fetchServerFunction( body: await serializeToJSONString(args), // duplex: 'half', // body: serializeToJSONStream(args), - headers: { ...options.headers, "Content-Type": "text/plain" }, + headers: { + ...options.headers, + "x-serialized": "true", + "Content-Type": "text/plain" + }, })); if ( From 9f80e1be117a1376d81395c9112caac226b8bc7d Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sun, 4 Jan 2026 12:13:06 +0800 Subject: [PATCH 07/15] Update server-functions-handler.ts --- packages/start/src/server/server-functions-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/start/src/server/server-functions-handler.ts b/packages/start/src/server/server-functions-handler.ts index 4881e8bbc..6152fc8bd 100644 --- a/packages/start/src/server/server-functions-handler.ts +++ b/packages/start/src/server/server-functions-handler.ts @@ -130,11 +130,11 @@ export async function handleServerFunction(h3Event: H3Event) { x = handleNoJS(x, request, parsed, true); } if (instance) { + h3Event.res.headers.set("x-serialized", "true"); if (import.meta.env.SEROVAL_MODE === "js") { h3Event.res.headers.set("content-type", "text/javascript"); return serializeToJSStream(instance, x); } - h3Event.res.headers.set("content-type", "text/plain"); return serializeToJSONStream(x); } return x; From 2f5839db07e3d7affc607f1ad24150576fdff32d Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Mon, 5 Jan 2026 13:10:35 +0800 Subject: [PATCH 08/15] Clone requests/responses --- .../start/src/server/server-functions-handler.ts | 9 +++++---- packages/start/src/server/server-runtime.ts | 13 +++++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/start/src/server/server-functions-handler.ts b/packages/start/src/server/server-functions-handler.ts index 6152fc8bd..62aa27f69 100644 --- a/packages/start/src/server/server-functions-handler.ts +++ b/packages/start/src/server/server-functions-handler.ts @@ -52,18 +52,19 @@ export async function handleServerFunction(h3Event: H3Event) { } } } - if (h3Event.method === "POST") { + if (request.method === "POST") { const contentType = request.headers.get("content-type"); + const clone = request.clone(); if ( contentType?.startsWith("multipart/form-data") || contentType?.startsWith("application/x-www-form-urlencoded") ) { - parsed.push(await event.request.formData()); + parsed.push(await clone.formData()); } else if (contentType?.startsWith('application/json')) { - parsed = await event.request.json() as any[]; + parsed = await clone.json() as any[]; } else if (request.headers.has('x-serialized')) { - parsed = (await deserializeJSONStream(event.request.clone())) as any[]; + parsed = (await deserializeJSONStream(clone)) as any[]; } } try { diff --git a/packages/start/src/server/server-runtime.ts b/packages/start/src/server/server-runtime.ts index 723542a05..3a31cc2f7 100644 --- a/packages/start/src/server/server-runtime.ts +++ b/packages/start/src/server/server-runtime.ts @@ -67,25 +67,26 @@ async function fetchServerFunction( /* @ts-ignore-next-line */ response.customBody = () => { if (import.meta.env.SEROVAL_MODE === "js") { - return deserializeJSStream(instance, response); + return deserializeJSStream(instance, response.clone()); } - return deserializeJSONStream(response); + return deserializeJSONStream(response.clone()); }; } return response; } const contentType = response.headers.get("Content-Type"); + const clone = response.clone(); let result; if (contentType?.startsWith("text/plain")) { - result = await response.text(); + result = await clone.text(); } else if (contentType?.startsWith("application/json")) { - result = await response.json(); + result = await clone.json(); } else if (response.headers.get('x-serialized')) { if (import.meta.env.SEROVAL_MODE === "js") { - result = await deserializeJSStream(instance, response); + result = await deserializeJSStream(instance, clone); } else { - result = await deserializeJSONStream(response); + result = await deserializeJSONStream(clone); } } if (response.headers.has("X-Error")) { From f15a3ac53f1107f2914cf8034b95bb34d110051d Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 15 Jan 2026 02:15:25 +0800 Subject: [PATCH 09/15] Add more formats --- .../src/server/server-functions-handler.ts | 46 ++++-- .../src/server/server-functions-shared.ts | 15 ++ packages/start/src/server/server-runtime.ts | 138 ++++++++++++++---- 3 files changed, 160 insertions(+), 39 deletions(-) create mode 100644 packages/start/src/server/server-functions-shared.ts diff --git a/packages/start/src/server/server-functions-handler.ts b/packages/start/src/server/server-functions-handler.ts index 62aa27f69..0f7c22ce3 100644 --- a/packages/start/src/server/server-functions-handler.ts +++ b/packages/start/src/server/server-functions-handler.ts @@ -13,6 +13,7 @@ import { serializeToJSONStream, serializeToJSStream, } from "./serialization.ts"; +import { BODY_FORMAL_FILE, BODY_FORMAT_KEY, BodyFormat } from "./server-functions-shared.ts"; import type { FetchEvent, PageEvent } from "./types.ts"; import { getExpectedRedirectStatus } from "./util.ts"; @@ -43,7 +44,7 @@ export async function handleServerFunction(h3Event: H3Event) { let parsed: any[] = []; // grab bound arguments from url when no JS - if (!instance || h3Event.method === "GET") { + if (!instance || request.method === "GET") { const args = url.searchParams.get("args"); if (args) { const result = (await deserializeFromJSONString(args)) as any[]; @@ -54,17 +55,38 @@ export async function handleServerFunction(h3Event: H3Event) { } if (request.method === "POST") { const contentType = request.headers.get("content-type"); + const startType = request.headers.get(BODY_FORMAT_KEY); const clone = request.clone(); - if ( - contentType?.startsWith("multipart/form-data") || - contentType?.startsWith("application/x-www-form-urlencoded") - ) { - parsed.push(await clone.formData()); - } else if (contentType?.startsWith('application/json')) { - parsed = await clone.json() as any[]; - } else if (request.headers.has('x-serialized')) { - parsed = (await deserializeJSONStream(clone)) as any[]; + switch (true) { + case startType === BodyFormat.Seroval: + parsed = (await deserializeJSONStream(clone)) as any[]; + break; + case startType === BodyFormat.String: + parsed.push(await clone.text()); + break; + case startType === BodyFormat.File: { + const formData = await clone.formData(); + parsed.push(formData.get(BODY_FORMAL_FILE)); + break; + } + case startType === BodyFormat.FormData: + case contentType?.startsWith("multipart/form-data"): + parsed.push(await clone.formData()); + break; + case startType === BodyFormat.URLSearchParams: + case contentType?.startsWith("application/x-www-form-urlencoded"): + parsed.push(new URLSearchParams(await clone.text())); + break; + case startType === BodyFormat.Blob: + parsed.push(await clone.blob()); + break; + case startType === BodyFormat.ArrayBuffer: + parsed.push(await clone.arrayBuffer()); + break; + case startType === BodyFormat.Uint8Array: + parsed.push(await clone.bytes()); + break; } } try { @@ -92,14 +114,14 @@ export async function handleServerFunction(h3Event: H3Event) { h3Event.res.status = result.status; if ((result as any).customBody) { result = await (result as any).customBody(); - } else if (result.body == undefined) result = null; + } else if (result.body == null) result = null; } } // handle no JS success case if (!instance) return handleNoJS(result, request, parsed); - h3Event.res.headers.set("x-serialized", "true"); + h3Event.res.headers.set(BODY_FORMAT_KEY, "true"); if (import.meta.env.SEROVAL_MODE === "js") { h3Event.res.headers.set("content-type", "text/javascript"); return serializeToJSStream(instance, result); diff --git a/packages/start/src/server/server-functions-shared.ts b/packages/start/src/server/server-functions-shared.ts new file mode 100644 index 000000000..f7c253dd8 --- /dev/null +++ b/packages/start/src/server/server-functions-shared.ts @@ -0,0 +1,15 @@ + +export const BODY_FORMAT_KEY = "X-Start-Type"; + +export const BODY_FORMAL_FILE = "__START__"; + +export const enum BodyFormat { + Seroval = "0", + String = "1", + FormData = "2", + URLSearchParams = "3", + Blob = "4", + File = "5", + ArrayBuffer = "6", + Uint8Array = "7", +} diff --git a/packages/start/src/server/server-runtime.ts b/packages/start/src/server/server-runtime.ts index 3a31cc2f7..b1496fd31 100644 --- a/packages/start/src/server/server-runtime.ts +++ b/packages/start/src/server/server-runtime.ts @@ -1,4 +1,3 @@ - import { type Component } from "solid-js"; import { deserializeJSONStream, @@ -6,6 +5,7 @@ import { // serializeToJSONStream, serializeToJSONString, } from "./serialization"; +import { BODY_FORMAL_FILE, BODY_FORMAT_KEY, BodyFormat } from "./server-functions-shared"; let INSTANCE = 0; @@ -25,6 +25,113 @@ function createRequest( }, }); } + +function getHeadersAndBody(body: any): { + headers?: HeadersInit; + body: BodyInit; +} | undefined { + switch (true) { + case typeof body === "string": + return { + headers: { + "Content-Type": "text/plain", + [BODY_FORMAT_KEY]: BodyFormat.String, + }, + body, + }; + case body instanceof FormData: + return { + headers: { + "Content-Type": "multipart/form-data", + [BODY_FORMAT_KEY]: BodyFormat.FormData, + }, + body, + }; + case body instanceof URLSearchParams: + return { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + [BODY_FORMAT_KEY]: BodyFormat.URLSearchParams, + }, + body, + }; + case body instanceof Blob: + return { + headers: { + [BODY_FORMAT_KEY]: BodyFormat.Blob, + }, + body, + }; + case body instanceof File: { + const formData = new FormData(); + formData.append(BODY_FORMAL_FILE, body, body.name); + return { + headers: { + [BODY_FORMAT_KEY]: BodyFormat.File, + }, + body: new FormData(), + }; + } + case body instanceof ArrayBuffer: + return { + headers: { + [BODY_FORMAT_KEY]: BodyFormat.ArrayBuffer, + }, + body, + }; + case body instanceof Uint8Array: + return { + headers: { + [BODY_FORMAT_KEY]: BodyFormat.Uint8Array, + }, + body: new Uint8Array(body), + }; + default: + return undefined; + } +} + +async function initializeResponse( + base: string, + id: string, + instance: string, + options: RequestInit, + args: any[], +) { + // No args, skip serialization + if (args.length === 0) { + return createRequest(base, id, instance, options); + } + // For single arguments, we can directly encode as body + if (args.length === 1) { + const body = args[0]; + const result = getHeadersAndBody(body); + if (result) { + return createRequest(base, id, instance, { + ...options, + body: result.body, + headers: { + ...options.headers, + ...result.headers, + }, + }); + } + } + // Fallback to seroval + return createRequest(base, id, instance, { + ...options, + // TODO(Alexis): move to serializeToJSONStream + body: await serializeToJSONString(args), + // duplex: 'half', + // body: serializeToJSONStream(args), + headers: { + ...options.headers, + "Content-Type": "text/plain", + [BODY_FORMAT_KEY]: BodyFormat.Seroval, + }, + }); +} + async function fetchServerFunction( base: string, id: string, @@ -32,31 +139,8 @@ async function fetchServerFunction( args: any[], ) { const instance = `server-fn:${INSTANCE++}`; - const response = await (args.length === 0 - ? createRequest(base, id, instance, options) - : args.length === 1 && args[0] instanceof FormData - ? createRequest(base, id, instance, { ...options, body: args[0] }) - : args.length === 1 && args[0] instanceof URLSearchParams - ? createRequest(base, id, instance, { - ...options, - body: args[0], - headers: { - ...options.headers, - "Content-Type": "application/x-www-form-urlencoded", - }, - }) - : createRequest(base, id, instance, { - ...options, - // TODO(Alexis): move to serializeToJSONStream - body: await serializeToJSONString(args), - // duplex: 'half', - // body: serializeToJSONStream(args), - headers: { - ...options.headers, - "x-serialized": "true", - "Content-Type": "text/plain" - }, - })); + + const response = await initializeResponse(base, id, instance, options, args); if ( response.headers.has("Location") || @@ -82,7 +166,7 @@ async function fetchServerFunction( result = await clone.text(); } else if (contentType?.startsWith("application/json")) { result = await clone.json(); - } else if (response.headers.get('x-serialized')) { + } else if (response.headers.get(BODY_FORMAT_KEY)) { if (import.meta.env.SEROVAL_MODE === "js") { result = await deserializeJSStream(instance, clone); } else { From 25b3f44199171ea341aa8c6177fe8683bb0c0377 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 15 Jan 2026 02:17:41 +0800 Subject: [PATCH 10/15] Fix `File` encoding --- packages/start/src/server/server-runtime.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/start/src/server/server-runtime.ts b/packages/start/src/server/server-runtime.ts index b1496fd31..46d07808c 100644 --- a/packages/start/src/server/server-runtime.ts +++ b/packages/start/src/server/server-runtime.ts @@ -55,13 +55,6 @@ function getHeadersAndBody(body: any): { }, body, }; - case body instanceof Blob: - return { - headers: { - [BODY_FORMAT_KEY]: BodyFormat.Blob, - }, - body, - }; case body instanceof File: { const formData = new FormData(); formData.append(BODY_FORMAL_FILE, body, body.name); @@ -69,9 +62,16 @@ function getHeadersAndBody(body: any): { headers: { [BODY_FORMAT_KEY]: BodyFormat.File, }, - body: new FormData(), + body: formData, }; } + case body instanceof Blob: + return { + headers: { + [BODY_FORMAT_KEY]: BodyFormat.Blob, + }, + body, + }; case body instanceof ArrayBuffer: return { headers: { From d0c55e2dcdf00f85ed87b0aadcdffc14bec9c6a1 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 15 Jan 2026 11:16:00 +0800 Subject: [PATCH 11/15] Update server-functions-handler.ts --- packages/start/src/server/server-functions-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/start/src/server/server-functions-handler.ts b/packages/start/src/server/server-functions-handler.ts index 0f7c22ce3..c03340dae 100644 --- a/packages/start/src/server/server-functions-handler.ts +++ b/packages/start/src/server/server-functions-handler.ts @@ -153,7 +153,7 @@ export async function handleServerFunction(h3Event: H3Event) { x = handleNoJS(x, request, parsed, true); } if (instance) { - h3Event.res.headers.set("x-serialized", "true"); + h3Event.res.headers.set(BODY_FORMAT_KEY, "true"); if (import.meta.env.SEROVAL_MODE === "js") { h3Event.res.headers.set("content-type", "text/javascript"); return serializeToJSStream(instance, x); From a5e55906f9b8a6e70129bc70d79efa5ba61914bc Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 15 Jan 2026 11:17:48 +0800 Subject: [PATCH 12/15] Update server-runtime.ts --- packages/start/src/server/server-runtime.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/start/src/server/server-runtime.ts b/packages/start/src/server/server-runtime.ts index 46d07808c..c0f4ec4d6 100644 --- a/packages/start/src/server/server-runtime.ts +++ b/packages/start/src/server/server-runtime.ts @@ -4,8 +4,8 @@ import { deserializeJSStream, // serializeToJSONStream, serializeToJSONString, -} from "./serialization"; -import { BODY_FORMAL_FILE, BODY_FORMAT_KEY, BodyFormat } from "./server-functions-shared"; +} from "./serialization.ts"; +import { BODY_FORMAL_FILE, BODY_FORMAT_KEY, BodyFormat } from "./server-functions-shared.ts"; let INSTANCE = 0; From a1f33a64e7bf5f5030e5509b7de9faceb428691d Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Fri, 16 Jan 2026 08:05:01 +0800 Subject: [PATCH 13/15] Fix `FormData` missing boundary --- packages/start/src/server/server-runtime.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/start/src/server/server-runtime.ts b/packages/start/src/server/server-runtime.ts index c0f4ec4d6..cb2b889f2 100644 --- a/packages/start/src/server/server-runtime.ts +++ b/packages/start/src/server/server-runtime.ts @@ -42,7 +42,6 @@ function getHeadersAndBody(body: any): { case body instanceof FormData: return { headers: { - "Content-Type": "multipart/form-data", [BODY_FORMAT_KEY]: BodyFormat.FormData, }, body, From 3cc23ab5e80d054290d8c3976a6c99b20e995381 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Fri, 16 Jan 2026 16:42:23 +0800 Subject: [PATCH 14/15] Add form-data test --- apps/tests/src/e2e/server-function.test.ts | 5 ++++ .../src/routes/server-function-form-data.tsx | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 apps/tests/src/routes/server-function-form-data.tsx diff --git a/apps/tests/src/e2e/server-function.test.ts b/apps/tests/src/e2e/server-function.test.ts index edba86fcc..a4dbc3746 100644 --- a/apps/tests/src/e2e/server-function.test.ts +++ b/apps/tests/src/e2e/server-function.test.ts @@ -72,4 +72,9 @@ test.describe("server-function", () => { await page.goto("http://localhost:3000/server-function-ping"); await expect(page.locator("#server-fn-test")).toContainText('{"result":true}'); }); + + test("should build with a server function w/ form data", async ({ page }) => { + await page.goto("http://localhost:3000/server-form-data"); + await expect(page.locator("#server-fn-test")).toContainText('{"result":true}'); + }); }); diff --git a/apps/tests/src/routes/server-function-form-data.tsx b/apps/tests/src/routes/server-function-form-data.tsx new file mode 100644 index 000000000..d54ad095a --- /dev/null +++ b/apps/tests/src/routes/server-function-form-data.tsx @@ -0,0 +1,26 @@ +import { createEffect, createSignal } from "solid-js"; + +async function ping(value: FormData) { + "use server"; + const file = value.get('example') as File; + return await file.text(); +} + +export default function App() { + const [output, setOutput] = createSignal<{ result?: boolean }>({}); + + createEffect(async () => { + const file = new File(['Hello, World!'], 'hello-world.txt'); + const formData = new FormData(); + formData.append('example', file); + const result = await ping(formData); + const value = await file.text(); + setOutput(prev => ({ ...prev, result: value === result })); + }); + + return ( +
+ {JSON.stringify(output())} +
+ ); +} From 6752f85e44f6c99c825a15cbf3674bf53d35693d Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Fri, 16 Jan 2026 16:46:36 +0800 Subject: [PATCH 15/15] fix tests --- apps/tests/src/e2e/server-function.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tests/src/e2e/server-function.test.ts b/apps/tests/src/e2e/server-function.test.ts index a4dbc3746..861495e3b 100644 --- a/apps/tests/src/e2e/server-function.test.ts +++ b/apps/tests/src/e2e/server-function.test.ts @@ -74,7 +74,7 @@ test.describe("server-function", () => { }); test("should build with a server function w/ form data", async ({ page }) => { - await page.goto("http://localhost:3000/server-form-data"); + await page.goto("http://localhost:3000/server-function-form-data"); await expect(page.locator("#server-fn-test")).toContainText('{"result":true}'); }); });