diff --git a/.changeset/tall-melons-nest.md b/.changeset/tall-melons-nest.md new file mode 100644 index 000000000000..b9ba72fc28c3 --- /dev/null +++ b/.changeset/tall-melons-nest.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: allow remote functions to return nested `query`, `query.batch` and `prerender` resources, and add `query.from(arg, value)` / `query.batch().from(arg, value)` to create a query seeded with a known value diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 31b39c82a722..6beedf0dd1f7 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -2299,9 +2299,32 @@ export type RemotePrerenderFunction = ( * `Input = number` but `Validated = string`). For `'unchecked'` validators and queries * without arguments it defaults to `Input`. */ -export type RemoteQueryFunction = ( +export type RemoteQueryFunction = (( arg: undefined extends Input ? Input | void : Input -) => RemoteQuery; +) => RemoteQuery) & { + /** + * Create a query that is seeded with a value you already have, without invoking the + * query's function. This is useful on the server when one remote function has already + * loaded the data a nested query would otherwise fetch — seed it with `from(...)` and + * return it, and its value travels back to the client alongside the response so the + * client never has to fetch it: + * + * ```js + * export const getPost = query(async (slug) => { + * const post = await db.getPost(slug); + * // seed the per-comment queries so the client doesn't refetch them + * return { post, comments: post.comments.map((c) => getComment.from(c.id, c)) }; + * }); + * ``` + * + * Pass `undefined` as `arg` for queries that take no argument. + * + * Unlike [`set`](#type-RemoteQuery), which updates a query the client already has + * mounted as part of a single-flight `command`/`form` mutation, `from` constructs a new + * query instance carrying the provided value. + */ + from(arg: undefined extends Input ? Input | void : Input, value: Output): RemoteQuery; +}; /** * The type of a remote `query.live` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.live) for full documentation. diff --git a/packages/kit/src/runtime/app/server/remote/prerender.js b/packages/kit/src/runtime/app/server/remote/prerender.js index 6076c2c443ac..c86f6e684f31 100644 --- a/packages/kit/src/runtime/app/server/remote/prerender.js +++ b/packages/kit/src/runtime/app/server/remote/prerender.js @@ -1,18 +1,25 @@ -/** @import { RemoteResource, RemotePrerenderFunction } from '@sveltejs/kit' */ -/** @import { RemotePrerenderInputsGenerator, RemotePrerenderInternals, MaybePromise } from 'types' */ +/** @import { RemoteResource, RemotePrerenderFunction, RequestEvent } from '@sveltejs/kit' */ +/** @import { RemotePrerenderInputsGenerator, RemotePrerenderInternals, MaybePromise, RequestState, RemoteQueriesMap } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { error, json } from '@sveltejs/kit'; import { DEV } from 'esm-env'; import { get_request_store } from '@sveltejs/kit/internal/server'; -import { stringify, stringify_remote_arg } from '../../../shared.js'; +import { + create_remote_key, + parse_remote_value, + REMOTE_VALUE_BRAND, + stringify_remote_arg +} from '../../../shared.js'; import { noop } from '../../../../utils/functions.js'; import { app_dir, base } from '$app/paths/internal/server'; import { create_validator, get_cache, + get_decoders, get_response, parse_remote_response, - run_remote_function + run_remote_function, + serialize_remote_result } from './shared.js'; /** @@ -89,10 +96,15 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { /** @type {RemotePrerenderFunction & { __: RemotePrerenderInternals }} */ const wrapper = (arg) => { + // Compute the payload synchronously (within the request context) so the returned + // promise can be marked as a serializable pointer before any awaiting occurs. + const { state: outer_state } = get_request_store(); + const outer_payload = stringify_remote_arg(arg, outer_state.transport); + /** @type {Promise & Partial>} */ const promise = (async () => { const { event, state } = get_request_store(); - const payload = stringify_remote_arg(arg, state.transport); + const payload = outer_payload; const id = __.id; const url = `${base}/${app_dir}/remote/${id}${payload ? `/${payload}` : ''}`; @@ -116,11 +128,20 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { error(prerendered.status, prerendered.error); } + // Stash any nested prerender values the endpoint shipped in its + // `queries` side-channel so their pointers can be revived without + // re-fetching (see `resolve_prerendered`). + store_prerender_seeds(prerendered.queries, state); + return prerendered.result; }) }).data; - return parse_remote_response(await promise, state.transport); + return parse_remote_value( + await promise, + get_decoders(state.transport), + make_prerender_reviver(state, event) + ); }); } catch { // not available prerendered, fallback to normal function @@ -142,7 +163,8 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { const result = await promise; if (state.prerendering) { - const body = { type: 'result', result: stringify(result, state.transport) }; + // prerender results may only nest other prerenders (allow_queries: false) + const body = { type: 'result', result: serialize_remote_result(result, state, false) }; state.prerendering.dependencies.set(url, { body: JSON.stringify(body), response: json(body) @@ -155,6 +177,12 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { promise.catch(noop); + // Allow this prerender result to be serialized as a `[id, payload, code]` pointer + // when it is returned (nested) from another remote function. + void Object.defineProperty(promise, REMOTE_VALUE_BRAND, { + value: { internals: __, payload: outer_payload } + }); + return /** @type {RemoteResource} */ (promise); }; @@ -162,3 +190,119 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { return wrapper; } + +/** + * Stash the nested values a prerender endpoint shipped in its `queries` side-channel into the + * request-stored seed cache, keyed by `create_remote_key(id, payload)`, so nested prerender + * pointers can be revived without an extra request. + * + * @param {string | undefined} queries + * @param {RequestState} state + */ +function store_prerender_seeds(queries, state) { + if (!queries) return; + + const map = /** @type {RemoteQueriesMap} */ (parse_remote_response(queries, state.transport)); + + const seeds = (state.remote.prerender_seeds ??= new Map()); + + for (const key in map) { + seeds.set(key, map[key]); + } +} + +/** + * Builds the `__skq` reviver used when parsing a prerendered payload on the server. Nested + * prerender pointers are revived via {@link resolve_prerendered}; queries can never appear in + * a prerender result (they're rejected at serialization time). + * + * @param {RequestState} state + * @param {RequestEvent} event + */ +function make_prerender_reviver(state, event) { + /** @param {[string, string, 'q' | 'b' | 'p']} pointer */ + return ([id, payload, code]) => { + if (code !== 'p') { + throw new Error('A prerender function can only return other prerender functions'); + } + + return resolve_prerendered(id, payload, state, event); + }; +} + +/** + * Server-side revival of a nested prerender pointer. Returns a marked, awaitable resource that + * resolves to the nested value — from the parent's `queries` seed when available, otherwise by + * fetching the nested prerender's own endpoint — and registers it in `state.remote.data` so its + * value is serialized for client hydration (rather than being re-fetched by the browser). + * + * @param {string} id + * @param {string} payload + * @param {RequestState} state + * @param {RequestEvent} event + * @returns {Promise} + */ +function resolve_prerendered(id, payload, state, event) { + const key = create_remote_key(id, payload); + + const resolved = (state.remote.prerender_resolved ??= new Map()); + const existing = resolved.get(key); + if (existing) return existing; + + const name = id.slice(id.lastIndexOf('/') + 1); + + // synthetic internals — only `id`, `type` and `name` are read by the serialization layer + /** @type {RemotePrerenderInternals} */ + const internals = { type: 'prerender', id, name, has_arg: payload !== '' }; + + const promise = (async () => { + const seed = state.remote.prerender_seeds?.get(key); + + if (seed !== undefined) { + if (seed.type === 'error') { + error(seed.status ?? 500, seed.error); + } + + return parse_remote_value( + seed.data, + get_decoders(state.transport), + make_prerender_reviver(state, event) + ); + } + + // not seeded (e.g. served from a static prerendered file without a side-channel) — + // fetch the nested prerender's own endpoint, which serves its prerendered data + const url = `${base}/${app_dir}/remote/${id}${payload ? `/${payload}` : ''}`; + const response = await fetch(new URL(url, event.url.origin).href); + + if (!response.ok) { + throw new Error('Prerendered response not found'); + } + + const prerendered = await response.json(); + + if (prerendered.type === 'error') { + error(prerendered.status, prerendered.error); + } + + store_prerender_seeds(prerendered.queries, state); + + return parse_remote_value( + prerendered.result, + get_decoders(state.transport), + make_prerender_reviver(state, event) + ); + })(); + + promise.catch(noop); + + // re-serialize as a pointer when this nested resource is itself serialized + void Object.defineProperty(promise, REMOTE_VALUE_BRAND, { value: { internals, payload } }); + + // register the value so `render.js` seeds it into the hydration payload for the client + get_cache(internals, state)[payload] = { serialize: true, data: promise }; + + resolved.set(key, promise); + + return promise; +} diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index e596cbf1c95d..21b4f92d573e 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -2,14 +2,15 @@ /** @import { RemoteInternals, MaybePromise, RequestState, RemoteQueryLiveInternals, RemoteQueryBatchInternals, RemoteQueryInternals, RemoteLiveQueryUserFunctionReturnType } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { get_request_store } from '@sveltejs/kit/internal/server'; -import { create_remote_key, stringify, stringify_remote_arg } from '../../../shared.js'; +import { create_remote_key, REMOTE_VALUE_BRAND, stringify_remote_arg } from '../../../shared.js'; import { prerendering } from '__sveltekit/environment'; import { create_validator, get_cache, get_response, run_remote_function, - run_remote_generator + run_remote_generator, + serialize_remote_result } from './shared.js'; import { handle_error_and_jsonify } from '../../../server/utils.js'; import { HttpError, SvelteKitError } from '@sveltejs/kit/internal'; @@ -101,6 +102,12 @@ export function query(validate_or_fn, maybe_fn) { }; Object.defineProperty(wrapper, '__', { value: __ }); + Object.defineProperty(wrapper, 'from', { + value: /** @type {RemoteQueryFunction['from']} */ ( + (arg, value) => create_seeded_query(__, arg, value) + ), + enumerable: true + }); return wrapper; } @@ -326,7 +333,7 @@ function batch(validate_or_fn, maybe_fn) { input.map(async (arg, i) => { try { const data = get_result(arg, i); - return { type: 'result', data: stringify(data, state.transport) }; + return { type: 'result', data: serialize_remote_result(data, state) }; } catch (error) { return { type: 'error', @@ -368,6 +375,12 @@ function batch(validate_or_fn, maybe_fn) { }; Object.defineProperty(wrapper, '__', { value: __ }); + Object.defineProperty(wrapper, 'from', { + value: /** @type {RemoteQueryFunction['from']} */ ( + (arg, value) => create_seeded_query(__, arg, value) + ), + enumerable: true + }); return wrapper; } @@ -394,7 +407,8 @@ function create_query_resource(__, payload, state, fn) { void (__.id && state.is_in_render && get_promise()); }; - return { + /** @type {RemoteQuery} */ + const resource = { /** @type {Promise['catch']} */ catch(onrejected) { return get_promise().catch(onrejected); @@ -453,6 +467,47 @@ function create_query_resource(__, payload, state, fn) { return 'QueryResource'; } }; + + void Object.defineProperty(resource, REMOTE_VALUE_BRAND, { value: { internals: __, payload } }); + + return resource; +} + +/** + * Creates a query resource that is seeded with a known value, without ever invoking the + * query's function. The value is written directly into the per-request cache (marked for + * serialization), so that when this resource is returned (nested) from a remote function it + * is shipped to the client via the response's `queries` side-channel — or, during SSR, via + * the hydration payload — and revived without an extra round-trip. + * + * This is the seeding counterpart to `set()`: where `set()` updates a query the client + * already has mounted (as part of a single-flight `command`/`form` mutation), `from()` + * creates a brand new query carrying a value you already have to hand. + * + * @param {RemoteQueryInternals | RemoteQueryBatchInternals} __ + * @param {any} arg — the raw argument (i.e. the cache key the client will use) + * @param {any} value — the value to seed the query with + * @returns {RemoteQuery} + */ +function create_seeded_query(__, arg, value) { + if (prerendering) { + throw new Error( + `Cannot call '${__.name}.from(...)' while prerendering, as prerendered pages need static data. Use 'prerender' from $app/server instead` + ); + } + + const { state } = get_request_store(); + const payload = stringify_remote_arg(arg, state.transport); + const promise = Promise.resolve(value); + + promise.catch(noop); + + // Seed the cache directly — the query's own function is never called. `serialize: true` + // ensures the value is picked up by the SSR hydration payload (`render.js`) and by the + // `collected` side-channel when this resource is returned from a remote function. + get_cache(__, state)[payload] = { serialize: true, data: promise }; + + return create_query_resource(__, payload, state, () => promise); } /** @@ -482,7 +537,8 @@ function create_live_query_resource(__, payload, state, signal, get_generator) { void (__.id && state.is_in_render && get_promise()); }; - return { + /** @type {RemoteLiveQuery} */ + const resource = { /** @type {Promise['catch']} */ catch(onrejected) { return get_promise().catch(onrejected); @@ -551,6 +607,10 @@ function create_live_query_resource(__, payload, state, signal, get_generator) { return 'LiveQueryResource'; } }; + + void Object.defineProperty(resource, REMOTE_VALUE_BRAND, { value: { internals: __, payload } }); + + return resource; } /** diff --git a/packages/kit/src/runtime/app/server/remote/shared.js b/packages/kit/src/runtime/app/server/remote/shared.js index d7c8ceb92af9..cae55dcd7cf9 100644 --- a/packages/kit/src/runtime/app/server/remote/shared.js +++ b/packages/kit/src/runtime/app/server/remote/shared.js @@ -4,7 +4,11 @@ import { parse } from 'devalue'; import { error } from '@sveltejs/kit'; import { with_request_store, get_request_store } from '@sveltejs/kit/internal/server'; import { noop } from '../../../../utils/functions.js'; -import { create_remote_key, stringify, unfriendly_hydratable } from '../../../shared.js'; +import { + create_remote_key, + stringify_remote_value, + unfriendly_hydratable +} from '../../../shared.js'; /** * @param {any} validate_or_fn @@ -87,7 +91,11 @@ export async function get_response(internals, payload, state, get_result) { Promise.resolve(entry.data) .then((value) => { - void unfriendly_hydratable(remote_key, () => stringify(value, state.transport)); + void unfriendly_hydratable(remote_key, () => + stringify_remote_value(value, state.transport, { + allow_queries: internals.type !== 'prerender' + }) + ); }) .catch(noop); } @@ -96,17 +104,56 @@ export async function get_response(internals, payload, state, get_result) { } /** - * @param {any} data + * Serialize a remote function's return value, emitting `[id, payload, code]` pointers for + * any nested remote resources. Every nested resource that was *used* during this request + * (i.e. has a cached value) is recorded in `state.remote.collected` so that the HTTP + * response handler can ship its value alongside the result (the side-channel), letting the + * client revive the pointer without an extra round-trip. + * + * @param {any} value + * @param {RequestState} state + * @param {boolean} [allow_queries=true] `false` for prerender results (which may only nest prerenders) + * @returns {string} + */ +export function serialize_remote_result(value, state, allow_queries = true) { + return stringify_remote_value(value, state.transport, { + allow_queries, + on_pointer: ({ internals, payload, code }) => { + const key = create_remote_key(internals.id, payload); + const collected = (state.remote.collected ??= new Map()); + + if (collected.has(key)) return; + + // only collect the value if the nested resource was actually used during + // this request — otherwise we just ship the pointer and let the client fetch on use + const cache = state.remote.data?.get(internals); + if (!cache || !(payload in cache)) return; + + collected.set(key, { internals, payload, code }); + } + }); +} + +/** + * Build the devalue revivers (decoders) for the app's transport hooks. * @param {ServerHooks['transport']} transport */ -export function parse_remote_response(data, transport) { +export function get_decoders(transport) { /** @type {Record} */ - const revivers = {}; + const decoders = {}; for (const key in transport) { - revivers[key] = transport[key].decode; + decoders[key] = transport[key].decode; } - return parse(data, revivers); + return decoders; +} + +/** + * @param {any} data + * @param {ServerHooks['transport']} transport + */ +export function parse_remote_response(data, transport) { + return parse(data, get_decoders(transport)); } /** diff --git a/packages/kit/src/runtime/client/remote-functions/command.svelte.js b/packages/kit/src/runtime/client/remote-functions/command.svelte.js index 4004b99a5672..05c1de6c9ba3 100644 --- a/packages/kit/src/runtime/client/remote-functions/command.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/command.svelte.js @@ -1,13 +1,12 @@ /** @import { RemoteCommand, RemoteQueryUpdate } from '@sveltejs/kit' */ /** @import { RemoteFunctionResponse } from 'types' */ import { app_dir, base } from '$app/paths/internal/client'; -import * as devalue from 'devalue'; import { HttpError } from '@sveltejs/kit/internal'; import { app } from '../client.js'; -import { stringify_remote_arg } from '../../shared.js'; +import { parse_remote_value, stringify_remote_arg } from '../../shared.js'; import { get_remote_request_headers, - apply_refreshes, + apply_queries, categorize_updates, apply_reconnections } from './shared.svelte.js'; @@ -76,15 +75,16 @@ export function command(id) { } else if (result.type === 'error') { throw new HttpError(result.status ?? 500, result.error); } else { - if (result.refreshes) { - apply_refreshes(result.refreshes); - } + // apply the `queries` side-channel (live-update mounted queries, seed new ones) + // *before* reviving the result so that any nested query/prerender pointers in + // the return value resolve to the freshly-applied values without fetching + const revive = apply_queries(result.queries); if (result.reconnects) { - apply_reconnections(result.reconnects); + apply_reconnections(result.reconnects, revive); } - return devalue.parse(result.result, app.decoders); + return parse_remote_value(result.result, app.decoders, revive); } } finally { overrides?.forEach((fn) => fn()); diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index 5061951710f4..67f9ef05c0b5 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -7,7 +7,13 @@ import { DEV } from 'esm-env'; import { HttpError } from '@sveltejs/kit/internal'; import { app, query_responses, _goto, set_nearest_error_page, invalidateAll } from '../client.js'; import { tick } from 'svelte'; -import { apply_refreshes, categorize_updates, apply_reconnections } from './shared.svelte.js'; +import { + apply_queries, + create_remote_pointer_reviver, + categorize_updates, + apply_reconnections +} from './shared.svelte.js'; +import { parse_remote_value } from '../../shared.js'; import { createAttachmentKey } from 'svelte/attachments'; import { convert_formdata, @@ -71,7 +77,15 @@ export function form(id) { const issues = $derived(flatten_issues(raw_issues)); /** @type {any} */ - let result = $state.raw(query_responses[action_id]); + let result = $state.raw( + query_responses[action_id] === undefined + ? undefined + : parse_remote_value( + query_responses[action_id], + app.decoders, + create_remote_pointer_reviver() + )?.result + ); /** @type {number} */ let pending_count = $state(0); @@ -206,19 +220,24 @@ export function form(id) { result = undefined; if (form_result.type === 'result') { - ({ issues: raw_issues = [], result } = devalue.parse(form_result.result, app.decoders)); + // apply the `queries` side-channel before reviving the result, so nested + // query/prerender pointers in the form's return value resolve without fetching + const revive = apply_queries(form_result.queries); + + if (form_result.reconnects) { + apply_reconnections(form_result.reconnects, revive); + } + + ({ issues: raw_issues = [], result } = parse_remote_value( + form_result.result, + app.decoders, + revive + )); const succeeded = raw_issues.length === 0; if (succeeded) { - if (refreshes === null && !form_result.refreshes && !form_result.reconnects) { + if (refreshes === null && !form_result.queries && !form_result.reconnects) { void invalidateAll(); - } else { - if (form_result.refreshes) { - apply_refreshes(form_result.refreshes); - } - if (form_result.reconnects) { - apply_reconnections(form_result.reconnects); - } } } else { if (DEV) { @@ -228,22 +247,20 @@ export function form(id) { return succeeded; } else if (form_result.type === 'redirect') { - const stringified_refreshes = form_result.refreshes ?? ''; + const stringified_queries = form_result.queries ?? ''; const stringified_reconnects = form_result.reconnects ?? ''; - if (stringified_refreshes) { - apply_refreshes(stringified_refreshes); - } + + const revive = apply_queries(stringified_queries || undefined); if (stringified_reconnects) { - apply_reconnections(stringified_reconnects); + apply_reconnections(stringified_reconnects, revive); } // Use internal version to allow redirects to external URLs void _goto( form_result.location, { - invalidateAll: - refreshes === null && !stringified_refreshes && !stringified_reconnects + invalidateAll: refreshes === null && !stringified_queries && !stringified_reconnects }, 0 ); diff --git a/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js b/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js index 8f23b235b9fd..24da83ff0fbc 100644 --- a/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js @@ -1,10 +1,17 @@ /** @import { RemotePrerenderFunction } from '@sveltejs/kit' */ +/** @import { PointerInitial } from './shared.svelte.js' */ import { app_dir, base } from '$app/paths/internal/client'; import { version } from '__sveltekit/environment'; -import * as devalue from 'devalue'; import { app, prerender_responses } from '../client.js'; -import { get_remote_request_headers, remote_request } from './shared.svelte.js'; -import { create_remote_key, stringify_remote_arg } from '../../shared.js'; +import { + apply_queries, + get_remote_request_headers, + register_pointer_reviver, + remote_response, + revive_remote_value +} from './shared.svelte.js'; +import { create_remote_key, parse_remote_value, stringify_remote_arg } from '../../shared.js'; +import { noop } from '../../../utils/functions.js'; // Initialize Cache API for prerender functions const CACHE_NAME = __SVELTEKIT_DEV__ ? `sveltekit:${Date.now()}` : `sveltekit:${version}`; @@ -50,67 +57,117 @@ function put(url, encoded) { } /** + * The fetcher used by a {@link Prerender} that wasn't seeded: serves from the inline + * hydration payload (first render), then the Cache API, then the network. * @param {string} id - * @returns {RemotePrerenderFunction} + * @param {string} payload + * @param {string} url */ -export function prerender(id) { - return (arg) => { - const payload = stringify_remote_arg(arg, app.hooks.transport); - const cache_key = create_remote_key(id, payload); +function create_prerender_fetcher(id, payload, url) { + const cache_key = create_remote_key(id, payload); - let resource = prerender_resources.get(cache_key)?.deref(); + return async () => { + await prerender_cache_ready; - if (!resource) { - resource = new Prerender(async () => { - await prerender_cache_ready; + if (Object.hasOwn(prerender_responses, cache_key)) { + const serialized = prerender_responses[cache_key]; - const url = `${base}/${app_dir}/remote/${id}${payload ? `/${payload}` : ''}`; + if (prerender_cache) { + void put(url, serialized); + } - if (Object.hasOwn(prerender_responses, cache_key)) { - const data = prerender_responses[cache_key]; + return revive_remote_value(serialized); + } - if (prerender_cache) { - void put(url, devalue.stringify(data, app.encoders)); - } + // Do this here, after await Svelte' reactivity context is gone. + const headers = get_remote_request_headers(); - return data; - } + // Check the Cache API first + if (prerender_cache) { + try { + const cached_response = await prerender_cache.match(url); - // Do this here, after await Svelte' reactivity context is gone. - const headers = get_remote_request_headers(); - - // Check the Cache API first - if (prerender_cache) { - try { - const cached_response = await prerender_cache.match(url); - - if (cached_response) { - const cached_result = await cached_response.text(); - return devalue.parse(cached_result, app.decoders); - } - } catch { - void prerender_cache.delete(url); - } + if (cached_response) { + const cached_result = await cached_response.text(); + return revive_remote_value(cached_result); } + } catch { + void prerender_cache.delete(url); + } + } - const encoded = await remote_request(url, headers); + const resolved = await remote_response(url, headers); + const revive = apply_queries(resolved.queries); - // For successful prerender requests, save to cache - if (prerender_cache) { - void put(url, encoded); - } + // For successful prerender requests, save to cache + if (prerender_cache) { + void put(url, resolved.result); + } - return devalue.parse(encoded, app.decoders); - }); + return parse_remote_value(resolved.result, app.decoders, revive); + }; +} + +/** + * Get-or-create the {@link Prerender} resource for `(id, payload)`. When `initial` is + * provided (the resource is being revived from a seeded nested pointer), the resource + * resolves immediately to the seeded value and the value is written to the browser cache as + * if it had been fetched. + * + * @param {string} id + * @param {string} payload + * @param {PointerInitial} [initial] + * @returns {Prerender} + */ +function get_prerender_resource(id, payload, initial) { + const cache_key = create_remote_key(id, payload); + + let resource = prerender_resources.get(cache_key)?.deref(); + + if (resource) return resource; + + const url = `${base}/${app_dir}/remote/${id}${payload ? `/${payload}` : ''}`; - prerender_resources.set(cache_key, new WeakRef(resource)); - prerender_resource_cleanup?.register(resource, cache_key); + if (initial) { + resource = new Prerender( + () => Promise.reject(new Error('A seeded prerender resource should never fetch')), + initial.type === 'result' + ? { type: 'result', value: initial.value } + : { type: 'error', error: initial.error } + ); + + // populate the browser cache as if the data had been fetched. Prerender seeds always + // carry their serialized `data` (the `data`-less `initial` only arises from the + // query/query.batch `from(...)` seeding path, which never produces prerenders). + if (initial.type === 'result' && initial.data !== undefined) { + const data = initial.data; + void prerender_cache_ready.then(() => { + if (prerender_cache) void put(url, data); + }); } + } else { + resource = new Prerender(create_prerender_fetcher(id, payload, url)); + } - return resource; - }; + prerender_resources.set(cache_key, new WeakRef(resource)); + prerender_resource_cleanup?.register(resource, cache_key); + + return resource; +} + +/** + * @param {string} id + * @returns {RemotePrerenderFunction} + */ +export function prerender(id) { + return (arg) => get_prerender_resource(id, stringify_remote_arg(arg, app.hooks.transport)); } +// Revive `[id, payload, 'p']` pointers (nested `prerender` results) into a Prerender resource. +register_pointer_reviver('p', (id, payload, initial) => + get_prerender_resource(id, payload, initial) +); + /** @type {Map>>} */ const prerender_resources = new Map(); @@ -142,8 +199,26 @@ class Prerender { /** * @param {() => Promise} fn + * @param {{ type: 'result', value: T } | { type: 'error', error: any }} [body] when provided + * (seeded from a side-channel), the resource resolves immediately and `fn` is never called */ - constructor(fn) { + constructor(fn, body) { + if (body) { + this.#loading = false; + + if (body.type === 'result') { + this.#ready = true; + this.#current = body.value; + this.#promise = Promise.resolve(body.value); + } else { + this.#error = body.error; + this.#promise = Promise.reject(body.error); + this.#promise.catch(noop); + } + + return; + } + this.#promise = fn().then( (value) => { this.#loading = false; diff --git a/packages/kit/src/runtime/client/remote-functions/query-batch.svelte.js b/packages/kit/src/runtime/client/remote-functions/query-batch.svelte.js index be1416d17cc2..871218478a61 100644 --- a/packages/kit/src/runtime/client/remote-functions/query-batch.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query-batch.svelte.js @@ -2,104 +2,171 @@ /** @import { RemoteFunctionResponse } from 'types' */ import { app_dir, base } from '$app/paths/internal/client'; import { app, goto } from '../client.js'; -import { get_remote_request_headers, QUERY_FUNCTION_ID } from './shared.svelte.js'; +import { + apply_queries, + create_remote_pointer_reviver, + get_remote_request_headers, + QUERY_FUNCTION_ID, + register_pointer_reviver +} from './shared.svelte.js'; import { QueryProxy } from './query/proxy.js'; import * as devalue from 'devalue'; import { HttpError, Redirect } from '@sveltejs/kit/internal'; -import { unfriendly_hydratable } from '../../shared.js'; +import { parse_remote_value, stringify_remote_arg, unfriendly_hydratable } from '../../shared.js'; + +/** @type {Map Promise>} */ +const batch_fetchers = new Map(); /** + * Memoized per id so that all calls (and revived pointers) for the same `query.batch` + * function share a single batch. * @param {string} id - * @returns {RemoteQueryFunction} */ -export function query_batch(id) { - /** @type {Map void, reject: (error: any) => void}>>} */ +function get_batch_fetcher(id) { + let fetcher = batch_fetchers.get(id); + + if (!fetcher) { + fetcher = create_batch_fetcher(id); + batch_fetchers.set(id, fetcher); + } + + return fetcher; +} + +/** + * @param {string} id + * @returns {(key: string, payload: string) => Promise} + */ +function create_batch_fetcher(id) { + /** @type {Map void, reject: (error: any) => void, set_revive: (revive: (pointer: [string, string, 'q' | 'b' | 'p']) => any) => void }>>} */ let batching = new Map(); - /** @type {RemoteQueryFunction} */ - const wrapper = (arg) => { - return new QueryProxy(id, arg, async (key, payload) => { - const serialized = await unfriendly_hydratable(key, () => { - return new Promise((resolve, reject) => { - // create_remote_function caches identical calls, but in case a refresh to the same query is called multiple times this function - // is invoked multiple times with the same payload, so we need to deduplicate here - const entry = batching.get(payload) ?? []; - entry.push({ resolve, reject }); - batching.set(payload, entry); - - if (batching.size > 1) return; - - // Do this here, after await Svelte' reactivity context is gone. - // TODO is it possible to have batches of the same key - // but in different forks/async contexts and in the same macrotask? - // If so this would potentially be buggy - const headers = { - 'Content-Type': 'application/json', - ...get_remote_request_headers() - }; - - // Wait for the next macrotask - don't use microtask as Svelte runtime uses these to collect changes and flush them, - // and flushes could reveal more queries that should be batched. - setTimeout(async () => { - const batched = batching; - batching = new Map(); - - try { - const response = await fetch(`${base}/${app_dir}/remote/${id}`, { - method: 'POST', - body: JSON.stringify({ - payloads: Array.from(batched.keys()) - }), - headers - }); - - if (!response.ok) { - throw new Error('Failed to execute batch query'); - } + return (key, payload) => { + /** @type {((pointer: [string, string, 'q' | 'b' | 'p']) => any) | undefined} */ + let revive; - const result = /** @type {RemoteFunctionResponse} */ (await response.json()); - if (result.type === 'error') { - throw new HttpError(result.status ?? 500, result.error); - } + const hydrated = unfriendly_hydratable(key, () => { + return new Promise((resolve, reject) => { + // create_remote_function caches identical calls, but in case a refresh to the same query is called multiple times this function + // is invoked multiple times with the same payload, so we need to deduplicate here + const entry = batching.get(payload) ?? []; + entry.push({ resolve, reject, set_revive: (r) => (revive = r) }); + batching.set(payload, entry); - if (result.type === 'redirect') { - await goto(result.location); - throw new Redirect(307, result.location); - } + if (batching.size > 1) return; + + // Do this here, after await Svelte' reactivity context is gone. + const headers = { + 'Content-Type': 'application/json', + ...get_remote_request_headers() + }; + + // Wait for the next macrotask - don't use microtask as Svelte runtime uses these to collect changes and flush them, + // and flushes could reveal more queries that should be batched. + setTimeout(async () => { + const batched = batching; + batching = new Map(); + + try { + const response = await fetch(`${base}/${app_dir}/remote/${id}`, { + method: 'POST', + body: JSON.stringify({ + payloads: Array.from(batched.keys()) + }), + headers + }); + + if (!response.ok) { + throw new Error('Failed to execute batch query'); + } + + const result = /** @type {RemoteFunctionResponse} */ (await response.json()); + if (result.type === 'error') { + throw new HttpError(result.status ?? 500, result.error); + } + + if (result.type === 'redirect') { + await goto(result.location); + throw new Redirect(307, result.location); + } - const results = devalue.parse(result.result, app.decoders); + // apply the side-channel and build a reviver shared by every entry in the batch + const batch_revive = apply_queries(result.queries); - // Resolve individual queries - // Maps guarantee insertion order so we can do it like this - let i = 0; + const results = devalue.parse(result.result, app.decoders); - for (const resolvers of batched.values()) { - for (const { resolve, reject } of resolvers) { - if (results[i].type === 'error') { - reject(new HttpError(results[i].status, results[i].error)); - } else { - resolve(results[i].data); - } + // Resolve individual queries + // Maps guarantee insertion order so we can do it like this + let i = 0; + + for (const resolvers of batched.values()) { + for (const { resolve, reject, set_revive } of resolvers) { + if (results[i].type === 'error') { + reject(new HttpError(results[i].status, results[i].error)); + } else { + set_revive(batch_revive); + resolve(results[i].data); } - i++; } - } catch (error) { - // Reject all queries in the batch - for (const resolver of batched.values()) { - for (const { reject } of resolver) { - reject(error); - } + i++; + } + } catch (error) { + // Reject all queries in the batch + for (const resolver of batched.values()) { + for (const { reject } of resolver) { + reject(error); } } - }, 0); - }); + } + }, 0); }); - - return devalue.parse(serialized, app.decoders); }); + + // Hydration served the value synchronously (always a string). Parse it now so nested + // pointers read their hydratable seeds within the same synchronous hydration window. + if (typeof hydrated === 'string') { + return Promise.resolve( + parse_remote_value(hydrated, app.decoders, create_remote_pointer_reviver()) + ); + } + + return Promise.resolve(hydrated).then((serialized) => + parse_remote_value(serialized, app.decoders, revive ?? create_remote_pointer_reviver()) + ); + }; +} + +/** + * @param {string} id + * @returns {RemoteQueryFunction} + */ +export function query_batch(id) { + const fetcher = get_batch_fetcher(id); + + /** @type {RemoteQueryFunction} */ + const wrapper = (arg) => { + return new QueryProxy(id, stringify_remote_arg(arg, app.hooks.transport), fetcher); }; Object.defineProperty(wrapper, QUERY_FUNCTION_ID, { value: id }); + Object.defineProperty(wrapper, 'from', { + value: /** @type {RemoteQueryFunction['from']} */ ( + (arg, value) => + // Seed via `initial` (rather than `.set`) so an already-mounted entry for the + // same key is never clobbered — matching how revived nested pointers are seeded. + new QueryProxy(id, stringify_remote_arg(arg, app.hooks.transport), fetcher, { + type: 'result', + value + }) + ), + enumerable: true + }); return wrapper; } + +// Revive `[id, payload, 'b']` pointers (nested `query.batch` results) into a QueryProxy. +register_pointer_reviver( + 'b', + (id, payload, initial) => new QueryProxy(id, payload, get_batch_fetcher(id), initial) +); diff --git a/packages/kit/src/runtime/client/remote-functions/query-live/instance.svelte.js b/packages/kit/src/runtime/client/remote-functions/query-live/instance.svelte.js index 76fac6f6c03a..78dcd6618232 100644 --- a/packages/kit/src/runtime/client/remote-functions/query-live/instance.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query-live/instance.svelte.js @@ -1,12 +1,11 @@ /** @import { PromiseWithResolvers } from '../../../../utils/promise.js' */ -import { app } from '../../client.js'; -import * as devalue from 'devalue'; import { HttpError, Redirect } from '@sveltejs/kit/internal'; import { noop, once } from '../../../../utils/functions.js'; import { with_resolvers } from '../../../../utils/promise.js'; import { SharedIterator } from '../../../../utils/shared-iterator.js'; import { tick } from 'svelte'; import { unfriendly_hydratable } from '../../../shared.js'; +import { revive_remote_value } from '../shared.svelte.js'; import { create_live_iterator } from './iterator.js'; /** @@ -89,7 +88,7 @@ export class LiveQuery { const serialized = unfriendly_hydratable(key, () => undefined); if (serialized !== undefined) { - this.set(devalue.parse(serialized, app.decoders)); + this.set(revive_remote_value(serialized)); } } diff --git a/packages/kit/src/runtime/client/remote-functions/query-live/iterator.js b/packages/kit/src/runtime/client/remote-functions/query-live/iterator.js index 960ecacb9e9b..5c44702175d7 100644 --- a/packages/kit/src/runtime/client/remote-functions/query-live/iterator.js +++ b/packages/kit/src/runtime/client/remote-functions/query-live/iterator.js @@ -1,7 +1,11 @@ import { app_dir, base } from '$app/paths/internal/client'; import { app } from '../../client.js'; -import { get_remote_request_headers, handle_side_channel_response } from '../shared.svelte.js'; -import * as devalue from 'devalue'; +import { + apply_queries, + get_remote_request_headers, + handle_side_channel_response +} from '../shared.svelte.js'; +import { parse_remote_value } from '../../../shared.js'; import { HttpError } from '@sveltejs/kit/internal'; import { noop } from '../../../../utils/functions.js'; import { read_ndjson } from '../../ndjson.js'; @@ -44,7 +48,8 @@ async function get_stream_reader(response) { async function* read_live_ndjson(reader) { for await (const node of read_ndjson(reader)) { if (node.type === 'result') { - yield devalue.parse(node.result, app.decoders); + const revive = apply_queries(node.queries); + yield parse_remote_value(node.result, app.decoders, revive); continue; } diff --git a/packages/kit/src/runtime/client/remote-functions/query/index.js b/packages/kit/src/runtime/client/remote-functions/query/index.js index 01669b57945f..0e1989087ed9 100644 --- a/packages/kit/src/runtime/client/remote-functions/query/index.js +++ b/packages/kit/src/runtime/client/remote-functions/query/index.js @@ -1,12 +1,71 @@ /** @import { RemoteQueryFunction } from '@sveltejs/kit' */ import { app_dir, base } from '$app/paths/internal/client'; import { app, query_map, query_responses } from '../../client.js'; -import { get_remote_request_headers, QUERY_FUNCTION_ID, remote_request } from '../shared.svelte.js'; -import * as devalue from 'devalue'; +import { + apply_queries, + create_remote_pointer_reviver, + get_remote_request_headers, + QUERY_FUNCTION_ID, + register_pointer_reviver, + remote_response, + revive_remote_value +} from '../shared.svelte.js'; import { DEV } from 'esm-env'; -import { unfriendly_hydratable } from '../../../shared.js'; +import { + parse_remote_value, + stringify_remote_arg, + unfriendly_hydratable +} from '../../../shared.js'; import { QueryProxy } from './proxy.js'; +/** + * Creates the fetcher used by a {@link QueryProxy} to resolve a query's value: first from the + * universal-load hydration payload (first render only), then from the render-time + * `hydratable` cache, and finally from the network. When fetched over the network, the + * response's `queries` side-channel is applied so that nested resources are seeded. + * + * @param {string} id + * @param {() => void} [on_network] called when an actual network request is made — used to + * warn (in dev) when a *revived* nested pointer wasn't seeded and has to fetch + * @returns {(key: string, payload: string) => Promise} + */ +function create_query_fetcher(id, on_network) { + return (key, payload) => { + // universal-load seed (first render only) + if (Object.hasOwn(query_responses, key)) { + const serialized = query_responses[key]; + delete query_responses[key]; + return Promise.resolve(revive_remote_value(serialized)); + } + + const url = `${base}/${app_dir}/remote/${id}${payload ? `?payload=${payload}` : ''}`; + + /** @type {((pointer: [string, string, 'q' | 'b' | 'p']) => any) | undefined} */ + let revive; + + const hydrated = unfriendly_hydratable(key, async () => { + on_network?.(); + const resolved = await remote_response(url, get_remote_request_headers()); + revive = apply_queries(resolved.queries); + return resolved.result; + }); + + // Hydration served the value synchronously (always a string). Parse it *now*, so that + // reviving nested pointers reads their own hydratable seeds within the same synchronous + // hydration window — a later (awaited) read would miss Svelte's hydratable cache. + if (typeof hydrated === 'string') { + return Promise.resolve( + parse_remote_value(hydrated, app.decoders, create_remote_pointer_reviver()) + ); + } + + // Otherwise we're fetching: `hydrated` is the request promise. + return Promise.resolve(hydrated).then((serialized) => + parse_remote_value(serialized, app.decoders, revive ?? create_remote_pointer_reviver()) + ); + }; +} + /** * @param {string} id * @returns {RemoteQueryFunction} @@ -23,26 +82,53 @@ export function query(id) { } } + const fetcher = create_query_fetcher(id); + /** @type {RemoteQueryFunction} */ const wrapper = (arg) => { - return new QueryProxy(id, arg, async (key, payload) => { - if (Object.hasOwn(query_responses, key)) { - const value = query_responses[key]; - delete query_responses[key]; - return value; - } - - const url = `${base}/${app_dir}/remote/${id}${payload ? `?payload=${payload}` : ''}`; - - const serialized = await unfriendly_hydratable(key, () => - remote_request(url, get_remote_request_headers()) - ); - - return devalue.parse(serialized, app.decoders); - }); + return new QueryProxy(id, stringify_remote_arg(arg, app.hooks.transport), fetcher); }; Object.defineProperty(wrapper, QUERY_FUNCTION_ID, { value: id }); + Object.defineProperty(wrapper, 'from', { + value: /** @type {RemoteQueryFunction['from']} */ ( + (arg, value) => + // Seed via `initial` (rather than `.set`) so an already-mounted entry for the + // same key is never clobbered — matching how revived nested pointers are seeded. + new QueryProxy(id, stringify_remote_arg(arg, app.hooks.transport), fetcher, { + type: 'result', + value + }) + ), + enumerable: true + }); return wrapper; } + +// Revive `[id, payload, 'q']` pointers (nested `query` results) into a QueryProxy. When the +// value wasn't seeded, fetching it on use warns in dev. +register_pointer_reviver( + 'q', + (id, payload, initial) => + new QueryProxy( + id, + payload, + create_query_fetcher(id, initial ? undefined : () => warn_unseeded(id, payload)), + initial + ) +); + +/** + * @param {string} id + * @param {string} payload + */ +function warn_unseeded(id, payload) { + if (DEV) { + console.warn( + `A nested query (${id}${payload ? ` with payload ${payload}` : ''}) was used on the client ` + + `but its value wasn't serialized during SSR, so it had to be fetched. To avoid the extra ` + + `request, make sure the query is awaited/read wherever it's returned.` + ); + } +} diff --git a/packages/kit/src/runtime/client/remote-functions/query/proxy.js b/packages/kit/src/runtime/client/remote-functions/query/proxy.js index 014f7ff48200..2df60cd92cf9 100644 --- a/packages/kit/src/runtime/client/remote-functions/query/proxy.js +++ b/packages/kit/src/runtime/client/remote-functions/query/proxy.js @@ -1,11 +1,12 @@ -import { app, query_map } from '../../client.js'; +/** @import { PointerInitial } from '../shared.svelte.js' */ +import { query_map } from '../../client.js'; import { pin_in_effect, pin_while_resolving, QUERY_OVERRIDE_KEY, QUERY_RESOURCE_KEY } from '../shared.svelte.js'; -import { create_remote_key, stringify_remote_arg } from '../../../shared.js'; +import { create_remote_key } from '../../../shared.js'; import { Query } from './instance.svelte.js'; import { cache } from './cache.js'; @@ -24,24 +25,40 @@ export class QueryProxy { /** * @param {string} id - * @param {any} arg + * @param {string} payload the stringified raw argument (the cache key) * @param {(key: string, payload: string) => Promise} fn + * @param {PointerInitial} [initial] when this proxy is + * revived from a nested pointer with a seeded value, the underlying `Query` is + * constructed directly in its resolved/errored state so it never fetches. */ - constructor(id, arg, fn) { + constructor(id, payload, fn, initial) { this.#id = id; - this.#payload = stringify_remote_arg(arg, app.hooks.transport); - this.#key = create_remote_key(id, this.#payload); + this.#payload = payload; + this.#key = create_remote_key(id, payload); Object.defineProperty(this, QUERY_RESOURCE_KEY, { value: this.#key }); this.#fn = fn; const key = this.#key; - const payload = this.#payload; const entry = cache.ensure_entry( this.#id, this.#payload, // IMPORTANT: This cannot close over `this` or it becomes impossible to // garbage collect the QueryProxy and thus impossible to evict cache entries. - () => new Query(key, () => fn(key, payload)) + // `initial` is only applied when the entry is newly created (i.e. this revival + // owns the cache entry), so it never clobbers an already-mounted query. + () => { + const query = new Query(key, () => fn(key, payload)); + + if (initial) { + if (initial.type === 'result') { + query.set(initial.value); + } else { + query.fail(initial.error); + } + } + + return query; + } ); cache.ref(this, entry, this.#id, this.#payload); diff --git a/packages/kit/src/runtime/client/remote-functions/query/proxy.svelte.spec.js b/packages/kit/src/runtime/client/remote-functions/query/proxy.svelte.spec.js index 425f74a8307b..19f9e2b45a67 100644 --- a/packages/kit/src/runtime/client/remote-functions/query/proxy.svelte.spec.js +++ b/packages/kit/src/runtime/client/remote-functions/query/proxy.svelte.spec.js @@ -51,7 +51,7 @@ describe('QueryProxy', () => { test('constructing a proxy populates the cache', () => { const fn = () => Promise.resolve('value'); - new QueryProxy('q', undefined, fn); + new QueryProxy('q', '', fn); const entries = /** @type {Map} */ (query_map.get('q')); expect(entries).toBeDefined(); @@ -61,10 +61,10 @@ describe('QueryProxy', () => { expect(entry.proxy_count).toBe(1); }); - test('two proxies for the same (id, arg) share a single cache entry', () => { + test('two proxies for the same (id, payload) share a single cache entry', () => { const fn = () => Promise.resolve('value'); - new QueryProxy('q', { x: 1 }, fn); - new QueryProxy('q', { x: 1 }, fn); + new QueryProxy('q', '{"x":1}', fn); + new QueryProxy('q', '{"x":1}', fn); const entries = /** @type {Map} */ (query_map.get('q')); expect(entries.size).toBe(1); @@ -73,10 +73,10 @@ describe('QueryProxy', () => { expect(entry.proxy_count).toBe(2); }); - test('proxies for different args produce different cache entries', () => { + test('proxies for different payloads produce different cache entries', () => { const fn = () => Promise.resolve('value'); - new QueryProxy('q', { x: 1 }, fn); - new QueryProxy('q', { x: 2 }, fn); + new QueryProxy('q', '{"x":1}', fn); + new QueryProxy('q', '{"x":2}', fn); expect(query_map.get('q')?.size).toBe(2); }); @@ -87,7 +87,7 @@ describe('QueryProxy', () => { // Construct the proxy in a nested scope so V8's debug scope tracking can't // keep it alive in the surrounding closure. (() => { - new QueryProxy('q', { name: 'echo' }, fn); + new QueryProxy('q', '{"name":"echo"}', fn); })(); expect(query_map.get('q')?.size).toBe(1); diff --git a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js index 810babce87aa..8e3faed5d579 100644 --- a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js @@ -1,12 +1,22 @@ -/** @import { RemoteFunctionResponse, RemoteSingleflightMap, RemoteSingleflightEntry } from 'types' */ +/** @import { RemoteFunctionResponse, RemoteQueriesMap, RemoteSingleflightEntry, RemoteResourceCode } from 'types' */ /** @import { RemoteQueryUpdate } from '@sveltejs/kit' */ import * as devalue from 'devalue'; import { app, goto, live_query_map, query_map } from '../client.js'; import { HttpError, Redirect } from '@sveltejs/kit/internal'; import { untrack } from 'svelte'; -import { create_remote_key, split_remote_key } from '../../shared.js'; +import { + create_remote_key, + parse_remote_value, + split_remote_key, + unfriendly_hydratable +} from '../../shared.js'; import { navigating, page } from '../state.svelte.js'; +/** + * @typedef {[string, string, RemoteResourceCode]} RemotePointer + * @typedef {{ type: 'result', value: any, data?: string } | { type: 'error', error: HttpError }} PointerInitial + */ + /** Indicates a query function, as opposed to a query instance */ export const QUERY_FUNCTION_ID = Symbol('sveltekit.query_function_id'); /** Indicates a query override callback, used to release the override */ @@ -92,10 +102,14 @@ export function get_remote_request_headers() { } /** + * Perform a GET request to a remote function endpoint and return the resolved + * `result`-typed response (after handling redirects/errors). The caller is responsible for + * applying the `queries` side-channel and reviving `result`. + * * @param {string} url * @param {HeadersInit} headers */ -export async function remote_request(url, headers) { +export async function remote_response(url, headers) { const response = await fetch(url, { headers: { 'Content-Type': 'application/json', @@ -109,9 +123,7 @@ export async function remote_request(url, headers) { const result = /** @type {RemoteFunctionResponse} */ (await response.json()); - const resolved = await handle_side_channel_response(result); - - return resolved.result; + return handle_side_channel_response(result); } /** @@ -208,48 +220,156 @@ export function categorize_updates(updates) { } /** - * @template TResource - * @param {string} stringified_singleflight - * @param {Map>} map - * @param {(resource: TResource, value: RemoteSingleflightEntry) => void} callback + * Registered by the query / query.batch / prerender client modules so that nested resource + * pointers (`[id, payload, code]`) can be revived into the correct resource type without + * needing a client-side id→type registry (which wouldn't work if the nested module isn't + * loaded). There are only three codes and all three modules are always present in the + * `__sveltekit/remote` barrel. + * @type {Partial any>>} */ -function apply_singleflight(stringified_singleflight, map, callback) { - const singleflight = /** @type {RemoteSingleflightMap} */ ( - devalue.parse(stringified_singleflight, app.decoders) - ); +const pointer_revivers = {}; + +/** + * @param {RemoteResourceCode} code + * @param {(id: string, payload: string, initial: PointerInitial | undefined) => any} reviver + */ +export function register_pointer_reviver(code, reviver) { + pointer_revivers[code] = reviver; +} + +/** + * Build the `__skq` reviver that turns `[id, payload, code]` pointer tuples back into live + * resources. When a `seeds` map is supplied (from a response's `queries` side-channel), a + * matching entry is consumed and used to construct the resource directly in its + * resolved/errored state, so it never needs to fetch. + * + * @param {Map} [seeds] + * @returns {(pointer: RemotePointer) => any} + */ +export function create_remote_pointer_reviver(seeds) { + /** @param {RemotePointer} pointer */ + const revive = (pointer) => { + const [id, payload, code] = pointer; + const reviver = pointer_revivers[code]; - for (const [key, value] of Object.entries(singleflight)) { - const parts = split_remote_key(key); - const entry = map.get(parts.id)?.get(parts.payload); - if (entry?.resource) { - callback(entry.resource, value); + if (!reviver) { + throw new Error(`Cannot revive remote function pointer with unknown code "${code}"`); } - } + + /** @type {PointerInitial | undefined} */ + let initial; + + const key = create_remote_key(id, payload); + const entry = seeds?.get(key); + + if (entry) { + seeds?.delete(key); + + if (entry.type === 'result') { + initial = { + type: 'result', + value: parse_remote_value(entry.data, app.decoders, revive), + data: entry.data + }; + } else { + initial = { type: 'error', error: new HttpError(entry.status ?? 500, entry.error) }; + } + } else { + const serialized = unfriendly_hydratable(key, () => undefined); + + if (serialized !== undefined) { + initial = { + type: 'result', + value: parse_remote_value(serialized, app.decoders, revive), + data: serialized + }; + } + } + + return reviver(id, payload, initial); + }; + + return revive; } /** - * Apply refresh data from the server to the relevant queries + * Revive a serialized remote value, turning any nested `[id, payload, code]` pointers into + * resources (seeding them from `seeds` where available, otherwise leaving them to fetch on + * use). + * @param {string} serialized + * @param {Map} [seeds] + */ +export function revive_remote_value(serialized, seeds) { + return parse_remote_value(serialized, app.decoders, create_remote_pointer_reviver(seeds)); +} + +/** + * Apply the `queries` side-channel from a remote function response: live-update any queries + * already mounted on the client, and return a `__skq` reviver that seeds the *remaining* + * (new, not-yet-mounted) nested resources as they're revived from the result. * - * @param {string} stringified_refreshes + * @param {string | undefined} stringified_queries + * @returns {(pointer: RemotePointer) => any} */ -export const apply_refreshes = (stringified_refreshes) => { - apply_singleflight(stringified_refreshes, query_map, (resource, value) => { - if (value.type === 'result') { - resource?.set(value.data); +export function apply_queries(stringified_queries) { + /** @type {Map} */ + const seeds = new Map( + stringified_queries + ? Object.entries( + /** @type {RemoteQueriesMap} */ (devalue.parse(stringified_queries, app.decoders)) + ) + : [] + ); + + const revive = create_remote_pointer_reviver(seeds); + + for (const key of [...seeds.keys()]) { + // may have been consumed as a nested seed while reviving another entry's value + if (!seeds.has(key)) continue; + + const { id, payload } = split_remote_key(key); + const live = query_map.get(id)?.get(payload); + + // not mounted — leave it in `seeds` so revival can construct a new, seeded instance + if (!live?.resource) continue; + + const entry = /** @type {RemoteSingleflightEntry} */ (seeds.get(key)); + seeds.delete(key); + + if (entry.type === 'result') { + live.resource.set(parse_remote_value(entry.data, app.decoders, revive)); } else { - resource?.fail(new HttpError(value.status ?? 500, value.error)); + live.resource.fail(new HttpError(entry.status ?? 500, entry.error)); } - }); -}; + } + + return revive; +} + +/** + * Apply the live-query `reconnects` side-channel: set the latest value on mounted live + * queries and trigger a reconnect, or surface an error. + * + * @param {string} stringified_reconnects + * @param {(pointer: RemotePointer) => any} [revive] + */ +export const apply_reconnections = ( + stringified_reconnects, + revive = create_remote_pointer_reviver() +) => { + const map = /** @type {RemoteQueriesMap} */ (devalue.parse(stringified_reconnects, app.decoders)); + + for (const [key, entry] of Object.entries(map)) { + const { id, payload } = split_remote_key(key); + const live = live_query_map.get(id)?.get(payload); -/** @param {string} stringified_reconnects */ -export const apply_reconnections = (stringified_reconnects) => { - apply_singleflight(stringified_reconnects, live_query_map, (resource, value) => { - if (value.type === 'result') { - resource?.set(value.data); - void resource?.reconnect(); + if (!live?.resource) continue; + + if (entry.type === 'result') { + live.resource.set(parse_remote_value(entry.data, app.decoders, revive)); + void live.resource.reconnect(); } else { - resource?.fail(new HttpError(value.status ?? 500, value.error)); + live.resource.fail(new HttpError(entry.status ?? 500, entry.error)); } - }); + } }; diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 1bf008179878..02a646547f1a 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -15,13 +15,8 @@ import { create_server_routing_response, generate_route_object } from './server_ import { add_resolution_suffix } from '../../pathname.js'; import { try_get_request_store, with_request_store } from '@sveltejs/kit/internal/server'; import { text_encoder } from '../../utils.js'; -import { - count_non_ssi_comments, - create_replacer, - get_global_name, - handle_error_and_jsonify -} from '../utils.js'; -import { create_remote_key } from '../../shared.js'; +import { count_non_ssi_comments, get_global_name, handle_error_and_jsonify } from '../utils.js'; +import { create_remote_key, stringify_remote_value } from '../../shared.js'; import { get_status } from '../../../utils/error.js'; // TODO rename this function/module @@ -516,10 +511,13 @@ export async function render_response({ let serialized_prerender_data = ''; if (remote.data) { - /** @type {Record} */ + // each value is serialized to a devalue string (which may contain `[id, payload, code]` + // pointers to nested remote resources) so render- and load-channel revival are identical + // on the client; the outer object of strings is then `uneval`'d into the page + /** @type {Record} */ const query = {}; - /** @type {Record} */ + /** @type {Record} */ const prerender = {}; for (const [internals, cache] of remote.data) { @@ -527,6 +525,9 @@ export async function render_response({ // cannot be called from the client if (!internals.id) continue; + // prerender results may only nest other prerenders + const allow_queries = internals.type !== 'prerender'; + for (const key in cache) { const entry = cache[key]; @@ -542,7 +543,9 @@ export async function render_response({ ) { // This entry was refreshed/set by a command or form action. // Always await it so the mutation result is serialized. - store[remote_key] = await entry.data; + store[remote_key] = stringify_remote_value(await entry.data, options.hooks.transport, { + allow_queries + }); } else { // Don't block the response on pending remote data - if a query // hasn't settled yet, it wasn't awaited in the template (or is behind a pending boundary). @@ -558,20 +561,21 @@ export async function render_response({ if (result.settled) { if ('error' in result) throw result.error; - store[remote_key] = result.value; + store[remote_key] = stringify_remote_value(result.value, options.hooks.transport, { + allow_queries + }); } } } } - const replacer = create_replacer(options.hooks.transport); - + // values are already devalue strings, so no replacer is needed here if (Object.keys(query).length > 0) { - serialized_query_data = `${global}.query = ${devalue.uneval(query, replacer)};\n\n\t\t\t\t\t\t`; + serialized_query_data = `${global}.query = ${devalue.uneval(query)};\n\n\t\t\t\t\t\t`; } if (Object.keys(prerender).length > 0) { - serialized_prerender_data = `${global}.prerender = ${devalue.uneval(prerender, replacer)};\n\n\t\t\t\t\t\t`; + serialized_prerender_data = `${global}.prerender = ${devalue.uneval(prerender)};\n\n\t\t\t\t\t\t`; } } diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index b42465661d10..9fded6975ee9 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -1,5 +1,5 @@ /** @import { ActionResult, RemoteForm, RequestEvent, SSRManifest } from '@sveltejs/kit' */ -/** @import { RemoteFormInternals, RemoteFunctionResponse, RemoteInternals, RequestState, SSROptions } from 'types' */ +/** @import { RemoteFormInternals, RemoteFunctionResponse, RemoteInternals, RemoteResourceCode, RemoteSingleflightEntry, RequestState, SSROptions } from 'types' */ import { json, error } from '@sveltejs/kit'; import { HttpError, Redirect, SvelteKitError } from '@sveltejs/kit/internal'; @@ -7,6 +7,7 @@ import { with_request_store, merge_tracing } from '@sveltejs/kit/internal/server import { app_dir, base } from '$app/paths/internal/server'; import { is_form_content_type } from '../../utils/http.js'; import { parse_remote_arg, split_remote_key, stringify } from '../shared.js'; +import { serialize_remote_result } from '../app/server/remote/shared.js'; import { handle_error_and_jsonify } from './utils.js'; import { normalize_error } from '../../utils/error.js'; import { check_incorrect_fail_use } from './page/actions.js'; @@ -78,10 +79,14 @@ async function handle_remote_call_internal(event, state, options, manifest, id) internals.run(args, options) ); + // `results` is an array of `{ type, data }` entries whose `data` was already + // serialized (via `serialize_remote_result`) per-entry inside `run`, collecting any + // nested pointers into `state.remote.collected` along the way. return json( /** @type {RemoteFunctionResponse} */ ({ type: 'result', - result: stringify(results, transport) + result: stringify(results, transport), + queries: await build_queries() }) ); } @@ -117,16 +122,15 @@ async function handle_remote_call_internal(event, state, options, manifest, id) const fn = internals.fn; const result = await with_request_store({ event, state }, () => fn(data, meta, form_data)); + const result_string = serialize_remote_result(result, state); + const reconnects = result.issues ? undefined : await serialize_reconnects(); + return json( /** @type {RemoteFunctionResponse} */ ({ type: 'result', - result: stringify(result, transport), - refreshes: result.issues - ? undefined - : await serialize_singleflight(state.remote.refreshes), - reconnects: result.issues - ? undefined - : await serialize_singleflight(state.remote.reconnects) + result: result_string, + queries: result.issues ? undefined : await build_queries(), + reconnects }) ); } @@ -138,12 +142,15 @@ async function handle_remote_call_internal(event, state, options, manifest, id) const arg = parse_remote_arg(payload, transport); const data = await with_request_store({ event, state }, () => fn(arg)); + const result_string = serialize_remote_result(data, state); + const reconnects = await serialize_reconnects(); + return json( /** @type {RemoteFunctionResponse} */ ({ type: 'result', - result: stringify(data, transport), - refreshes: await serialize_singleflight(state.remote.refreshes), - reconnects: await serialize_singleflight(state.remote.reconnects) + result: result_string, + queries: await build_queries(), + reconnects }) ); } @@ -206,10 +213,18 @@ async function handle_remote_call_internal(event, state, options, manifest, id) } // only send changed data - if (result !== (result = stringify(value, transport))) { + if (result !== (result = serialize_remote_result(value, state))) { + // ship any nested query/prerender values used by this emitted value in a + // per-message `queries` side-channel so the client can revive their + // pointers without a separate request. Collected pointers accumulate on + // `state.remote`, so clear them afterwards to keep each message self-contained. + const queries = await build_queries(); + state.remote.collected?.clear(); + send(controller, { type: 'result', - result + result, + queries }); return; @@ -263,20 +278,25 @@ async function handle_remote_call_internal(event, state, options, manifest, id) fn(parse_remote_arg(payload, transport)) ); + // prerender results may only nest other prerenders (allow_queries: false) + const result_string = serialize_remote_result(data, state, internals.type !== 'prerender'); + return json( /** @type {RemoteFunctionResponse} */ ({ type: 'result', - result: stringify(data, transport) + result: result_string, + queries: await build_queries() }) ); } catch (error) { if (error instanceof Redirect) { + const reconnects = await serialize_reconnects(); return json( /** @type {RemoteFunctionResponse} */ ({ type: 'redirect', location: error.location, - refreshes: await serialize_singleflight(state.remote.refreshes), - reconnects: await serialize_singleflight(state.remote.reconnects) + queries: await build_queries(), + reconnects }) ); } @@ -301,33 +321,96 @@ async function handle_remote_call_internal(event, state, options, manifest, id) ); } - /** @param {Map> | null} map */ - async function serialize_singleflight(map) { - if (!map || map.size === 0) { - return undefined; + /** + * Serialize a settled promise into a side-channel entry. The value is serialized via + * `serialize_remote_result` so it's a per-value string that may itself contain nested + * `[id, payload, code]` pointers (and collects their values for the `queries` channel). + * @param {Promise} promise + * @param {boolean} [allow_queries=true] + * @returns {Promise} + */ + async function serialize_entry(promise, allow_queries = true) { + try { + return { type: 'result', data: serialize_remote_result(await promise, state, allow_queries) }; + } catch (error) { + const status = + error instanceof HttpError || error instanceof SvelteKitError ? error.status : 500; + + return { + type: 'error', + status, + error: await handle_error_and_jsonify(event, state, options, error) + }; } + } - const results = await Promise.all( - Array.from(map, async ([key, promise]) => { - try { - return [key, { type: 'result', data: await promise }]; - } catch (error) { - const status = - error instanceof HttpError || error instanceof SvelteKitError ? error.status : 500; - - return [ - key, - { - type: 'error', - status, - error: await handle_error_and_jsonify(event, state, options, error) - } - ]; - } - }) + /** + * The live-query `reconnects` side-channel: a map of live-query key -> current value, so + * the client can render the latest value before re-establishing its stream. + */ + async function serialize_reconnects() { + const map = state.remote.reconnects; + if (!map || map.size === 0) return undefined; + + const entries = await Promise.all( + Array.from(map, async ([key, promise]) => [key, await serialize_entry(promise)]) ); - return stringify(Object.fromEntries(results), transport); + return stringify(Object.fromEntries(entries), transport); + } + + /** + * The `queries` side-channel: explicit single-flight refreshes/sets *plus* every nested + * query/prerender that was used and referenced as a pointer in the response. Iterated to a + * fixpoint because serializing one value can reveal further nested pointers. + */ + async function build_queries() { + /** @type {Record} */ + const serialized = {}; + const seen = new Set(); + + /** @type {Array<{ key: string, code: RemoteResourceCode, get_value: () => Promise }>} */ + let worklist = []; + + // explicit single-flight refreshes/sets target queries already mounted on the client + for (const [key, promise] of state.remote.refreshes ?? []) { + if (seen.has(key)) continue; + seen.add(key); + worklist.push({ key, code: 'q', get_value: () => promise }); + } + + const enqueue_collected = () => { + for (const [key, { internals, payload, code }] of state.remote.collected ?? []) { + if (seen.has(key)) continue; + seen.add(key); + worklist.push({ + key, + code, + get_value: () => + Promise.resolve(/** @type {any} */ (state.remote.data?.get(internals))?.[payload]?.data) + }); + } + }; + + enqueue_collected(); + + while (worklist.length > 0) { + const batch = worklist; + worklist = []; + + await Promise.all( + batch.map(async ({ key, code, get_value }) => { + serialized[key] = { ...(await serialize_entry(get_value(), code !== 'p')), code }; + }) + ); + + // serializing values above may have collected further nested pointers + enqueue_collected(); + } + + if (Object.keys(serialized).length === 0) return undefined; + + return stringify(serialized, transport); } } diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 6a7395b40b44..4ce62d61aa2d 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -151,6 +151,9 @@ export async function internal_respond(request, options, manifest, state) { refreshes: null, requested: null, reconnects: null, + collected: null, + prerender_seeds: null, + prerender_resolved: null, batches: null, live_iterators: null }, diff --git a/packages/kit/src/runtime/shared.js b/packages/kit/src/runtime/shared.js index aface60cb5b0..1707070cb613 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -1,4 +1,5 @@ /** @import { Transport } from '@sveltejs/kit' */ +/** @import { RemoteInternals } from 'types' */ import * as devalue from 'devalue'; import { base64_decode, base64_encode, text_encoder } from './utils.js'; import * as svelte from 'svelte'; @@ -51,6 +52,109 @@ export function stringify(data, transport) { return devalue.stringify(data, encoders); } +// "sveltekit query pointer" +const remote_value_pointer = '__skq'; + +export const REMOTE_VALUE_BRAND = Symbol('sveltekit.remote_value_pointer'); + +/** + * Single-character codes used to identify the kind of resource a pointer refers to, + * kept short to minimise wire size. `query.live` is intentionally absent — live queries + * cannot be returned from other remote functions. + * @type {Record} + */ +const remote_type_codes = { + query: 'q', + query_batch: 'b', + prerender: 'p' +}; + +/** + * `devalue.stringify` a remote function's return value, replacing any nested remote + * resources (query / query.batch / prerender) with `[id, payload, code]` pointer tuples + * via the `__skq` reducer. Reading the pointer marker does not execute the nested + * resource — only its identity is serialized. The nested resource's *value* is serialized + * separately (and only if it was used), via the relevant hydration/side-channel. + * + * @param {any} value + * @param {Transport} transport + * @param {{ + * allow_queries?: boolean, + * on_pointer?: (info: { internals: RemoteInternals, payload: string, code: 'q' | 'b' | 'p' }) => void + * }} [options] + * @returns {string} + */ +export function stringify_remote_value( + value, + transport, + { allow_queries = true, on_pointer } = {} +) { + /** @type {Record any>} */ + const reducers = {}; + + for (const key in transport) { + reducers[key] = transport[key].encode; + } + + reducers[remote_value_pointer] = (thing) => { + const marker = + thing != null && typeof thing === 'object' + ? /** @type {any} */ (thing)[REMOTE_VALUE_BRAND] + : undefined; + + if (!marker) return; + + /** @type {{ internals: RemoteInternals, payload: string }} */ + const { internals, payload } = marker; + const { type, id, name } = internals; + + if (type === 'query_live') { + throw new Error( + `Cannot return the live query \`${name}\` from a remote function — live queries cannot be nested` + ); + } + + const code = remote_type_codes[type]; + + // not a nestable remote resource (e.g. command/form) — let devalue handle it + if (!code) return; + + if (!allow_queries && code !== 'p') { + throw new Error( + `Cannot return the query \`${name}\` from a prerender function — prerender functions can only return other prerender functions` + ); + } + + if (!id) { + throw new Error( + `Cannot serialize the remote function \`${name}\` because it is not exported from a \`.remote\` file` + ); + } + + on_pointer?.({ internals, payload, code }); + + return [id, payload, code]; + }; + + return devalue.stringify(value, reducers); +} + +/** + * `devalue.parse` a remote function's return value, reviving nested resource pointers + * (`[id, payload, code]` tuples emitted by {@link stringify_remote_value}) via the + * supplied `revive_pointer` callback. + * + * @param {string} serialized + * @param {Record any>} decoders + * @param {(pointer: [string, string, 'q' | 'b' | 'p']) => any} revive_pointer + */ +export function parse_remote_value(serialized, decoders, revive_pointer) { + return devalue.parse(serialized, { + ...decoders, + [remote_value_pointer]: revive_pointer + }); +} + const object_proto_names = /* @__PURE__ */ Object.getOwnPropertyNames(Object.prototype) .sort() .join('\0'); diff --git a/packages/kit/src/runtime/shared.spec.js b/packages/kit/src/runtime/shared.spec.js index 48a00e6eb6f1..fe209893d4af 100644 --- a/packages/kit/src/runtime/shared.spec.js +++ b/packages/kit/src/runtime/shared.spec.js @@ -1,5 +1,11 @@ import { describe, expect, test } from 'vitest'; -import { parse_remote_arg, stringify_remote_arg } from './shared.js'; +import { + parse_remote_arg, + parse_remote_value, + REMOTE_VALUE_BRAND, + stringify_remote_arg, + stringify_remote_value +} from './shared.js'; class Thing { /** @param {number} a @param {number} z */ @@ -369,3 +375,81 @@ describe('parse_remote_arg', () => { expect(Object.keys(parsed.nested)).toEqual(['a', 'b']); }); }); + +/** + * @param {{ id: string, type: string, name?: string }} internals + * @param {string} payload + */ +function fake_resource(internals, payload) { + const resource = {}; + Object.defineProperty(resource, REMOTE_VALUE_BRAND, { + value: { internals: { name: '', ...internals }, payload } + }); + return resource; +} + +describe('stringify_remote_value / parse_remote_value', () => { + test('serializes a nested query resource as an [id, payload, code] pointer', () => { + const child = fake_resource({ id: 'app/x.remote.js/get_child', type: 'query' }, 'PAYLOAD'); + const serialized = stringify_remote_value({ a: 1, child }, {}); + + const revived = parse_remote_value(serialized, {}, (pointer) => ({ pointer })); + + expect(revived).toEqual({ + a: 1, + child: { pointer: ['app/x.remote.js/get_child', 'PAYLOAD', 'q'] } + }); + }); + + test('uses single-character codes for query, query.batch and prerender', () => { + /** @type {Array<[string, string]>} */ + const cases = [ + ['query', 'q'], + ['query_batch', 'b'], + ['prerender', 'p'] + ]; + + for (const [type, code] of cases) { + const res = fake_resource({ id: 'id', type }, 'P'); + const serialized = stringify_remote_value( + { res }, + {}, + { allow_queries: type !== 'prerender' } + ); + const revived = parse_remote_value(serialized, {}, (pointer) => pointer); + expect(revived.res).toEqual(['id', 'P', code]); + } + }); + + test('invokes on_pointer for each emitted pointer', () => { + const child = fake_resource({ id: 'id', type: 'query' }, 'P'); + /** @type {string[]} */ + const seen = []; + + stringify_remote_value({ child }, {}, { on_pointer: (info) => seen.push(info.code) }); + + expect(seen).toEqual(['q']); + }); + + test('throws when a live query is returned', () => { + const live = fake_resource({ id: 'id', type: 'query_live', name: 'myLive' }, 'P'); + expect(() => stringify_remote_value({ live }, {})).toThrow(/live quer/i); + }); + + test('throws when a prerender returns a query', () => { + const q = fake_resource({ id: 'id', type: 'query', name: 'myQuery' }, 'P'); + expect(() => stringify_remote_value({ q }, {}, { allow_queries: false })).toThrow(/prerender/i); + }); + + test('allows a prerender to return a prerender', () => { + const p = fake_resource({ id: 'id', type: 'prerender' }, 'P'); + const serialized = stringify_remote_value({ p }, {}, { allow_queries: false }); + const revived = parse_remote_value(serialized, {}, (pointer) => pointer); + expect(revived.p).toEqual(['id', 'P', 'p']); + }); + + test('throws for a resource that is not exported (no id)', () => { + const res = fake_resource({ id: '', type: 'query', name: 'anon' }, 'P'); + expect(() => stringify_remote_value({ res }, {})).toThrow(/not exported/i); + }); +}); diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index c64d0e46fde6..faee629d5600 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -310,8 +310,8 @@ export type ServerNodesResponse = { export type RemoteFunctionResponse = | (ServerRedirectNode & { - /** devalue'd Record */ - refreshes?: string; + /** devalue'd RemoteQueriesMap */ + queries?: string; /** devalue'd Record */ reconnects?: string; }) @@ -319,27 +319,38 @@ export type RemoteFunctionResponse = | { type: 'result'; result: string; - /** devalue'd Record */ - refreshes: string | undefined; + /** + * devalue'd RemoteQueriesMap — the single-flight/side-channel map of query + * (and nested-returned query/prerender) values to apply/seed on the client + */ + queries: string | undefined; /** devalue'd Record */ reconnects: string | undefined; }; +/** Single-character code identifying the kind of resource a side-channel entry seeds */ +export type RemoteResourceCode = 'q' | 'b' | 'p'; + export type RemoteSingleflightResult = { type: 'result'; - data: any; + /** devalue'd value string (may itself contain `[id, payload, code]` pointers) */ + data: string; + code?: RemoteResourceCode; }; export type RemoteSingleflightError = { type: 'error'; status?: number; error: App.Error; + code?: RemoteResourceCode; }; export type RemoteSingleflightEntry = RemoteSingleflightResult | RemoteSingleflightError; export type RemoteSingleflightMap = Record; +export type RemoteQueriesMap = RemoteSingleflightMap; + export type RemoteLiveQueryUserFunctionReturnType = MaybePromise< | AsyncGenerator | AsyncIterator @@ -721,6 +732,23 @@ export interface RequestState { /** A map of remote function key to corresponding single-flight-mutation promise */ refreshes: null | Map>; reconnects: null | Map>; + /** + * Nested remote resources (query / query.batch / prerender) that were used during the + * request and referenced as pointers in a serialized result. Populated by + * `serialize_remote_result` and flushed into the response's `queries` side-channel so + * the client can revive the pointers without an extra round-trip. + */ + collected: null | Map< + string, + { internals: RemoteInternals; payload: string; code: 'q' | 'b' | 'p' } + >; + /** + * Server-side seeds for nested prerender pointers, populated from a parent prerender's + * `queries` side-channel so the pointers can be revived without re-fetching. + */ + prerender_seeds: null | Map; + /** De-dupes server-side revival of nested prerender pointers within a request */ + prerender_resolved: null | Map>; /** A map of remote function ID to payloads requested for refreshing by the client */ requested: null | Map; /** A map of query.batch ID to payloads requested for that batch within the same macrotask */ diff --git a/packages/kit/test/apps/async/src/routes/remote/nested-from/+page.svelte b/packages/kit/test/apps/async/src/routes/remote/nested-from/+page.svelte new file mode 100644 index 000000000000..5f368cc4dfbb --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/remote/nested-from/+page.svelte @@ -0,0 +1,35 @@ + + +

Seeded nested query (from)

+ + +

{(await parent).label}

+

{(await (await parent).child).value}

+

{(await client_seeded).value}

+ + {#snippet failed(error)} +

error: {/** @type {Error} */ (error).message}

+ {/snippet} +
+ + + +

{command_result}

diff --git a/packages/kit/test/apps/async/src/routes/remote/nested-live/+page.svelte b/packages/kit/test/apps/async/src/routes/remote/nested-live/+page.svelte new file mode 100644 index 000000000000..fe60013d5713 --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/remote/nested-live/+page.svelte @@ -0,0 +1,21 @@ + + +

Nested query in a live query

+ + + {@const first = await live} +

{first.tick}

+

+ {live.current ? `${live.current.tick}:${(await live.current.child).value}` : 'pending'} +

+ + {#snippet failed(error)} +

error: {/** @type {Error} */ (error).message}

+ {/snippet} +
+ + diff --git a/packages/kit/test/apps/async/src/routes/remote/nested-prerender/+page.svelte b/packages/kit/test/apps/async/src/routes/remote/nested-prerender/+page.svelte new file mode 100644 index 000000000000..0de3321580e1 --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/remote/nested-prerender/+page.svelte @@ -0,0 +1,15 @@ + + +

Nested prerender

+ + +

{await (await pparent).child}

+ + {#snippet failed(error)} +

error: {/** @type {Error} */ (error).message}

+ {/snippet} +
diff --git a/packages/kit/test/apps/async/src/routes/remote/nested/+page.svelte b/packages/kit/test/apps/async/src/routes/remote/nested/+page.svelte new file mode 100644 index 000000000000..5c5af272e575 --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/remote/nested/+page.svelte @@ -0,0 +1,31 @@ + + +

Nested remote functions

+ + +

{(await parent).label}

+

{(await (await parent).child).value}

+ + {#snippet failed(error)} +

error: {/** @type {Error} */ (error).message}

+ {/snippet} +
+ + + +

{command_result}

diff --git a/packages/kit/test/apps/async/src/routes/remote/nested/nested.remote.js b/packages/kit/test/apps/async/src/routes/remote/nested/nested.remote.js new file mode 100644 index 000000000000..962c7ea8fe2c --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/remote/nested/nested.remote.js @@ -0,0 +1,99 @@ +import { command, getRequestEvent, prerender, query } from '$app/server'; + +let child_calls = 0; + +export const get_child = query('unchecked', (/** @type {string} */ id) => { + child_calls++; + return { id, value: `child:${id}` }; +}); + +export const get_child_calls = query(() => child_calls); + +export const reset_child_calls = command(() => { + child_calls = 0; +}); + +// returns a nested query that IS awaited during render, so its value gets seeded +export const get_parent = query('unchecked', (/** @type {string} */ id) => { + return { id, label: `parent:${id}`, child: get_child(id) }; +}); + +// returns a nested query that is NOT used — only the pointer is serialized, and the client +// fetches the value on use +export const get_parent_unused = query('unchecked', (/** @type {string} */ id) => { + return { id, child: get_child(`${id}-unused`) }; +}); + +// a command that returns a nested query whose value is seeded via the side-channel +export const create_child = command('unchecked', async (/** @type {string} */ id) => { + const child = get_child(id); + await child; // mark as used so its value is seeded into the response + return { created: id, child }; +}); + +// returns a nested query seeded via `.from(...)`: the query's own function is never invoked, +// but the provided value still travels to the client so the pointer resolves without a fetch +export const get_parent_from = query('unchecked', (/** @type {string} */ id) => { + return { + id, + label: `parent-from:${id}`, + child: get_child.from(id, { id, value: `seeded:${id}` }) + }; +}); + +// a command that returns a nested query created (and seeded) via `.from(...)` +export const create_child_from = command('unchecked', (/** @type {string} */ id) => { + return { created: id, child: get_child.from(id, { id, value: `seeded:${id}` }) }; +}); + +// a live query that yields values containing a nested query, seeded per stream message +/** @type {Set<() => void>} */ +const live_listeners = new Set(); +let live_tick = 0; + +export const bump_live = command(() => { + live_tick += 1; + for (const listener of live_listeners) listener(); + live_listeners.clear(); +}); + +export const live_with_child = query.live(async function* () { + const signal = getRequestEvent().request.signal; + + while (true) { + const tick = live_tick; + const child = get_child(`live-${tick}`); + await child; // mark used so its value is seeded into the stream message + yield { tick, child }; + + const changed = await new Promise((resolve) => { + const on_change = () => { + signal.removeEventListener('abort', on_abort); + resolve(true); + }; + const on_abort = () => { + live_listeners.delete(on_change); + resolve(false); + }; + live_listeners.add(on_change); + signal.addEventListener('abort', on_abort, { once: true }); + }); + + if (!changed) return; + } +}); + +const prerender_child = prerender('unchecked', (/** @type {string} */ id) => `pchild:${id}`, { + dynamic: true +}); + +export { prerender_child }; + +// a prerender that returns a nested prerender +export const prerender_parent = prerender( + 'unchecked', + (/** @type {string} */ id) => { + return { id, child: prerender_child(id) }; + }, + { dynamic: true } +); diff --git a/packages/kit/test/apps/async/test/client.test.js b/packages/kit/test/apps/async/test/client.test.js index b10c7ed5123b..7f30b57b409a 100644 --- a/packages/kit/test/apps/async/test/client.test.js +++ b/packages/kit/test/apps/async/test/client.test.js @@ -809,6 +809,113 @@ test.describe('remote function mutations', () => { // Should have refreshed await expect(count).toHaveText('Count: 1'); }); + + test('a query returning a nested query seeds it without an extra request', async ({ page }) => { + let request_count = 0; + /** @param {import('@playwright/test').Request} r */ + const handler = (r) => (request_count += r.url().includes('/_app/remote') ? 1 : 0); + page.on('request', handler); + + await page.goto('/remote/nested'); + + await expect(page.locator('#parent')).toHaveText('parent:a'); + // the nested query value was seeded during SSR, so it resolves without a request + await expect(page.locator('#child')).toHaveText('child:a'); + + await page.waitForTimeout(100); + expect(request_count).toBe(0); + }); + + test('a prerender can return a nested prerender', async ({ page }) => { + let nested_request_count = 0; + /** @param {import('@playwright/test').Request} r */ + const handler = (r) => (nested_request_count += r.url().includes('prerender_child') ? 1 : 0); + page.on('request', handler); + + await page.goto('/remote/nested-prerender'); + await expect(page.locator('#prerender-child')).toHaveText('pchild:p'); + + await page.waitForTimeout(100); + // the nested prerender's value is seeded (server-side revival + the parent response's + // `queries` side-channel), so it never has to be fetched separately on the client + expect(nested_request_count).toBe(0); + }); + + test('a live query can return a nested query seeded per stream message', async ({ page }) => { + let nested_request_count = 0; + /** @param {import('@playwright/test').Request} r */ + const handler = (r) => (nested_request_count += r.url().includes('get_child') ? 1 : 0); + page.on('request', handler); + + await page.goto('/remote/nested-live'); + await expect(page.locator('#live-child')).toHaveText('0:child:live-0'); + + // trigger a new stream value whose nested query value only arrives over the stream + await page.click('#bump'); + await expect(page.locator('#live-child')).toHaveText('1:child:live-1'); + + await page.waitForTimeout(100); + // the nested query's value rides along in each stream message's `queries` side-channel, + // so it's never fetched separately + expect(nested_request_count).toBe(0); + }); + + test('a command can return a nested query seeded via the side-channel', async ({ page }) => { + await page.goto('/remote/nested'); + await expect(page.locator('#parent')).toHaveText('parent:a'); + + let request_count = 0; + /** @param {import('@playwright/test').Request} r */ + const handler = (r) => (request_count += r.url().includes('/_app/remote') ? 1 : 0); + page.on('request', handler); + + await page.click('#create-child'); + + // the nested query returned by the command resolves to its seeded value + await expect(page.locator('#command-result')).toHaveText('z/child:z'); + + await page.waitForTimeout(100); + // only the command POST itself — the nested query was seeded, not fetched + expect(request_count).toBe(1); + }); + + test('a query can return a nested query seeded with `.from(...)`', async ({ page }) => { + let request_count = 0; + /** @param {import('@playwright/test').Request} r */ + const handler = (r) => (request_count += r.url().includes('/_app/remote') ? 1 : 0); + page.on('request', handler); + + await page.goto('/remote/nested-from'); + + await expect(page.locator('#parent')).toHaveText('parent-from:a'); + // the child's value was provided via `.from(...)` (its function never ran), and seeded + // into the page — so it resolves to the seeded value without a request + await expect(page.locator('#child')).toHaveText('seeded:a'); + // a query seeded purely on the client via `.from(...)` also resolves without a request + await expect(page.locator('#client-seeded')).toHaveText('client-seeded'); + + await page.waitForTimeout(100); + expect(request_count).toBe(0); + }); + + test('a command can return a nested query seeded with `.from(...)`', async ({ page }) => { + await page.goto('/remote/nested-from'); + await expect(page.locator('#parent')).toHaveText('parent-from:a'); + + let request_count = 0; + /** @param {import('@playwright/test').Request} r */ + const handler = (r) => (request_count += r.url().includes('/_app/remote') ? 1 : 0); + page.on('request', handler); + + await page.click('#create-child'); + + // the nested query created by the command via `.from(...)` resolves to its seeded value + await expect(page.locator('#command-result')).toHaveText('z/seeded:z'); + + await page.waitForTimeout(100); + // only the command POST itself — the nested query was seeded, not fetched + expect(request_count).toBe(1); + }); }); test.describe('client error boundaries', () => { diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 1948fe5f4615..ff2578389bc5 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2273,9 +2273,32 @@ declare module '@sveltejs/kit' { * `Input = number` but `Validated = string`). For `'unchecked'` validators and queries * without arguments it defaults to `Input`. */ - export type RemoteQueryFunction = ( + export type RemoteQueryFunction = (( arg: undefined extends Input ? Input | void : Input - ) => RemoteQuery; + ) => RemoteQuery) & { + /** + * Create a query that is seeded with a value you already have, without invoking the + * query's function. This is useful on the server when one remote function has already + * loaded the data a nested query would otherwise fetch — seed it with `from(...)` and + * return it, and its value travels back to the client alongside the response so the + * client never has to fetch it: + * + * ```js + * export const getPost = query(async (slug) => { + * const post = await db.getPost(slug); + * // seed the per-comment queries so the client doesn't refetch them + * return { post, comments: post.comments.map((c) => getComment.from(c.id, c)) }; + * }); + * ``` + * + * Pass `undefined` as `arg` for queries that take no argument. + * + * Unlike [`set`](#type-RemoteQuery), which updates a query the client already has + * mounted as part of a single-flight `command`/`form` mutation, `from` constructs a new + * query instance carrying the provided value. + */ + from(arg: undefined extends Input ? Input | void : Input, value: Output): RemoteQuery; + }; /** * The type of a remote `query.live` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.live) for full documentation.