Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tall-melons-nest.md
Original file line number Diff line number Diff line change
@@ -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
27 changes: 25 additions & 2 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2299,9 +2299,32 @@ export type RemotePrerenderFunction<Input, Output> = (
* `Input = number` but `Validated = string`). For `'unchecked'` validators and queries
* without arguments it defaults to `Input`.
*/
export type RemoteQueryFunction<Input, Output, _Validated = Input> = (
export type RemoteQueryFunction<Input, Output, _Validated = Input> = ((
arg: undefined extends Input ? Input | void : Input
) => RemoteQuery<Output>;
) => RemoteQuery<Output>) & {
/**
* 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<Output>;
};

/**
* The type of a remote `query.live` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.live) for full documentation.
Expand Down
158 changes: 151 additions & 7 deletions packages/kit/src/runtime/app/server/remote/prerender.js
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -89,10 +96,15 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) {

/** @type {RemotePrerenderFunction<Input, Output> & { __: 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<Output> & Partial<RemoteResource<Output>>} */
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}` : ''}`;

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -155,10 +177,132 @@ 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<Output>} */ (promise);
};

Object.defineProperty(wrapper, '__', { value: __ });

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<any>}
*/
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;
}
70 changes: 65 additions & 5 deletions packages/kit/src/runtime/app/server/remote/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -101,6 +102,12 @@ export function query(validate_or_fn, maybe_fn) {
};

Object.defineProperty(wrapper, '__', { value: __ });
Object.defineProperty(wrapper, 'from', {
value: /** @type {RemoteQueryFunction<Input, Output>['from']} */ (
(arg, value) => create_seeded_query(__, arg, value)
),
enumerable: true
});

return wrapper;
}
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -368,6 +375,12 @@ function batch(validate_or_fn, maybe_fn) {
};

Object.defineProperty(wrapper, '__', { value: __ });
Object.defineProperty(wrapper, 'from', {
value: /** @type {RemoteQueryFunction<Input, Output>['from']} */ (
(arg, value) => create_seeded_query(__, arg, value)
),
enumerable: true
});

return wrapper;
}
Expand All @@ -394,7 +407,8 @@ function create_query_resource(__, payload, state, fn) {
void (__.id && state.is_in_render && get_promise());
};

return {
/** @type {RemoteQuery<any>} */
const resource = {
/** @type {Promise<any>['catch']} */
catch(onrejected) {
return get_promise().catch(onrejected);
Expand Down Expand Up @@ -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<any>}
*/
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);
}

/**
Expand Down Expand Up @@ -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<any>} */
const resource = {
/** @type {Promise<any>['catch']} */
catch(onrejected) {
return get_promise().catch(onrejected);
Expand Down Expand Up @@ -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;
}

/**
Expand Down
Loading