diff --git a/.changeset/fast-dragons-own.md b/.changeset/fast-dragons-own.md new file mode 100644 index 000000000000..b9c82e9f8151 --- /dev/null +++ b/.changeset/fast-dragons-own.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: transport custom types across the server/client boundary diff --git a/documentation/docs/30-advanced/20-hooks.md b/documentation/docs/30-advanced/20-hooks.md index 9663d724fc18..bcdf21fbf9e8 100644 --- a/documentation/docs/30-advanced/20-hooks.md +++ b/documentation/docs/30-advanced/20-hooks.md @@ -290,6 +290,23 @@ The `lang` parameter will be correctly derived from the returned pathname. Using `reroute` will _not_ change the contents of the browser's address bar, or the value of `event.url`. +### transport + +This is a collection of _transporters_, which allow you to pass custom types — returned from `load` and form actions — across the server/client boundary. Each transporter contains an `encode` function, which encodes values on the server (or returns `false` for anything that isn't an instance of the type) and a corresponding `decode` function: + +```js +/// file: src/hooks.js +import { Vector } from '$lib/math'; + +/** @type {import('@sveltejs/kit').Transport} */ +export const transport = { + Vector: { + encode: (value) => value instanceof Vector && [value.x, value.y], + decode: ([x, y]) => new Vector(x, y) + } +}; +``` + ## Further reading diff --git a/packages/kit/src/core/sync/write_client_manifest.js b/packages/kit/src/core/sync/write_client_manifest.js index fe6127de934c..d7161fbab102 100644 --- a/packages/kit/src/core/sync/write_client_manifest.js +++ b/packages/kit/src/core/sync/write_client_manifest.js @@ -152,10 +152,14 @@ export function write_client_manifest(kit, manifest_data, output, metadata) { client_hooks_file ? 'client_hooks.handleError || ' : '' }(({ error }) => { console.error(error) }), ${client_hooks_file ? 'init: client_hooks.init,' : ''} - - reroute: ${universal_hooks_file ? 'universal_hooks.reroute || ' : ''}(() => {}) + reroute: ${universal_hooks_file ? 'universal_hooks.reroute || ' : ''}(() => {}), + transport: ${universal_hooks_file ? 'universal_hooks.transport || ' : ''}{} }; + export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode])); + + export const decode = (type, value) => decoders[type](value); + export { default as root } from '../root.${isSvelte5Plus() ? 'js' : 'svelte'}'; ` ); diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index 5dabea1f1c44..90074cfd8501 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -71,14 +71,16 @@ export async function get_hooks() { ${server_hooks ? `({ handle, handleFetch, handleError, init } = await import(${s(server_hooks)}));` : ''} let reroute; - ${universal_hooks ? `({ reroute } = await import(${s(universal_hooks)}));` : ''} + let transport; + ${universal_hooks ? `({ reroute, transport } = await import(${s(universal_hooks)}));` : ''} return { handle, handleFetch, handleError, - reroute, init, + reroute, + transport }; } diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 70c1d8bf7e3e..c4ade48e09a2 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -742,6 +742,43 @@ export type ClientInit = () => MaybePromise; */ export type Reroute = (event: { url: URL }) => void | string; +/** + * The [`transport`](https://svelte.dev/docs/kit/hooks#Universal-hooks-transport) hook allows you to transport custom types across the server/client boundary. + * + * Each transporter has a pair of `encode` and `decode` functions. On the server, `encode` determines whether a value is an instance of the custom type and, if so, returns a non-falsy encoding of the value which can be an object or an array (or `false` otherwise). + * + * In the browser, `decode` turns the encoding back into an instance of the custom type. + * + * ```ts + * import type { Transport } from '@sveltejs/kit'; + * + * declare class MyCustomType { + * data: any + * } + * + * // hooks.js + * export const transport: Transport = { + * MyCustomType: { + * encode: (value) => value instanceof MyCustomType && [value.data], + * decode: ([data]) => new MyCustomType(data) + * } + * }; + * ``` + * @since 2.11.0 + */ +export type Transport = Record; + +/** + * A member of the [`transport`](https://svelte.dev/docs/kit/hooks#Universal-hooks-transport) hook. + */ +export interface Transporter< + T = any, + U = Exclude +> { + encode: (value: T) => false | U; + decode: (data: U) => T; +} + /** * The generic form of `PageLoad` and `LayoutLoad`. You should import those from `./$types` (see [generated types](https://svelte.dev/docs/kit/types#Generated-types)) * rather than using `Load` directly. diff --git a/packages/kit/src/runtime/app/forms.js b/packages/kit/src/runtime/app/forms.js index bdb4f0fa8321..0c353ea3663d 100644 --- a/packages/kit/src/runtime/app/forms.js +++ b/packages/kit/src/runtime/app/forms.js @@ -1,7 +1,7 @@ import * as devalue from 'devalue'; import { DEV } from 'esm-env'; import { invalidateAll } from './navigation.js'; -import { applyAction } from '../client/client.js'; +import { app, applyAction } from '../client/client.js'; export { applyAction }; @@ -29,9 +29,11 @@ export { applyAction }; */ export function deserialize(result) { const parsed = JSON.parse(result); + if (parsed.data) { - parsed.data = devalue.parse(parsed.data); + parsed.data = devalue.parse(parsed.data, app.decoders); } + return parsed; } diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 17469be00511..0c514f91e3bb 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -174,7 +174,7 @@ let container; /** @type {HTMLElement} */ let target; /** @type {import('./types.js').SvelteKitApp} */ -let app; +export let app; /** @type {Array<((url: URL) => boolean)>} */ const invalidated = []; @@ -2493,6 +2493,7 @@ async function load_data(url, invalid) { */ function deserialize(data) { return devalue.unflatten(data, { + ...app.decoders, Promise: (id) => { return new Promise((fulfil, reject) => { deferreds.set(id, { fulfil, reject }); diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index 50a78a049f1c..78b740e78393 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -26,6 +26,10 @@ export interface SvelteKitApp { hooks: ClientHooks; + decode: (type: string, value: any) => any; + + decoders: Record any>; + root: typeof SvelteComponent; } @@ -54,7 +58,7 @@ export type NavigationFinished = { state: NavigationState; props: { constructors: Array; - components?: Array; + components?: SvelteComponent[]; page: Page; form?: Record | null; [key: `data_${number}`]: Record; diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index b278ee31873b..6377fd7a0ef1 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -197,6 +197,9 @@ export function get_data_json(event, options, nodes) { const { iterator, push, done } = create_async_iterator(); const reducers = { + ...Object.fromEntries( + Object.entries(options.hooks.transport).map(([key, value]) => [key, value.encode]) + ), /** @param {any} thing */ Promise: (thing) => { if (typeof thing?.then === 'function') { diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index 5aa10ac560f5..a2740a8e6aa4 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -76,7 +76,8 @@ export class Server { handle: module.handle || (({ event, resolve }) => resolve(event)), handleError: module.handleError || (({ error }) => console.error(error)), handleFetch: module.handleFetch || (({ request, fetch }) => fetch(request)), - reroute: module.reroute || (() => {}) + reroute: module.reroute || (() => {}), + transport: module.transport || {} }; if (module.init) { @@ -90,7 +91,8 @@ export class Server { }, handleError: ({ error }) => console.error(error), handleFetch: ({ request, fetch }) => fetch(request), - reroute: () => {} + reroute: () => {}, + transport: {} }; } else { throw error; diff --git a/packages/kit/src/runtime/server/page/actions.js b/packages/kit/src/runtime/server/page/actions.js index 97e01e34d770..ed37c3e18a70 100644 --- a/packages/kit/src/runtime/server/page/actions.js +++ b/packages/kit/src/runtime/server/page/actions.js @@ -61,14 +61,22 @@ export async function handle_action_json_request(event, options, server) { // @ts-expect-error we assign a string to what is supposed to be an object. That's ok // because we don't use the object outside, and this way we have better code navigation // through knowing where the related interface is used. - data: stringify_action_response(data.data, /** @type {string} */ (event.route.id)) + data: stringify_action_response( + data.data, + /** @type {string} */ (event.route.id), + options.hooks.transport + ) }); } else { return action_json({ type: 'success', status: data ? 200 : 204, // @ts-expect-error see comment above - data: stringify_action_response(data, /** @type {string} */ (event.route.id)) + data: stringify_action_response( + data, + /** @type {string} */ (event.route.id), + options.hooks.transport + ) }); } } catch (e) { @@ -254,18 +262,33 @@ function validate_action_return(data) { * Try to `devalue.uneval` the data object, and if it fails, return a proper Error with context * @param {any} data * @param {string} route_id + * @param {import('types').ServerHooks['transport']} transport */ -export function uneval_action_response(data, route_id) { - return try_deserialize(data, devalue.uneval, route_id); +export function uneval_action_response(data, route_id, transport) { + const replacer = (/** @type {any} */ thing) => { + for (const key in transport) { + const encoded = transport[key].encode(thing); + if (encoded) { + return `app.decode('${key}', ${devalue.uneval(encoded, replacer)})`; + } + } + }; + + return try_serialize(data, (value) => devalue.uneval(value, replacer), route_id); } /** * Try to `devalue.stringify` the data object, and if it fails, return a proper Error with context * @param {any} data * @param {string} route_id + * @param {import('types').ServerHooks['transport']} transport */ -function stringify_action_response(data, route_id) { - return try_deserialize(data, devalue.stringify, route_id); +function stringify_action_response(data, route_id, transport) { + const encoders = Object.fromEntries( + Object.entries(transport).map(([key, value]) => [key, value.encode]) + ); + + return try_serialize(data, (value) => devalue.stringify(value, encoders), route_id); } /** @@ -273,7 +296,7 @@ function stringify_action_response(data, route_id) { * @param {(data: any) => string} fn * @param {string} route_id */ -function try_deserialize(data, fn, route_id) { +function try_serialize(data, fn, route_id) { try { return fn(data); } catch (e) { diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 6197d5ba63c6..b61935a581de 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -321,12 +321,20 @@ export async function render_response({ deferred.set(id, { fulfil, reject }); })`); + // When resolving, the id might not yet be available due to the data + // be evaluated upon init of kit, so we use a timeout to retry properties.push(`resolve: ({ id, data, error }) => { - const { fulfil, reject } = deferred.get(id); - deferred.delete(id); - - if (error) reject(error); - else fulfil(data); + const try_to_resolve = () => { + if (!deferred.has(id)) { + setTimeout(try_to_resolve, 0); + return; + } + const { fulfil, reject } = deferred.get(id); + deferred.delete(id); + if (error) reject(error); + else fulfil(data); + } + try_to_resolve(); }`); } @@ -342,12 +350,11 @@ export async function render_response({ if (page_config.ssr) { const serialized = { form: 'null', error: 'null' }; - blocks.push(`const data = ${data};`); - if (form_value) { serialized.form = uneval_action_response( form_value, - /** @type {string} */ (event.route.id) + /** @type {string} */ (event.route.id), + options.hooks.transport ); } @@ -357,7 +364,7 @@ export async function render_response({ const hydrate = [ `node_ids: [${branch.map(({ node }) => node.index).join(', ')}]`, - 'data', + `data: ${data}`, `form: ${serialized.form}`, `error: ${serialized.error}` ]; @@ -573,6 +580,13 @@ function get_data(event, options, nodes, csp, global) { ); return `${global}.defer(${id})`; + } else { + for (const key in options.hooks.transport) { + const encoded = options.hooks.transport[key].encode(thing); + if (encoded) { + return `app.decode('${key}', ${devalue.uneval(encoded, replacer)})`; + } + } } } diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index cd7b1518b86a..0c6679aab159 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -19,7 +19,8 @@ import { Emulator, Adapter, ServerInit, - ClientInit + ClientInit, + Transporter } from '@sveltejs/kit'; import { HttpMethod, @@ -111,12 +112,14 @@ export interface ServerHooks { handle: Handle; handleError: HandleServerError; reroute: Reroute; + transport: Record; init?: ServerInit; } export interface ClientHooks { handleError: HandleClientError; reroute: Reroute; + transport: Record; init?: ClientInit; } diff --git a/packages/kit/test/apps/basics/src/hooks.js b/packages/kit/test/apps/basics/src/hooks.js index 302ab5ec823c..deb1576401b0 100644 --- a/packages/kit/test/apps/basics/src/hooks.js +++ b/packages/kit/test/apps/basics/src/hooks.js @@ -1,4 +1,5 @@ import { browser } from '$app/environment'; +import { Foo } from './lib'; const mapping = { '/reroute/basic/a': '/reroute/basic/b', @@ -29,3 +30,11 @@ export const reroute = ({ url }) => { return mapping[url.pathname]; } }; + +/** @type {import("@sveltejs/kit").Transport} */ +export const transport = { + Foo: { + encode: (value) => value instanceof Foo && [value.message], + decode: ([message]) => new Foo(message) + } +}; diff --git a/packages/kit/test/apps/basics/src/lib/index.js b/packages/kit/test/apps/basics/src/lib/index.js new file mode 100644 index 000000000000..f9917fd877e2 --- /dev/null +++ b/packages/kit/test/apps/basics/src/lib/index.js @@ -0,0 +1,9 @@ +export class Foo { + constructor(message) { + this.message = message; + } + + bar() { + return this.message + '!'; + } +} diff --git a/packages/kit/test/apps/basics/src/routes/serialization-basic/+page.server.js b/packages/kit/test/apps/basics/src/routes/serialization-basic/+page.server.js new file mode 100644 index 000000000000..b9b663d17944 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-basic/+page.server.js @@ -0,0 +1,5 @@ +import { Foo } from '../../lib'; + +export function load() { + return { foo: new Foo('It works') }; +} diff --git a/packages/kit/test/apps/basics/src/routes/serialization-basic/+page.svelte b/packages/kit/test/apps/basics/src/routes/serialization-basic/+page.svelte new file mode 100644 index 000000000000..f14aca6fe2fd --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-basic/+page.svelte @@ -0,0 +1,8 @@ + + +

