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 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())} +
+ ); +} diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 2523f2b6d..ea602a5e6 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) => @@ -129,6 +133,7 @@ export function solidStart(options?: SolidStartOptions): Array { "import.meta.env.START_APP_ENTRY": `"${appEntryPath}"`, "import.meta.env.START_CLIENT_ENTRY": `"${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..62aa27f69 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,51 +46,25 @@ 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") { + 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()); - } else if (contentType?.startsWith("application/json")) { - parsed = fromJSON(await event.request.json(), { - plugins: [ - CustomEventPlugin, - DOMExceptionPlugin, - EventPlugin, - FormDataPlugin, - HeadersPlugin, - ReadableStreamPlugin, - RequestPlugin, - ResponsePlugin, - URLSearchParamsPlugin, - URLPlugin, - ], - }); + 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[]; } } try { @@ -178,9 +99,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); + 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); + } + return serializeToJSONStream(result); } catch (x) { if (x instanceof Response) { if (singleFlight && instance) { @@ -189,28 +113,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); + 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); + } + 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 +157,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 +177,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 +206,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 +227,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..3a31cc2f7 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 { type Component } from "solid-js"; +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,25 @@ 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, + // TODO(Alexis): move to serializeToJSONStream + body: await serializeToJSONString(args), + // duplex: 'half', + // body: serializeToJSONStream(args), + headers: { + ...options.headers, + "x-serialized": "true", + "Content-Type": "text/plain" + }, + })); if ( response.headers.has("Location") || @@ -172,20 +66,28 @@ async function fetchServerFunction( if (response.body) { /* @ts-ignore-next-line */ response.customBody = () => { - return deserializeStream(instance, response); + if (import.meta.env.SEROVAL_MODE === "js") { + return deserializeJSStream(instance, response.clone()); + } + return deserializeJSONStream(response.clone()); }; } return response; } const contentType = response.headers.get("Content-Type"); + const clone = response.clone(); let result; - if (contentType && contentType.startsWith("text/plain")) { - result = await response.text(); - } else if (contentType && contentType.startsWith("application/json")) { - result = await response.json(); - } else { - result = await deserializeStream(instance, response); + if (contentType?.startsWith("text/plain")) { + result = await clone.text(); + } else if (contentType?.startsWith("application/json")) { + result = await clone.json(); + } else if (response.headers.get('x-serialized')) { + if (import.meta.env.SEROVAL_MODE === "js") { + result = await deserializeJSStream(instance, clone); + } else { + result = await deserializeJSONStream(clone); + } } if (response.headers.has("X-Error")) { throw result; @@ -197,7 +99,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 +114,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,