diff --git a/.changeset/icy-rings-exist.md b/.changeset/icy-rings-exist.md new file mode 100644 index 000000000..27c7c5457 --- /dev/null +++ b/.changeset/icy-rings-exist.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": minor +--- + +seroval json mode diff --git a/packages/start/config/index.d.ts b/packages/start/config/index.d.ts index 513b935fa..7b00c1814 100644 --- a/packages/start/config/index.d.ts +++ b/packages/start/config/index.d.ts @@ -26,6 +26,10 @@ type SolidStartInlineConfig = { experimental?: { islands?: boolean; }; + serialization?: { + // This only matters for server function responses + mode?: 'js' | 'json'; + } vite?: | ViteCustomizableConfig | ((options: { router: "server" | "client" | "server-function" }) => ViteCustomizableConfig); diff --git a/packages/start/config/index.js b/packages/start/config/index.js index ba7bebc1f..de76a5784 100644 --- a/packages/start/config/index.js +++ b/packages/start/config/index.js @@ -167,6 +167,7 @@ export function defineConfig(baseConfig = {}) { "import.meta.env.SSR": JSON.stringify(true), "import.meta.env.START_SSR": JSON.stringify(start.ssr), "import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay), + "import.meta.env.SEROVAL_MODE": JSON.stringify(start.serialization?.mode || 'json'), ...userConfig.define } }) @@ -234,6 +235,7 @@ export function defineConfig(baseConfig = {}) { "import.meta.env.START_SSR": JSON.stringify(start.ssr), "import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay), "import.meta.env.SERVER_BASE_URL": JSON.stringify(server?.baseURL ?? ""), + "import.meta.env.SEROVAL_MODE": JSON.stringify(start.serialization?.mode || 'json'), ...userConfig.define } }) @@ -293,6 +295,7 @@ export function defineConfig(baseConfig = {}) { "import.meta.env.SSR": JSON.stringify(true), "import.meta.env.START_SSR": JSON.stringify(start.ssr), "import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay), + "import.meta.env.SEROVAL_MODE": JSON.stringify(start.serialization?.mode || 'json'), ...userConfig.define } }) diff --git a/packages/start/src/runtime/serialization.ts b/packages/start/src/runtime/serialization.ts new file mode 100644 index 000000000..c157d157e --- /dev/null +++ b/packages/start/src/runtime/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/runtime/server-handler.ts b/packages/start/src/runtime/server-handler.ts index 74ded0de7..a7136a0de 100644 --- a/packages/start/src/runtime/server-handler.ts +++ b/packages/start/src/runtime/server-handler.ts @@ -1,19 +1,5 @@ /// import { parseSetCookie } from "cookie-es"; -import { crossSerializeStream, fromJSON, getCrossReferenceHeader } from "seroval"; -// @ts-ignore -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"; @@ -32,52 +18,7 @@ import { createPageEvent } from "../server/pageEvent"; import { FetchEvent, PageEvent } from "../server"; // @ts-ignore import serverFnManifest from "solidstart:server-fn-manifest"; - -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, initial) { - controller.enqueue( - createChunk(initial ? `(${getCrossReferenceHeader(id)},${data})` : data) - ); - }, - onDone() { - controller.close(); - }, - onError(error) { - controller.error(error); - } - }); - } - }); -} +import { deserializeFromJSONString, deserializeJSONStream, serializeToJSONStream, serializeToJSStream } from "./serialization"; async function handleServerFunction(h3Event: HTTPEvent) { const event = getFetchEvent(h3Event); @@ -131,24 +72,10 @@ async function handleServerFunction(h3Event: HTTPEvent) { 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) => parsed.push(arg)); + const result = (await deserializeFromJSONString(args)) as any[]; + for (const arg of result) { + parsed.push(arg); + } } } if (h3Event.method === "POST") { @@ -168,42 +95,26 @@ async function handleServerFunction(h3Event: HTTPEvent) { (hasReadableStream && ((h3Request as EdgeIncomingMessage).body as ReadableStream).locked); const requestBody = isReadableStream ? h3Request : h3Request.body; + // workaround for https://github.com/unjs/nitro/issues/1721 + // (issue only in edge runtimes and netlify preset) + const tmpReq = isH3EventBodyStreamLocked + ? request + : new Request(request, { ...request, body: requestBody }); if ( contentType?.startsWith("multipart/form-data") || contentType?.startsWith("application/x-www-form-urlencoded") ) { // workaround for https://github.com/unjs/nitro/issues/1721 // (issue only in edge runtimes and netlify preset) - parsed.push( - await (isH3EventBodyStreamLocked - ? request - : new Request(request, { ...request, body: requestBody }) - ).formData() - ); + parsed.push(await tmpReq.formData()); // what should work when #1721 is fixed // parsed.push(await request.formData); - } else if (contentType?.startsWith("application/json")) { - // workaround for https://github.com/unjs/nitro/issues/1721 - // (issue only in edge runtimes and netlify preset) - const tmpReq = isH3EventBodyStreamLocked - ? request - : new Request(request, { ...request, body: requestBody }); + } else if (contentType?.startsWith('application/json')) { // what should work when #1721 is fixed // just use request.json() here - parsed = fromJSON(await tmpReq.json(), { - plugins: [ - CustomEventPlugin, - DOMExceptionPlugin, - EventPlugin, - FormDataPlugin, - HeadersPlugin, - ReadableStreamPlugin, - RequestPlugin, - ResponsePlugin, - URLSearchParamsPlugin, - URLPlugin - ] - }); + parsed = await tmpReq.json() as any[]; + } else if (request.headers.get('x-serialized')) { + parsed = await deserializeJSONStream(tmpReq) as any[]; } } try { @@ -238,8 +149,12 @@ async function handleServerFunction(h3Event: HTTPEvent) { // handle no JS success case if (!instance) return handleNoJS(result, request, parsed); - setHeader(h3Event, "content-type", "text/javascript"); - return serializeToStream(instance, result); + setHeader(h3Event, 'x-serialized', 'true'); + if (import.meta.env.SEROVAL_MODE === 'js') { + setHeader(h3Event, "content-type", "text/javascript"); + return serializeToJSStream(instance, result); + } + return serializeToJSONStream(result); } catch (x) { if (x instanceof Response) { if (singleFlight && instance) { @@ -261,8 +176,12 @@ async function handleServerFunction(h3Event: HTTPEvent) { x = handleNoJS(x, request, parsed, true); } if (instance) { - setHeader(h3Event, "content-type", "text/javascript"); - return serializeToStream(instance, x); + setHeader(h3Event, 'x-serialized', 'true'); + if (import.meta.env.SEROVAL_MODE === 'js') { + setHeader(h3Event, "content-type", "text/javascript"); + return serializeToJSStream(instance, x); + } + return serializeToJSONStream(x); } return x; } diff --git a/packages/start/src/runtime/server-runtime.ts b/packages/start/src/runtime/server-runtime.ts index 2900539a9..1a5b32214 100644 --- a/packages/start/src/runtime/server-runtime.ts +++ b/packages/start/src/runtime/server-runtime.ts @@ -1,171 +1,61 @@ -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"; import { createIslandReference } from "../server/islands/index"; - -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; - if (Number.isNaN(bytes)) { - throw new Error(`Malformed server function stream header: ${head}`); - } - - // 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, + 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, headers: { ...options.headers, "X-Server-Id": id, - "X-Server-Instance": instance - } + "X-Server-Instance": instance, + }, }); } -const plugins = [ - CustomEventPlugin, - DOMExceptionPlugin, - EventPlugin, - FormDataPlugin, - HeadersPlugin, - ReadableStreamPlugin, - RequestPlugin, - ResponsePlugin, - URLSearchParamsPlugin, - URLPlugin -]; - async function fetchServerFunction( base: string, id: string, options: Omit, - args: any[] + 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, - body: JSON.stringify(await Promise.resolve(toJSONAsync(args, { plugins }))), - headers: { ...options.headers, "Content-Type": "application/json" } - })); + ? 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, + body: await serializeToJSONString(args), + // duplex: 'half', + // body: serializeToJSONStream(args), + headers: { + ...options.headers, + "x-serialized": "true", + "Content-Type": "text/plain" + }, + })); if ( response.headers.has("Location") || @@ -175,20 +65,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 cloned = response.clone(); let result; if (contentType && contentType.startsWith("text/plain")) { - result = await response.text(); + result = await cloned.text(); } else if (contentType && contentType.startsWith("application/json")) { - result = await response.json(); - } else { - result = await deserializeStream(instance, response); + result = await cloned.json(); + } else if (response.headers.get("x-serialized")) { + if (import.meta.env.SEROVAL_MODE === "js") { + result = await deserializeJSStream(instance, cloned); + } else { + result = await deserializeJSONStream(cloned); + } } if (response.headers.has("X-Error")) { throw result; @@ -208,23 +106,24 @@ export function createServerReference(fn: Function, id: string, name: string) { } if (prop === "withOptions") { const url = `${baseURL}/_server/?id=${encodeURIComponent(id)}&name=${encodeURIComponent( - name + name, )}`; 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}#${name}`, options, - encodeArgs ? [] : args + encodeArgs ? [] : args, ); }; fn.url = url; @@ -234,12 +133,21 @@ export function createServerReference(fn: Function, id: string, name: string) { return (target as any)[prop]; }, apply(target, thisArg, args) { - return fetchServerFunction(`${baseURL}/_server`, `${id}#${name}`, {}, args); - } + return fetchServerFunction( + `${baseURL}/_server`, + `${id}#${name}`, + {}, + args, + ); + }, }); } -export function createClientReference(Component: Component, id: string, name: string) { +export function createClientReference( + Component: Component, + id: string, + name: string, +) { if (typeof Component === "function") { return createIslandReference(Component, id, name); }