{data.foo.bar()}

+child page diff --git a/packages/kit/test/apps/basics/src/routes/serialization-basic/child/+page.server.js b/packages/kit/test/apps/basics/src/routes/serialization-basic/child/+page.server.js new file mode 100644 index 000000000000..8ee85cd45184 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-basic/child/+page.server.js @@ -0,0 +1,5 @@ +import { Foo } from '../../../lib'; + +export function load() { + return { foo: new Foo('Client-side navigation also works') }; +} diff --git a/packages/kit/test/apps/basics/src/routes/serialization-basic/child/+page.svelte b/packages/kit/test/apps/basics/src/routes/serialization-basic/child/+page.svelte new file mode 100644 index 000000000000..ea812933bc2c --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-basic/child/+page.svelte @@ -0,0 +1,7 @@ + + +

{data.foo.bar()}

diff --git a/packages/kit/test/apps/basics/src/routes/serialization-form/+page.server.js b/packages/kit/test/apps/basics/src/routes/serialization-form/+page.server.js new file mode 100644 index 000000000000..ec780d99e22b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-form/+page.server.js @@ -0,0 +1,8 @@ +import { Foo } from '../../lib'; + +/** @satisfies {import('./$types').Actions} */ +export const actions = { + default: async () => { + return { foo: new Foo('It works') }; + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/serialization-form/+page.svelte b/packages/kit/test/apps/basics/src/routes/serialization-form/+page.svelte new file mode 100644 index 000000000000..4797a1b1a4cb --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-form/+page.svelte @@ -0,0 +1,9 @@ + + +
+ +
+ +

{form?.foo?.bar()}

diff --git a/packages/kit/test/apps/basics/src/routes/serialization-form2/+page.server.js b/packages/kit/test/apps/basics/src/routes/serialization-form2/+page.server.js new file mode 100644 index 000000000000..ec780d99e22b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-form2/+page.server.js @@ -0,0 +1,8 @@ +import { Foo } from '../../lib'; + +/** @satisfies {import('./$types').Actions} */ +export const actions = { + default: async () => { + return { foo: new Foo('It works') }; + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/serialization-form2/+page.svelte b/packages/kit/test/apps/basics/src/routes/serialization-form2/+page.svelte new file mode 100644 index 000000000000..4549aab48155 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-form2/+page.svelte @@ -0,0 +1,15 @@ + + +
+ +
+ +{#if form} +

{form?.foo?.bar()}

+{/if} + +To basic form diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index fb21412290d6..8a58d393836c 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -1379,3 +1379,31 @@ test.describe.serial('Cookies API', () => { expect(await span.innerText()).toContain('undefined'); }); }); + +test.describe('Serialization', () => { + test('A custom data type can be serialized/deserialized', async ({ page, clicknav }) => { + await page.goto('/serialization-basic'); + expect(await page.textContent('h1')).toBe('It works!'); + + await clicknav('[href="/serialization-basic/child"]'); + expect(await page.textContent('h1')).toBe('Client-side navigation also works!'); + }); + + test('A custom data type can be serialized/deserialized on POST', async ({ page }) => { + await page.goto('/serialization-form'); + await page.click('button'); + expect(await page.textContent('h1')).toBe('It works!'); + + // Test navigating to the basic page works as intended + await page.locator('a').first(); + expect(await page.textContent('h1')).toBe('It works!'); + }); + + test('A custom data type can be serialized/deserialized on POST with use:enhance', async ({ + page + }) => { + await page.goto('/serialization-form2'); + await page.click('button'); + expect(await page.textContent('h1')).toBe('It works!'); + }); +}); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index bfb7468dc955..b8e12835d79a 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -724,6 +724,43 @@ declare module '@sveltejs/kit' { */ export type Reroute = (event: { url: URL }) => void | string; + /** + * The [`transport`](https://svelte.dev/docs/kit/hooks#Universal-hooks-transport) hook allows you to transport custom types across the server/client boundary. + * + * Each transporter has a pair of `encode` and `decode` functions. On the server, `encode` determines whether a value is an instance of the custom type and, if so, returns a non-falsy encoding of the value which can be an object or an array (or `false` otherwise). + * + * In the browser, `decode` turns the encoding back into an instance of the custom type. + * + * ```ts + * import type { Transport } from '@sveltejs/kit'; + * + * declare class MyCustomType { + * data: any + * } + * + * // hooks.js + * export const transport: Transport = { + * MyCustomType: { + * encode: (value) => value instanceof MyCustomType && [value.data], + * decode: ([data]) => new MyCustomType(data) + * } + * }; + * ``` + * @since 2.11.0 + */ + export type Transport = Record; + + /** + * A member of the [`transport`](https://svelte.dev/docs/kit/hooks#Universal-hooks-transport) hook. + */ + export interface Transporter< + T = any, + U = Exclude + > { + encode: (value: T) => false | U; + decode: (data: U) => T; + } + /** * The generic form of `PageLoad` and `LayoutLoad`. You should import those from `./$types` (see [generated types](https://svelte.dev/docs/kit/types#Generated-types)) * rather than using `Load` directly.