From 1affeca31e571eb42dc97bab13293d852a818da7 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 20 Aug 2025 00:30:16 +0200 Subject: [PATCH 01/32] feat: add new remote function `query.batch` Implements `query.batch` to address the n+1 problem --- .changeset/red-waves-give.md | 5 + .../20-core-concepts/60-remote-functions.md | 41 ++++++ .../src/exports/internal/remote-functions.js | 8 +- .../src/runtime/app/server/remote/query.js | 126 ++++++++++++++++++ .../client/remote-functions/query.svelte.js | 77 ++++++++++- packages/kit/src/runtime/server/remote.js | 17 +++ packages/kit/src/types/internal.d.ts | 2 +- .../src/routes/remote/batch/+page.svelte | 23 ++++ .../src/routes/remote/batch/batch.remote.js | 12 ++ .../src/routes/remote/validation/+page.svelte | 21 ++- .../remote/validation/validation.remote.js | 8 ++ .../kit/test/apps/basics/test/client.test.js | 15 +++ packages/kit/types/index.d.ts | 18 +++ 13 files changed, 368 insertions(+), 5 deletions(-) create mode 100644 .changeset/red-waves-give.md create mode 100644 packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js diff --git a/.changeset/red-waves-give.md b/.changeset/red-waves-give.md new file mode 100644 index 000000000000..54af4ea776d0 --- /dev/null +++ b/.changeset/red-waves-give.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add new remote function `query.batch` diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index ef0cfe6dc843..49319511579f 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -172,6 +172,47 @@ Any query can be updated via its `refresh` method: > [!NOTE] Queries are cached while they're on the page, meaning `getPosts() === getPosts()`. This means you don't need a reference like `const posts = getPosts()` in order to refresh the query. +## query.batch + +`query.batch` works like `query` except that it batches requests that happen within the same macrotask. This solves the so-called n+1 problem where you have many calls to the same resource and it's more efficient to do one invocation with an array of arguments of the collected calls instead of doing one invocation per call. + +```js +/// file: src/routes/blog/data.remote.js +// @filename: ambient.d.ts +declare module '$lib/server/database' { + export function sql(strings: TemplateStringsArray, ...values: any[]): Promise; +} +// @filename: index.js +// ---cut--- +import * as v from 'valibot'; +import { query } from '$app/server'; +import * as db from '$lib/server/database'; + +export const getPost = query.batch(v.string(), async (slugs) => { + const posts = await db.sql` + SELECT * FROM post + WHERE slug = ANY(${slugs}) + `; + return posts; +}); +``` + +```svelte + + +

All my posts

+ + +``` + ## form The `form` function makes it easy to write data to the server. It takes a callback that receives the current [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)... diff --git a/packages/kit/src/exports/internal/remote-functions.js b/packages/kit/src/exports/internal/remote-functions.js index ad7962399cb8..fe85f39b025f 100644 --- a/packages/kit/src/exports/internal/remote-functions.js +++ b/packages/kit/src/exports/internal/remote-functions.js @@ -12,7 +12,13 @@ export function validate_remote_functions(module, file) { for (const name in module) { const type = module[name]?.__?.type; - if (type !== 'form' && type !== 'command' && type !== 'query' && type !== 'prerender') { + if ( + type !== 'form' && + type !== 'command' && + type !== 'query' && + type !== 'query.batch' && + type !== 'prerender' + ) { throw new Error( `\`${name}\` exported from ${file} is invalid — all exports from this file must be remote functions` ); diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 6a210a2ed8f7..67d22dbffd5b 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -104,3 +104,129 @@ export function query(validate_or_fn, maybe_fn) { return wrapper; } + +/** + * Creates a batch query function that collects multiple calls and executes them in a single request + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation. + * + * @template Input + * @template Output + * @overload + * @param {'unchecked'} validate + * @param {(args: Input[]) => MaybePromise} fn + * @returns {RemoteQueryFunction} + * @since 2.27 + */ +/** + * Creates a batch query function that collects multiple calls and executes them in a single request + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation. + * + * @template {StandardSchemaV1} Schema + * @template Output + * @overload + * @param {Schema} schema + * @param {(args: StandardSchemaV1.InferOutput[]) => MaybePromise} fn + * @returns {RemoteQueryFunction, Output>} + * @since 2.27 + */ +/** + * @template Input + * @template Output + * @param {any} validate_or_fn + * @param {(args?: Input[]) => MaybePromise} [maybe_fn] + * @returns {RemoteQueryFunction} + * @since 2.27 + */ +/*@__NO_SIDE_EFFECTS__*/ +function batch(validate_or_fn, maybe_fn) { + /** @type {(args?: Input[]) => Output[]} */ + const fn = maybe_fn ?? validate_or_fn; + + /** @type {(arg?: any) => MaybePromise} */ + const validate = create_validator(validate_or_fn, maybe_fn); + + /** @type {RemoteInfo} */ + const __ = { type: 'query.batch', id: '', name: '' }; + + /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}>, timeoutId: any }} */ + let batching = { args: [], resolvers: [], timeoutId: null }; + + /** @type {RemoteQueryFunction & { __: RemoteInfo }} */ + const wrapper = (arg) => { + if (prerendering) { + throw new Error( + `Cannot call query.batch '${__.name}' while prerendering, as prerendered pages need static data. Use 'prerender' from $app/server instead` + ); + } + + const { event, state } = get_request_store(); + + /** @type {Promise & Partial>} */ + const promise = get_response(__.id, arg, state, () => { + // Collect all the calls to the same query in the same macrotask, + // then execute them as one backend request. + return new Promise((resolve, reject) => { + batching.args.push(arg); + batching.resolvers.push({ resolve, reject }); + + if (batching.timeoutId) { + clearTimeout(batching.timeoutId); + } + + batching.timeoutId = setTimeout(async () => { + const batched = batching; + batching = { args: [], resolvers: [], timeoutId: null }; + + try { + const results = await run_remote_function( + event, + state, + false, + batched.args, + (array) => Promise.all(array.map(validate)), + fn + ); + for (let i = 0; i < batched.resolvers.length; i++) { + batched.resolvers[i].resolve(results[i]); + } + } catch (error) { + for (const resolver of batched.resolvers) { + resolver.reject(error); + } + } + }, 0); + }); + }); + + promise.catch(() => {}); + + promise.refresh = async () => { + const { state } = get_request_store(); + const refreshes = state.refreshes; + + if (!refreshes) { + throw new Error( + `Cannot call refresh on query.batch '${__.name}' because it is not executed in the context of a command/form remote function` + ); + } + + const cache_key = create_remote_cache_key(__.id, stringify_remote_arg(arg, state.transport)); + refreshes[cache_key] = await /** @type {Promise} */ (promise); + }; + + promise.withOverride = () => { + throw new Error(`Cannot call '${__.name}.withOverride()' on the server`); + }; + + return /** @type {RemoteQuery} */ (promise); + }; + + Object.defineProperty(wrapper, '__', { value: __ }); + + return wrapper; +} + +// Add batch as a property to the query function +Object.defineProperty(query, 'batch', { value: batch, enumerable: true }); diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index a6243bc85a1b..871e552ba5ab 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -1,8 +1,11 @@ /** @import { RemoteQueryFunction } from '@sveltejs/kit' */ +/** @import { RemoteFunctionResponse } from 'types' */ import { app_dir, base } from '__sveltekit/paths'; -import { remote_responses, started } from '../client.js'; +import { app, goto, remote_responses, started } from '../client.js'; import { tick } from 'svelte'; import { create_remote_function, remote_request } from './shared.svelte.js'; +import * as devalue from 'devalue'; +import { HttpError, Redirect } from '@sveltejs/kit/internal'; /** * @param {string} id @@ -25,6 +28,78 @@ export function query(id) { }); } +/** + * @param {string} id + * @returns {(arg: any) => Query} + */ +function batch(id) { + /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}>, timeoutId: any }} */ + let batching = { args: [], resolvers: [], timeoutId: null }; + + return create_remote_function(id, (cache_key, payload) => { + return new Query(cache_key, () => { + // Collect all the calls to the same query in the same macrotask, + // then execute them as one backend request. + return new Promise((resolve, reject) => { + batching.args.push(payload); + batching.resolvers.push({ resolve, reject }); + + if (batching.timeoutId) { + clearTimeout(batching.timeoutId); + } + + batching.timeoutId = setTimeout(async () => { + const batched = batching; + batching = { args: [], resolvers: [], timeoutId: null }; + + try { + const response = await fetch(`${base}/${app_dir}/remote/${id}`, { + method: 'POST', + body: JSON.stringify({ + payloads: batched.args + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + 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') { + // TODO double-check this + await goto(result.location); + await new Promise((r) => setTimeout(r, 100)); + throw new Redirect(307, result.location); + } + + const results = devalue.parse(result.result, app.decoders); + + // Resolve individual queries + for (let i = 0; i < batched.resolvers.length; i++) { + batched.resolvers[i].resolve(results[i]); + } + } catch (error) { + // Reject all queries in the batch + for (const resolver of batched.resolvers) { + resolver.reject(error); + } + } + }, 0); // Wait one macrotask + }); + }); + }); +} + +// Add batch as a property to the query function +Object.defineProperty(query, 'batch', { value: batch, enumerable: true }); + /** * @template T * @implements {Partial>} diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 6a5ec140a990..369854d41edd 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -60,6 +60,23 @@ async function handle_remote_call_internal(event, state, options, manifest, id) let form_client_refreshes; try { + if (info.type === 'query.batch' && event.request.method === 'POST') { + /** @type {{ payloads: string[] }} */ + const { payloads } = await event.request.json(); + + const args = payloads.map((payload) => parse_remote_arg(payload, transport)); + const results = await with_request_store({ event, state }, () => + Promise.all(args.map((arg) => fn(arg))) + ); + + return json( + /** @type {RemoteFunctionResponse} */ ({ + type: 'result', + result: stringify(results, transport) + }) + ); + } + if (info.type === 'form') { if (!is_form_content_type(event.request)) { throw new SvelteKitError( diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 24d7d2bd4a7b..6a4720bdbeb8 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -548,7 +548,7 @@ export type ValidatedKitConfig = Omit, 'adapter'> & export type RemoteInfo = | { - type: 'query' | 'command'; + type: 'query' | 'query.batch' | 'command'; id: string; name: string; } diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte new file mode 100644 index 000000000000..54650e293a3d --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte @@ -0,0 +1,23 @@ + + +

Query Batch Test

+ +
    + {#each todoIds as id} +
  • + {#await get_todo(id)} + Loading todo {id}... + {:then todo} + {todo.title} + {:catch error} + Error loading todo {id}: {error.message} + {/await} +
  • + {/each} +
+ + diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js new file mode 100644 index 000000000000..484880f13d46 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js @@ -0,0 +1,12 @@ +import { query } from '$app/server'; + +const mock_data = [ + { id: '1', title: 'Buy groceries' }, + { id: '2', title: 'Walk the dog' }, + { id: '3', title: 'Write code' }, + { id: '4', title: 'Read book' } +]; + +export const get_todo = query.batch('unchecked', (ids) => { + return ids.map((id) => mock_data.find((todo) => todo.id === id)); +}); diff --git a/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte index a0ab91242535..f911e3b9aef4 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte @@ -6,7 +6,9 @@ validated_prerendered_query_no_args, validated_prerendered_query_with_arg, validated_command_no_args, - validated_command_with_arg + validated_command_with_arg, + validated_batch_query_no_validation, + validated_batch_query_with_validation } from './validation.remote.js'; function validate_result(result) { @@ -32,6 +34,9 @@ validate_result(await validated_prerendered_query_with_arg('valid')); validate_result(await validated_command_with_arg('valid')); + validate_result(await validated_batch_query_no_validation('valid')); + validate_result(await validated_batch_query_with_validation('valid')); + status = 'success'; } catch (e) { status = 'error'; @@ -98,7 +103,17 @@ status = 'wrong error message'; return; } - status = 'success'; + + try { + await validated_batch_query_with_validation(123); + status = 'error'; + } catch (e) { + if (!isHttpError(e) || e.body.message !== 'Input must be a string') { + status = 'wrong error message'; + return; + } + status = 'success'; + } } } } @@ -117,6 +132,8 @@ validate_result(await validated_prerendered_query_with_arg('valid', 'ignored')); // @ts-expect-error validate_result(await validated_command_with_arg('valid', 'ignored')); + // @ts-expect-error + validate_result(await validated_batch_query_no_validation('valid', 'ignored')); status = 'success'; } catch (e) { diff --git a/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js b/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js index b58825d9cc9e..7a2e734a05be 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js +++ b/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js @@ -35,3 +35,11 @@ export const validated_command_no_args = command((arg) => export const validated_command_with_arg = command(schema, (...arg) => typeof arg[0] === 'string' && arg.length === 1 ? 'success' : 'failure' ); + +export const validated_batch_query_no_validation = query.batch('unchecked', (items) => + items.map((item) => (item === 'valid' ? 'success' : 'failure')) +); + +export const validated_batch_query_with_validation = query.batch(schema, (items) => + items.map((item) => (typeof item === 'string' ? 'success' : 'failure')) +); diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index b76fe93f52fe..c06dd870050f 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1917,4 +1917,19 @@ test.describe('remote functions', () => { await expect(page.locator('#form-pending')).toHaveText('Form pending: 0'); await expect(page.locator('#form-button-pending')).toHaveText('Button pending: 0'); }); + + // TODO once we have async SSR adjust the test and move this into test.js + test('query.batch works', async ({ page }) => { + await page.goto('/remote/batch'); + + await expect(page.locator('#batch-result-1')).toHaveText('Buy groceries'); + await expect(page.locator('#batch-result-2')).toHaveText('Walk the dog'); + + let request_count = 0; + page.on('request', (r) => (request_count += r.url().includes('/_app/remote') ? 1 : 0)); + + await page.click('button'); + await page.waitForTimeout(100); // allow all requests to finish + expect(request_count).toBe(1); + }); }); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 6c7fc61b4492..58897ba0b816 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2899,6 +2899,24 @@ declare module '$app/server' { * @since 2.27 */ export function query(schema: Schema, fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise): RemoteQueryFunction, Output>; + export namespace query { + /** + * Creates a batch query function that collects multiple calls and executes them in a single request + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation. + * + * @since 2.27 + */ + function batch(validate: "unchecked", fn: (args: Input[]) => MaybePromise): RemoteQueryFunction; + /** + * Creates a batch query function that collects multiple calls and executes them in a single request + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation. + * + * @since 2.27 + */ + function batch(schema: Schema, fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise): RemoteQueryFunction, Output>; + } type RemotePrerenderInputsGenerator = () => MaybePromise; type MaybePromise = T | Promise; From b70a3941389ac6f57a3741c902663f62760382f6 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 20 Aug 2025 00:31:10 +0200 Subject: [PATCH 02/32] my god this high already --- packages/kit/src/runtime/app/server/remote/query.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 67d22dbffd5b..55737a862570 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -116,7 +116,7 @@ export function query(validate_or_fn, maybe_fn) { * @param {'unchecked'} validate * @param {(args: Input[]) => MaybePromise} fn * @returns {RemoteQueryFunction} - * @since 2.27 + * @since 2.35 */ /** * Creates a batch query function that collects multiple calls and executes them in a single request @@ -129,7 +129,7 @@ export function query(validate_or_fn, maybe_fn) { * @param {Schema} schema * @param {(args: StandardSchemaV1.InferOutput[]) => MaybePromise} fn * @returns {RemoteQueryFunction, Output>} - * @since 2.27 + * @since 2.35 */ /** * @template Input @@ -137,7 +137,7 @@ export function query(validate_or_fn, maybe_fn) { * @param {any} validate_or_fn * @param {(args?: Input[]) => MaybePromise} [maybe_fn] * @returns {RemoteQueryFunction} - * @since 2.27 + * @since 2.35 */ /*@__NO_SIDE_EFFECTS__*/ function batch(validate_or_fn, maybe_fn) { From bd0c519bc55b67075fe2750d02b24231fa90c32e Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 20 Aug 2025 00:57:34 +0200 Subject: [PATCH 03/32] hhnngghhhh --- packages/kit/types/index.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 58897ba0b816..1c322e087beb 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2905,7 +2905,7 @@ declare module '$app/server' { * * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation. * - * @since 2.27 + * @since 2.35 */ function batch(validate: "unchecked", fn: (args: Input[]) => MaybePromise): RemoteQueryFunction; /** @@ -2913,7 +2913,7 @@ declare module '$app/server' { * * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation. * - * @since 2.27 + * @since 2.35 */ function batch(schema: Schema, fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise): RemoteQueryFunction, Output>; } From 60fb389aebf0b4fb05c64aec151e4b4445405744 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 00:45:11 +0200 Subject: [PATCH 04/32] feat: add remote function `query.stream` - streams are deduplicated on the client, i.e. same resource+payload == same stream instance - a stream is kept open as long as it's either in a reactive context or someone iterates over it. Streams can close when noone listens anymore and open back up when someone does again - cannot iterate on the server right now, only retrieve the first value via promise - cannot refresh/override (doesn't make sense) --- .changeset/smart-nails-allow.md | 5 + .../20-core-concepts/60-remote-functions.md | 66 ++++ .../src/exports/internal/remote-functions.js | 1 + packages/kit/src/exports/public.d.ts | 10 + .../src/runtime/app/server/remote/query.js | 119 ++++++- .../client/remote-functions/query.svelte.js | 214 +++++++++++- .../client/remote-functions/shared.svelte.js | 3 + packages/kit/src/runtime/server/remote.js | 63 ++++ packages/kit/src/types/internal.d.ts | 2 +- .../basics/src/routes/remote/stream/+page.js | 1 + .../src/routes/remote/stream/+page.svelte | 35 ++ .../src/routes/remote/stream/stream.remote.js | 17 + .../kit/test/apps/basics/test/client.test.js | 29 ++ packages/kit/types/index.d.ts | 329 +++++++++++++----- 14 files changed, 809 insertions(+), 85 deletions(-) create mode 100644 .changeset/smart-nails-allow.md create mode 100644 packages/kit/test/apps/basics/src/routes/remote/stream/+page.js create mode 100644 packages/kit/test/apps/basics/src/routes/remote/stream/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/remote/stream/stream.remote.js diff --git a/.changeset/smart-nails-allow.md b/.changeset/smart-nails-allow.md new file mode 100644 index 000000000000..b51d57cf2bc7 --- /dev/null +++ b/.changeset/smart-nails-allow.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add remote function `query.stream` diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 49319511579f..74a1af2b9592 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -213,6 +213,72 @@ export const getPost = query.batch(v.string(), async (slugs) => { ``` +## query.stream + +`query.stream` allows you to stream continuous data from the server to the client. + +```js +/// file: src/routes/time.remote.js +// @filename: ambient.d.ts +declare module '$lib/server/database' { + export function sql(strings: TemplateStringsArray, ...values: any[]): Promise; +} +// @filename: index.js +// ---cut--- +import { query } from '$app/server'; + +export const time = query.stream(async function* () { + while (true) { + yield new Date(); + await new Promise(r => setTimeout(r, 1000)) + } +}); +``` + +You can consume the stream like a promise or via the `current` property. In both cases, if it's used in a reactive context, it will automatically update to the latest version upon retrieving new data. + +```svelte + + + +

{await time()}

+

{time().current}

+``` + +Apart from that you can iterate over it like any other async iterable, including using `for await (...)`. + +```svelte + + + + + +{#each times as time} + {time} +{/each} +``` + +Stream requests to the same resource with the same payload are deduplicated, i.e. you cannot start the same stream multiple times in parallel and it to start from the beginning each time. + ## form The `form` function makes it easy to write data to the server. It takes a callback that receives the current [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)... diff --git a/packages/kit/src/exports/internal/remote-functions.js b/packages/kit/src/exports/internal/remote-functions.js index fe85f39b025f..e280d31e8389 100644 --- a/packages/kit/src/exports/internal/remote-functions.js +++ b/packages/kit/src/exports/internal/remote-functions.js @@ -17,6 +17,7 @@ export function validate_remote_functions(module, file) { type !== 'command' && type !== 'query' && type !== 'query.batch' && + type !== 'query.stream' && type !== 'prerender' ) { throw new Error( diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index c92f523a17b8..36fc9d2ac1b0 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1822,6 +1822,16 @@ export interface RemoteQueryOverride { release(): void; } +/** + * The return value of a remote `query.stream` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-stream) for full documentation. + */ +export type RemoteQueryStream = RemoteResource & AsyncIterable>; + +/** + * The return value of a remote `query.stream` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-stream) for full documentation. + */ +export type RemoteQueryStreamFunction = (arg: Input) => RemoteQueryStream; + /** * The return value of a remote `prerender` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#prerender) for full documentation. */ diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 55737a862570..94d0d2f33618 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -1,4 +1,4 @@ -/** @import { RemoteQuery, RemoteQueryFunction } from '@sveltejs/kit' */ +/** @import { RemoteQuery, RemoteQueryFunction, RemoteQueryStream, RemoteQueryStreamFunction } from '@sveltejs/kit' */ /** @import { RemoteInfo, MaybePromise } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { get_request_store } from '@sveltejs/kit/internal/server'; @@ -228,5 +228,120 @@ function batch(validate_or_fn, maybe_fn) { return wrapper; } -// Add batch as a property to the query function +/** + * Creates a streaming remote query. When called from the browser, the generator function will be invoked on the server and values will be streamed via Server-Sent Events. + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.stream) for full documentation. + * + * @template Output + * @overload + * @param {() => Generator | AsyncGenerator} fn + * @returns {RemoteQueryStreamFunction} + * @since 2.36 + */ +/** + * Creates a streaming remote query. When called from the browser, the generator function will be invoked on the server and values will be streamed via Server-Sent Events. + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.stream) for full documentation. + * + * @template Input + * @template Output + * @overload + * @param {'unchecked'} validate + * @param {(arg: Input) => Generator | AsyncGenerator} fn + * @returns {RemoteQueryStreamFunction} + * @since 2.36 + */ +/** + * Creates a streaming remote query. When called from the browser, the generator function will be invoked on the server and values will be streamed via Server-Sent Events. + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.stream) for full documentation. + * + * @template {StandardSchemaV1} Schema + * @template Output + * @overload + * @param {Schema} schema + * @param {(arg: StandardSchemaV1.InferOutput) => Generator | AsyncGenerator} fn + * @returns {RemoteQueryStreamFunction, Output>} + * @since 2.36 + */ +/** + * @template Input + * @template Output + * @param {any} validate_or_fn + * @param {(arg?: Input) => Generator | AsyncGenerator} [maybe_fn] + * @returns {RemoteQueryStreamFunction} + * @since 2.36 + */ +/*@__NO_SIDE_EFFECTS__*/ +function stream(validate_or_fn, maybe_fn) { + /** @type {(arg?: Input) => Generator | AsyncGenerator} */ + const fn = maybe_fn ?? validate_or_fn; + + /** @type {(arg?: any) => MaybePromise} */ + const validate = create_validator(validate_or_fn, maybe_fn); + + /** @type {RemoteInfo} */ + const __ = { type: 'query.stream', id: '', name: '' }; + + /** @type {RemoteQueryStreamFunction & { __: RemoteInfo }} */ + const wrapper = (/** @type {Input} */ arg) => { + if (prerendering) { + throw new Error( + `Cannot call query.stream '${__.name}' while prerendering, as prerendered pages need static data. Use 'prerender' from $app/server instead` + ); + } + + const { event, state } = get_request_store(); + + /** @type {IteratorResult | undefined} */ + let first_value; + + const promise = (async () => { + // We only care about the generator when doing a remote request + if (event.isRemoteRequest) return; + + const generator = await run_remote_function(event, state, false, arg, validate, fn); + first_value = await generator.next(); + await generator.return(); + return first_value.done ? undefined : first_value.value; + })(); + + // Catch promise to avoid unhandled rejection + promise.catch(() => {}); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Object.assign(promise, { + async *[Symbol.asyncIterator]() { + if (event.isRemoteRequest) { + const generator = await run_remote_function(event, state, false, arg, validate, fn); + yield* generator; + } else { + // TODO how would we subscribe to the stream on the server while deduplicating calls and knowing when to stop? + throw new Error( + 'Cannot iterate over a stream on the server. This restriction may be lifted in a future version.' + ); + } + }, + get error() { + return undefined; + }, + get ready() { + return !!first_value; + }, + get current() { + return first_value?.value; + } + }); + + return /** @type {RemoteQueryStream} */ (promise); + }; + + Object.defineProperty(wrapper, '__', { value: __ }); + + return wrapper; +} + +// Add batch and stream as properties to the query function Object.defineProperty(query, 'batch', { value: batch, enumerable: true }); +Object.defineProperty(query, 'stream', { value: stream, enumerable: true }); diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 871e552ba5ab..edd2940ce747 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -1,4 +1,4 @@ -/** @import { RemoteQueryFunction } from '@sveltejs/kit' */ +/** @import { RemoteQueryFunction, RemoteQueryStreamFunction } from '@sveltejs/kit' */ /** @import { RemoteFunctionResponse } from 'types' */ import { app_dir, base } from '__sveltekit/paths'; import { app, goto, remote_responses, started } from '../client.js'; @@ -97,8 +97,218 @@ function batch(id) { }); } -// Add batch as a property to the query function +/** + * @param {string} id + * @returns {RemoteQueryStreamFunction} + */ +function stream(id) { + return create_remote_function(id, (_, payload) => { + const url = `${base}/${app_dir}/remote/${id}${payload ? `?payload=${payload}` : ''}`; + return new QueryStream(url); + }); +} + +/** + * Query stream class that implements both Promise and AsyncIterable interfaces + * @template T + * @implements {Partial>} + */ +class QueryStream { + /** + * The promise next() and then/catch/finally methods return. Is reset after each message from the EventSource. + * @type {Promise} + */ + // @ts-expect-error TS doesn't see that we assign it in the constructor indirectly through function calls + #promise; + + /** + * The resolve function for the promise. + * @type {(value: any) => void} + */ + // @ts-expect-error TS doesn't see that we assign it in the constructor indirectly through function calls + #resolve; + + /** + * The reject function for the promise. + * @type {(error?: any) => void} + */ + // @ts-expect-error TS doesn't see that we assign it in the constructor indirectly through function calls + #reject; + + /** @type {any} */ + #current = $state.raw(); + + /** @type {boolean} */ + #ready = $state(false); + + /** @type {any} */ + #error = $state(); + + /** @type {EventSource | undefined} */ + #source; + + /** + * How many active async iterators are using this stream. + * If there are no active iterators, the EventSource is closed if it's unused. + * @type {number} */ + #count = 0; + + /** + * The URL of the EventSource. + * @type {string} + */ + #url; + + /** + * Becomes `true` when our query map deletes this stream, which means there's no reactive listener to it anymore. + * @type {boolean} + */ + #unused = false; + + /** + * @param {string} url + */ + constructor(url) { + this.#url = url; + this.#next(); + } + + #create_promise() { + this.#reject?.(); // in case there's a dangling listener + this.#promise = new Promise((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + }); + } + + #next() { + if (this.#source && this.#source.readyState !== EventSource.CLOSED) return; + + this.#create_promise(); + this.#source = new EventSource(this.#url); + + const source = this.#source; + + /** @param {MessageEvent} event */ + const onMessage = (event) => { + this.#ready = true; + this.#error = undefined; + + const message = event.data; + + if (message === '[DONE]') { + source.close(); + this.#resolve({ done: true, value: undefined }); + return; + } + + const parsed = devalue.parse(message, app.decoders); + if (parsed && typeof parsed === 'object' && parsed.type === 'error') { + source.close(); + this.#reject((this.#error = new HttpError(parsed.status ?? 500, parsed.error))); + return; + } + + this.#current = parsed; + this.#resolve({ done: false, value: parsed }); + this.#create_promise(); + }; + + /** @param {Event} error */ + const onError = (error) => { + this.#error = error; + this.#reject(error); + }; + + this.#source.addEventListener('message', onMessage); + this.#source.addEventListener('error', onError); + } + + get then() { + this.#current; + + /** + * @param {any} resolve + * @param {any} reject + */ + return (resolve, reject) => { + // On first call we return the promise. In all other cases we don't want any delay and return the current value. + // The getter will self-invalidate when the next message is received. + if (!this.#ready) { + return this.#promise.then((v) => v.value).then(resolve, reject); + } else { + if (this.#error) { + return reject(this.#error); + } else { + return resolve(this.#current); + } + } + }; + } + + get catch() { + this.#current; + + return (/** @type {any} */ reject) => { + return this.then(undefined, reject); + }; + } + + get finally() { + this.#current; + + return (/** @type {any} */ fn) => { + return this.then( + () => fn(), + () => fn() + ); + }; + } + + get current() { + return this.#current; + } + + get ready() { + return this.#ready; + } + + get error() { + return this.#error; + } + + _dispose() { + this.#unused = true; + if (this.#count === 0) { + this.#source?.close(); + } + } + + [Symbol.asyncIterator]() { + // Restart the stream in case it was closed previously. + // Can happen if this is iterated over from a non-reactive context. + this.#next(); + this.#count++; + const that = this; + + return { + next() { + return that.#promise; + }, + return() { + that.#count--; + if (that.#count === 0 && that.#unused) { + that.#source?.close(); + } + return Promise.resolve({ done: true, value: undefined }); + } + }; + } +} + +// Add batch and stream as properties to the query function Object.defineProperty(query, 'batch', { value: batch, enumerable: true }); +Object.defineProperty(query, 'stream', { value: stream, enumerable: true }); /** * @template T 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 7d94374f40b1..fc072b802b81 100644 --- a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js @@ -59,6 +59,7 @@ export function create_remote_function(id, create) { entry.count--; void tick().then(() => { if (!entry.count && entry === query_map.get(cache_key)) { + entry.resource._dispose?.(); query_map.delete(cache_key); } }); @@ -93,6 +94,7 @@ export function create_remote_function(id, create) { entry === query_map.get(cache_key) ) { // If no one is tracking this resource anymore, we can delete it from the cache + resource._dispose?.(); query_map.delete(cache_key); } }); @@ -100,6 +102,7 @@ export function create_remote_function(id, create) { .catch(() => { // error delete the resource from the cache // TODO is that correct? + resource._dispose?.(); query_map.delete(cache_key); }); } diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 369854d41edd..5b2fb17fad47 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -121,6 +121,69 @@ async function handle_remote_call_internal(event, state, options, manifest, id) ); } + if (info.type === 'query.stream') { + const payload = /** @type {string} */ ( + new URL(event.request.url).searchParams.get('payload') + ); + + const generator = with_request_store({ event, state }, () => + fn(parse_remote_arg(payload, transport)) + ); + + // Return a Server-Sent Events stream + let cancelled = false; + + return new Response( + new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + + try { + for await (const value of generator) { + if (cancelled) break; + const serialized = stringify(value, transport); + const chunk = `data: ${serialized}\n\n`; + controller.enqueue(encoder.encode(chunk)); + } + + // Send end marker + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + } catch (error) { + console.error(error); + // Send error and close + const errorData = await handle_error_and_jsonify(event, state, options, error); + const serialized = stringify( + { + type: 'error', + error: errorData, + status: + error instanceof HttpError || error instanceof SvelteKitError + ? error.status + : 500 + }, + transport + ); + const chunk = `data: ${serialized}\n\n`; + controller.enqueue(encoder.encode(chunk)); + controller.close(); + } + }, + + cancel() { + cancelled = true; + } + }), + { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'private, no-store', + connection: 'keep-alive' + } + } + ); + } + const payload = info.type === 'prerender' ? prerender_args diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 6a4720bdbeb8..8233a2d8ea98 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -548,7 +548,7 @@ export type ValidatedKitConfig = Omit, 'adapter'> & export type RemoteInfo = | { - type: 'query' | 'query.batch' | 'command'; + type: 'query' | 'query.batch' | 'query.stream' | 'command'; id: string; name: string; } diff --git a/packages/kit/test/apps/basics/src/routes/remote/stream/+page.js b/packages/kit/test/apps/basics/src/routes/remote/stream/+page.js new file mode 100644 index 000000000000..5ee254f59b7a --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/stream/+page.js @@ -0,0 +1 @@ +export const ssr = false; // TODO once async SSR exists also test server diff --git a/packages/kit/test/apps/basics/src/routes/remote/stream/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/stream/+page.svelte new file mode 100644 index 000000000000..39da2a69e9c7 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/stream/+page.svelte @@ -0,0 +1,35 @@ + + + +{#if true} +

{#await time() then t}{t}{/await}

+{/if} + +{#if true} +

{time().current}

+{/if} + +{#if true} +

[{streamValues.join(', ')}]

+{/if} + + diff --git a/packages/kit/test/apps/basics/src/routes/remote/stream/stream.remote.js b/packages/kit/test/apps/basics/src/routes/remote/stream/stream.remote.js new file mode 100644 index 000000000000..dc37661ac76d --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/stream/stream.remote.js @@ -0,0 +1,17 @@ +import { command, query } from '$app/server'; + +let i = 0; +let p = Promise.withResolvers(); + +export const next = command(() => { + i++; + p.resolve(); + p = Promise.withResolvers(); +}); + +export const time = query.stream(async function* () { + while (i < 2) { + yield i; + await p.promise; + } +}); diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index c06dd870050f..2bbba54ca056 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1932,4 +1932,33 @@ test.describe('remote functions', () => { await page.waitForTimeout(100); // allow all requests to finish expect(request_count).toBe(1); }); + + test('query.stream works', async ({ page }) => { + let request_count = 0; + page.on('request', (r) => (request_count += r.url().includes('/_app/remote') ? 1 : 0)); + + await page.goto('/remote/stream'); + + await expect(page.locator('#time-promise')).toHaveText('0'); + await expect(page.locator('#time-resource')).toHaveText('0'); + await expect(page.locator('#time-stream')).toHaveText('[0]'); + + expect(request_count).toBe(1); // deduplicated time() stream + + await page.click('button'); + await expect(page.locator('#time-promise')).toHaveText('1'); + await expect(page.locator('#time-resource')).toHaveText('1'); + await expect(page.locator('#time-stream')).toHaveText('[0, 1]'); + + await page.waitForTimeout(100); // allow all requests to finish + expect(request_count).toBe(2); // just the next() command + + await page.click('button'); + await expect(page.locator('#time-promise')).toHaveText('1'); + await expect(page.locator('#time-resource')).toHaveText('1'); + await expect(page.locator('#time-stream')).toHaveText('[0, 1]'); + + await page.waitForTimeout(100); // allow all requests to finish + expect(request_count).toBe(3); // just the next() command + }); }); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 1c322e087beb..d4b39be365f7 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -4,7 +4,11 @@ declare module '@sveltejs/kit' { import type { SvelteConfig } from '@sveltejs/vite-plugin-svelte'; import type { StandardSchemaV1 } from '@standard-schema/spec'; - import type { RouteId as AppRouteId, LayoutParams as AppLayoutParams, ResolvedPathname } from '$app/types'; + import type { + RouteId as AppRouteId, + LayoutParams as AppLayoutParams, + ResolvedPathname + } from '$app/types'; import type { Span } from '@opentelemetry/api'; /** * [Adapters](https://svelte.dev/docs/kit/adapters) are responsible for taking the production build and turning it into something that can be deployed to a platform of your choosing. @@ -269,7 +273,10 @@ declare module '@sveltejs/kit' { * @param name the name of the cookie * @param opts the options, passed directly to `cookie.serialize`. The `path` must match the path of the cookie you want to delete. See documentation [here](https://github.com/jshttp/cookie#cookieserializename-value-options) */ - delete: (name: string, opts: import('cookie').CookieSerializeOptions & { path: string }) => void; + delete: ( + name: string, + opts: import('cookie').CookieSerializeOptions & { path: string } + ) => void; /** * Serialize a cookie name-value pair into a `Set-Cookie` header string, but don't apply it to the response. @@ -1419,7 +1426,10 @@ declare module '@sveltejs/kit' { * but they will always be split at sensible boundaries such as `%sveltekit.head%` or layout/page components. * @param input the html chunk and the info if this is the last chunk */ - transformPageChunk?: (input: { html: string; done: boolean }) => MaybePromise; + transformPageChunk?: (input: { + html: string; + done: boolean; + }) => MaybePromise; /** * Determines which headers should be included in serialized responses when a `load` function loads a resource with `fetch`. * By default, none will be included. @@ -1798,6 +1808,29 @@ declare module '@sveltejs/kit' { release(): void; } + /** + * The return value of a remote `query.stream` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-stream) for full documentation. + */ + export type RemoteQueryStream = RemoteResource & + AsyncIterable> & { + /** + * On the client, this function will re-fetch the query from the server. + * + * On the server, this can be called in the context of a `command` or `form` and the refreshed data will accompany the action response back to the client. + * This prevents SvelteKit needing to refresh all queries on the page in a second server round-trip. + */ + refresh(): Promise; + /** + * Temporarily override the value of a query. This is used with the `updates` method of a [command](https://svelte.dev/docs/kit/remote-functions#command-Updating-queries) or [enhanced form submission](https://svelte.dev/docs/kit/remote-functions#form-enhance) to provide optimistic updates. + */ + withOverride(update: (current: Awaited) => Awaited): RemoteQueryOverride; + }; + + /** + * The return value of a remote `query.stream` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-stream) for full documentation. + */ + export type RemoteQueryStreamFunction = (arg: Input) => RemoteQueryStream; + /** * The return value of a remote `prerender` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#prerender) for full documentation. */ @@ -1829,7 +1862,9 @@ declare module '@sveltejs/kit' { * A function that is invoked once the entry has been created. This is where you * should write the function to the filesystem and generate redirect manifests. */ - complete(entry: { generateManifest(opts: { relativePath: string }): string }): MaybePromise; + complete(entry: { + generateManifest(opts: { relativePath: string }): string; + }): MaybePromise; } // Based on https://github.com/josh-hemphill/csp-typed-directives/blob/latest/src/csp.types.ts @@ -2294,16 +2329,24 @@ declare module '@sveltejs/kit' { * @throws {HttpError} This error instructs SvelteKit to initiate HTTP error handling. * @throws {Error} If the provided status is invalid (not between 400 and 599). */ - export function error(status: number, body?: { - message: string; - } extends App.Error ? App.Error | string | undefined : never): never; + export function error( + status: number, + body?: { + message: string; + } extends App.Error + ? App.Error | string | undefined + : never + ): never; /** * Checks whether this is an error thrown by {@link error}. * @param status The status to filter for. * */ - export function isHttpError(e: unknown, status?: T): e is (HttpError_1 & { + export function isHttpError( + e: unknown, + status?: T + ): e is HttpError_1 & { status: T extends undefined ? never : T; - }); + }; /** * Redirect a request. When called during request handling, SvelteKit will return a redirect response. * Make sure you're not catching the thrown redirect, which would prevent SvelteKit from handling it. @@ -2320,7 +2363,10 @@ declare module '@sveltejs/kit' { * @throws {Redirect} This error instructs SvelteKit to redirect to the specified location. * @throws {Error} If the provided status is invalid. * */ - export function redirect(status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | ({} & number), location: string | URL): never; + export function redirect( + status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | ({} & number), + location: string | URL + ): never; /** * Checks whether this is a redirect thrown by {@link redirect}. * @param e The object to check. @@ -2372,20 +2418,31 @@ declare module '@sveltejs/kit' { wasNormalized: boolean; denormalize: (url?: string | URL) => URL; }; - export type LessThan = TNumber extends TArray["length"] ? TArray[number] : LessThan; - export type NumericRange = Exclude, LessThan>; + export type LessThan< + TNumber extends number, + TArray extends any[] = [] + > = TNumber extends TArray['length'] + ? TArray[number] + : LessThan; + export type NumericRange = Exclude< + TEnd | LessThan, + LessThan + >; export const VERSION: string; class HttpError_1 { - - constructor(status: number, body: { - message: string; - } extends App.Error ? (App.Error | string | undefined) : App.Error); + constructor( + status: number, + body: { + message: string; + } extends App.Error + ? App.Error | string | undefined + : App.Error + ); status: number; body: App.Error; toString(): string; } class Redirect_1 { - constructor(status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308, location: string); status: 301 | 302 | 303 | 307 | 308 | 300 | 304 | 305 | 306; location: string; @@ -2472,13 +2529,20 @@ declare module '@sveltejs/kit/hooks' { } declare module '@sveltejs/kit/node' { - export function getRequest({ request, base, bodySizeLimit }: { - request: import("http").IncomingMessage; + export function getRequest({ + request, + base, + bodySizeLimit + }: { + request: import('http').IncomingMessage; base: string; bodySizeLimit?: number; }): Promise; - export function setResponse(res: import("http").ServerResponse, response: Response): Promise; + export function setResponse( + res: import('http').ServerResponse, + response: Response + ): Promise; /** * Converts a file on disk to a readable stream * @since 2.4.0 @@ -2503,7 +2567,7 @@ declare module '@sveltejs/kit/vite' { /** * Returns the SvelteKit Vite plugins. * */ - export function sveltekit(): Promise; + export function sveltekit(): Promise; export {}; } @@ -2551,7 +2615,10 @@ declare module '$app/forms' { * } * ``` * */ - export function deserialize | undefined, Failure extends Record | undefined>(result: string): import("@sveltejs/kit").ActionResult; + export function deserialize< + Success extends Record | undefined, + Failure extends Record | undefined + >(result: string): import('@sveltejs/kit').ActionResult; /** * This action enhances a `
` element that otherwise would work without JavaScript. * @@ -2575,14 +2642,23 @@ declare module '$app/forms' { * @param form_element The form element * @param submit Submit callback */ - export function enhance | undefined, Failure extends Record | undefined>(form_element: HTMLFormElement, submit?: import("@sveltejs/kit").SubmitFunction): { + export function enhance< + Success extends Record | undefined, + Failure extends Record | undefined + >( + form_element: HTMLFormElement, + submit?: import('@sveltejs/kit').SubmitFunction + ): { destroy(): void; }; /** * This action updates the `form` property of the current page with the given data and updates `page.status`. * In case of an error, it redirects to the nearest error page. * */ - export function applyAction | undefined, Failure extends Record | undefined>(result: import("@sveltejs/kit").ActionResult): Promise; + export function applyAction< + Success extends Record | undefined, + Failure extends Record | undefined + >(result: import('@sveltejs/kit').ActionResult): Promise; export {}; } @@ -2593,7 +2669,9 @@ declare module '$app/navigation' { * * `afterNavigate` must be called during a component initialization. It remains active as long as the component is mounted. * */ - export function afterNavigate(callback: (navigation: import("@sveltejs/kit").AfterNavigate) => void): void; + export function afterNavigate( + callback: (navigation: import('@sveltejs/kit').AfterNavigate) => void + ): void; /** * A navigation interceptor that triggers before we navigate to a URL, whether by clicking a link, calling `goto(...)`, or using the browser back/forward controls. * @@ -2605,7 +2683,9 @@ declare module '$app/navigation' { * * `beforeNavigate` must be called during a component initialization. It remains active as long as the component is mounted. * */ - export function beforeNavigate(callback: (navigation: import("@sveltejs/kit").BeforeNavigate) => void): void; + export function beforeNavigate( + callback: (navigation: import('@sveltejs/kit').BeforeNavigate) => void + ): void; /** * A lifecycle function that runs the supplied `callback` immediately before we navigate to a new URL except during full-page navigations. * @@ -2615,7 +2695,9 @@ declare module '$app/navigation' { * * `onNavigate` must be called during a component initialization. It remains active as long as the component is mounted. * */ - export function onNavigate(callback: (navigation: import("@sveltejs/kit").OnNavigate) => MaybePromise<(() => void) | void>): void; + export function onNavigate( + callback: (navigation: import('@sveltejs/kit').OnNavigate) => MaybePromise<(() => void) | void> + ): void; /** * If called when the page is being updated following a navigation (in `onMount` or `afterNavigate` or an action, for example), this disables SvelteKit's built-in scroll handling. * This is generally discouraged, since it breaks user expectations. @@ -2630,14 +2712,17 @@ declare module '$app/navigation' { * @param url Where to navigate to. Note that if you've set [`config.kit.paths.base`](https://svelte.dev/docs/kit/configuration#paths) and the URL is root-relative, you need to prepend the base path if you want to navigate within the app. * @param {Object} opts Options related to the navigation * */ - export function goto(url: string | URL, opts?: { - replaceState?: boolean | undefined; - noScroll?: boolean | undefined; - keepFocus?: boolean | undefined; - invalidateAll?: boolean | undefined; - invalidate?: (string | URL | ((url: URL) => boolean))[] | undefined; - state?: App.PageState | undefined; - }): Promise; + export function goto( + url: string | URL, + opts?: { + replaceState?: boolean | undefined; + noScroll?: boolean | undefined; + keepFocus?: boolean | undefined; + invalidateAll?: boolean | undefined; + invalidate?: (string | URL | ((url: URL) => boolean))[] | undefined; + state?: App.PageState | undefined; + } + ): Promise; /** * Causes any `load` functions belonging to the currently active page to re-run if they depend on the `url` in question, via `fetch` or `depends`. Returns a `Promise` that resolves when the page is subsequently updated. * @@ -2664,7 +2749,9 @@ declare module '$app/navigation' { * Causes all currently active remote functions to refresh, and all `load` functions belonging to the currently active page to re-run (unless disabled via the option argument). * Returns a `Promise` that resolves when the page is subsequently updated. * */ - export function refreshAll({ includeLoadFunctions }?: { + export function refreshAll({ + includeLoadFunctions + }?: { includeLoadFunctions?: boolean; }): Promise; /** @@ -2678,14 +2765,17 @@ declare module '$app/navigation' { * * @param href Page to preload * */ - export function preloadData(href: string): Promise<{ - type: "loaded"; - status: number; - data: Record; - } | { - type: "redirect"; - location: string; - }>; + export function preloadData(href: string): Promise< + | { + type: 'loaded'; + status: number; + data: Record; + } + | { + type: 'redirect'; + location: string; + } + >; /** * Programmatically imports the code for routes that haven't yet been fetched. * Typically, you might call this to speed up subsequent navigation. @@ -2787,7 +2877,14 @@ declare module '$app/paths' { } declare module '$app/server' { - import type { RequestEvent, RemoteCommand, RemoteForm, RemotePrerenderFunction, RemoteQueryFunction } from '@sveltejs/kit'; + import type { + RequestEvent, + RemoteCommand, + RemoteForm, + RemotePrerenderFunction, + RemoteQueryFunction, + RemoteQueryStreamFunction + } from '@sveltejs/kit'; import type { StandardSchemaV1 } from '@standard-schema/spec'; /** * Read the contents of an imported asset from the filesystem @@ -2825,7 +2922,10 @@ declare module '$app/server' { * * @since 2.27 */ - export function command(validate: "unchecked", fn: (arg: Input) => Output): RemoteCommand; + export function command( + validate: 'unchecked', + fn: (arg: Input) => Output + ): RemoteCommand; /** * Creates a remote command. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -2833,7 +2933,10 @@ declare module '$app/server' { * * @since 2.27 */ - export function command(validate: Schema, fn: (arg: StandardSchemaV1.InferOutput) => Output): RemoteCommand, Output>; + export function command( + validate: Schema, + fn: (arg: StandardSchemaV1.InferOutput) => Output + ): RemoteCommand, Output>; /** * Creates a form object that can be spread onto a `` element. * @@ -2849,10 +2952,15 @@ declare module '$app/server' { * * @since 2.27 */ - export function prerender(fn: () => MaybePromise, options?: { - inputs?: RemotePrerenderInputsGenerator; - dynamic?: boolean; - } | undefined): RemotePrerenderFunction; + export function prerender( + fn: () => MaybePromise, + options?: + | { + inputs?: RemotePrerenderInputsGenerator; + dynamic?: boolean; + } + | undefined + ): RemotePrerenderFunction; /** * Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -2860,10 +2968,16 @@ declare module '$app/server' { * * @since 2.27 */ - export function prerender(validate: "unchecked", fn: (arg: Input) => MaybePromise, options?: { - inputs?: RemotePrerenderInputsGenerator; - dynamic?: boolean; - } | undefined): RemotePrerenderFunction; + export function prerender( + validate: 'unchecked', + fn: (arg: Input) => MaybePromise, + options?: + | { + inputs?: RemotePrerenderInputsGenerator; + dynamic?: boolean; + } + | undefined + ): RemotePrerenderFunction; /** * Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -2871,10 +2985,16 @@ declare module '$app/server' { * * @since 2.27 */ - export function prerender(schema: Schema, fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise, options?: { - inputs?: RemotePrerenderInputsGenerator>; - dynamic?: boolean; - } | undefined): RemotePrerenderFunction, Output>; + export function prerender( + schema: Schema, + fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise, + options?: + | { + inputs?: RemotePrerenderInputsGenerator>; + dynamic?: boolean; + } + | undefined + ): RemotePrerenderFunction, Output>; /** * Creates a remote query. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -2890,7 +3010,10 @@ declare module '$app/server' { * * @since 2.27 */ - export function query(validate: "unchecked", fn: (arg: Input) => MaybePromise): RemoteQueryFunction; + export function query( + validate: 'unchecked', + fn: (arg: Input) => MaybePromise + ): RemoteQueryFunction; /** * Creates a remote query. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -2898,7 +3021,10 @@ declare module '$app/server' { * * @since 2.27 */ - export function query(schema: Schema, fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise): RemoteQueryFunction, Output>; + export function query( + schema: Schema, + fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise + ): RemoteQueryFunction, Output>; export namespace query { /** * Creates a batch query function that collects multiple calls and executes them in a single request @@ -2907,7 +3033,10 @@ declare module '$app/server' { * * @since 2.35 */ - function batch(validate: "unchecked", fn: (args: Input[]) => MaybePromise): RemoteQueryFunction; + function batch( + validate: 'unchecked', + fn: (args: Input[]) => MaybePromise + ): RemoteQueryFunction; /** * Creates a batch query function that collects multiple calls and executes them in a single request * @@ -2915,7 +3044,44 @@ declare module '$app/server' { * * @since 2.35 */ - function batch(schema: Schema, fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise): RemoteQueryFunction, Output>; + function batch( + schema: Schema, + fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise + ): RemoteQueryFunction, Output>; + /** + * Creates a streaming remote query. When called from the browser, the generator function will be invoked on the server and values will be streamed via Server-Sent Events. + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-stream) for full documentation. + * + * @since 2.36 + */ + function stream( + fn: () => Generator | AsyncGenerator + ): RemoteQueryStreamFunction; + /** + * Creates a streaming remote query. When called from the browser, the generator function will be invoked on the server and values will be streamed via Server-Sent Events. + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-stream) for full documentation. + * + * @since 2.36 + */ + function stream( + validate: 'unchecked', + fn: (arg: Input) => Generator | AsyncGenerator + ): RemoteQueryStreamFunction; + /** + * Creates a streaming remote query. When called from the browser, the generator function will be invoked on the server and values will be streamed via Server-Sent Events. + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-stream) for full documentation. + * + * @since 2.36 + */ + function stream( + schema: Schema, + fn: ( + arg: StandardSchemaV1.InferOutput + ) => Generator | AsyncGenerator + ): RemoteQueryStreamFunction, Output>; } type RemotePrerenderInputsGenerator = () => MaybePromise; type MaybePromise = T | Promise; @@ -2960,19 +3126,21 @@ declare module '$app/state' { * On the server, values can only be read during rendering (in other words _not_ in e.g. `load` functions). In the browser, the values can be read at any time. * * */ - export const page: import("@sveltejs/kit").Page; + export const page: import('@sveltejs/kit').Page; /** * A read-only object representing an in-progress navigation, with `from`, `to`, `type` and (if `type === 'popstate'`) `delta` properties. * Values are `null` when no navigation is occurring, or during server rendering. * */ - export const navigating: import("@sveltejs/kit").Navigation | { - from: null; - to: null; - type: null; - willUnload: null; - delta: null; - complete: null; - }; + export const navigating: + | import('@sveltejs/kit').Navigation + | { + from: null; + to: null; + type: null; + willUnload: null; + delta: null; + complete: null; + }; /** * A read-only reactive value that's initially `false`. If [`version.pollInterval`](https://svelte.dev/docs/kit/configuration#version) is a non-zero value, SvelteKit will poll for new versions of the app and update `current` to `true` when it detects one. `updated.check()` will force an immediate check, regardless of polling. * */ @@ -2986,11 +3154,10 @@ declare module '$app/state' { declare module '$app/stores' { export function getStores(): { - page: typeof page; - + navigating: typeof navigating; - + updated: typeof updated; }; /** @@ -3000,7 +3167,7 @@ declare module '$app/stores' { * * @deprecated Use `page` from `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated)) * */ - export const page: import("svelte/store").Readable; + export const page: import('svelte/store').Readable; /** * A readable store. * When navigating starts, its value is a `Navigation` object with `from`, `to`, `type` and (if `type === 'popstate'`) `delta` properties. @@ -3010,7 +3177,9 @@ declare module '$app/stores' { * * @deprecated Use `navigating` from `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated)) * */ - export const navigating: import("svelte/store").Readable; + export const navigating: import('svelte/store').Readable< + import('@sveltejs/kit').Navigation | null + >; /** * A readable store whose initial value is `false`. If [`version.pollInterval`](https://svelte.dev/docs/kit/configuration#version) is a non-zero value, SvelteKit will poll for new versions of the app and update the store value to `true` when it detects one. `updated.check()` will force an immediate check, regardless of polling. * @@ -3018,12 +3187,12 @@ declare module '$app/stores' { * * @deprecated Use `updated` from `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated)) * */ - export const updated: import("svelte/store").Readable & { + export const updated: import('svelte/store').Readable & { check(): Promise; }; export {}; -}/** +} /** * It's possible to tell SvelteKit how to type objects inside your app by declaring the `App` namespace. By default, a new project will have a file called `src/app.d.ts` containing the following: * * ```ts @@ -3159,4 +3328,4 @@ declare module '$app/types' { export type Asset = ReturnType; } -//# sourceMappingURL=index.d.ts.map \ No newline at end of file +//# sourceMappingURL=index.d.ts.map From 41fad93b054689a351979d33d84518b77134231b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 14:21:33 +0200 Subject: [PATCH 05/32] make it treeshakeable on the client --- packages/kit/src/exports/internal/remote-functions.js | 4 ++-- packages/kit/src/runtime/app/server/remote/query.js | 2 +- packages/kit/src/runtime/client/remote-functions/index.js | 2 +- .../kit/src/runtime/client/remote-functions/query.svelte.js | 5 +---- packages/kit/src/runtime/server/remote.js | 2 +- packages/kit/src/types/internal.d.ts | 5 ++++- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/kit/src/exports/internal/remote-functions.js b/packages/kit/src/exports/internal/remote-functions.js index fe85f39b025f..bd0893375046 100644 --- a/packages/kit/src/exports/internal/remote-functions.js +++ b/packages/kit/src/exports/internal/remote-functions.js @@ -10,13 +10,13 @@ export function validate_remote_functions(module, file) { } for (const name in module) { - const type = module[name]?.__?.type; + const type = /** @type {import('types').RemoteInfo['type']} */ (module[name]?.__?.type); if ( type !== 'form' && type !== 'command' && type !== 'query' && - type !== 'query.batch' && + type !== 'query_batch' && type !== 'prerender' ) { throw new Error( diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 55737a862570..47b55e85da17 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -148,7 +148,7 @@ function batch(validate_or_fn, maybe_fn) { const validate = create_validator(validate_or_fn, maybe_fn); /** @type {RemoteInfo} */ - const __ = { type: 'query.batch', id: '', name: '' }; + const __ = { type: 'query_batch', id: '', name: '' }; /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}>, timeoutId: any }} */ let batching = { args: [], resolvers: [], timeoutId: null }; diff --git a/packages/kit/src/runtime/client/remote-functions/index.js b/packages/kit/src/runtime/client/remote-functions/index.js index ec6ad0f6f344..4b20cabddd92 100644 --- a/packages/kit/src/runtime/client/remote-functions/index.js +++ b/packages/kit/src/runtime/client/remote-functions/index.js @@ -1,4 +1,4 @@ export { command } from './command.svelte.js'; export { form } from './form.svelte.js'; export { prerender } from './prerender.svelte.js'; -export { query } from './query.svelte.js'; +export { query, query_batch } from './query.svelte.js'; diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 871e552ba5ab..b89dfe7b0aca 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -32,7 +32,7 @@ export function query(id) { * @param {string} id * @returns {(arg: any) => Query} */ -function batch(id) { +export function query_batch(id) { /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}>, timeoutId: any }} */ let batching = { args: [], resolvers: [], timeoutId: null }; @@ -97,9 +97,6 @@ function batch(id) { }); } -// Add batch as a property to the query function -Object.defineProperty(query, 'batch', { value: batch, enumerable: true }); - /** * @template T * @implements {Partial>} diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 369854d41edd..9180e575afbc 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -60,7 +60,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) let form_client_refreshes; try { - if (info.type === 'query.batch' && event.request.method === 'POST') { + if (info.type === 'query_batch' && event.request.method === 'POST') { /** @type {{ payloads: string[] }} */ const { payloads } = await event.request.json(); diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 6a4720bdbeb8..0b3c726715df 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -548,7 +548,10 @@ export type ValidatedKitConfig = Omit, 'adapter'> & export type RemoteInfo = | { - type: 'query' | 'query.batch' | 'command'; + /** + * Corresponds to the name of the client-side exports (that's why we use underscores and not dots) + */ + type: 'query' | 'query_batch' | 'command'; id: string; name: string; } From 4819f97a7b7a710df600a1935ece9185161ee0e7 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 14:27:26 +0200 Subject: [PATCH 06/32] hydrate data --- .../src/runtime/client/remote-functions/query.svelte.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index b89dfe7b0aca..98b982629b73 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -38,6 +38,13 @@ export function query_batch(id) { return create_remote_function(id, (cache_key, payload) => { return new Query(cache_key, () => { + if (!started) { + const result = remote_responses[cache_key]; + if (result) { + return result; + } + } + // Collect all the calls to the same query in the same macrotask, // then execute them as one backend request. return new Promise((resolve, reject) => { From a955c16a934b5b51b2ebb6a6c44f85722cb787e5 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 14:30:30 +0200 Subject: [PATCH 07/32] validation --- packages/kit/src/runtime/server/remote.js | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 9180e575afbc..c184d094fb62 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -60,7 +60,15 @@ async function handle_remote_call_internal(event, state, options, manifest, id) let form_client_refreshes; try { - if (info.type === 'query_batch' && event.request.method === 'POST') { + if (info.type === 'query_batch') { + if (event.request.method !== 'POST') { + throw new SvelteKitError( + 405, + 'Method Not Allowed', + `\`query.batch\` functions must be invoked via POST request, not ${event.request.method}` + ); + } + /** @type {{ payloads: string[] }} */ const { payloads } = await event.request.json(); @@ -78,11 +86,19 @@ async function handle_remote_call_internal(event, state, options, manifest, id) } if (info.type === 'form') { + if (event.request.method !== 'POST') { + throw new SvelteKitError( + 405, + 'Method Not Allowed', + `\`form\` functions must be invoked via POST request, not ${event.request.method}` + ); + } + if (!is_form_content_type(event.request)) { throw new SvelteKitError( 415, 'Unsupported Media Type', - `Form actions expect form-encoded data — received ${event.request.headers.get( + `\`form\` functions expect form-encoded data — received ${event.request.headers.get( 'content-type' )}` ); From 1b5f45399bc6e3782f5b64160430c3a442b307fe Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 14:33:21 +0200 Subject: [PATCH 08/32] lint --- packages/kit/src/runtime/server/remote.js | 2 +- .../test/apps/basics/src/routes/remote/validation/+page.svelte | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index c184d094fb62..5ed241b0ac58 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -69,7 +69,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) ); } - /** @type {{ payloads: string[] }} */ + /** @type {{ payloads: string[] }} */ const { payloads } = await event.request.json(); const args = payloads.map((payload) => parse_remote_arg(payload, transport)); diff --git a/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte index f911e3b9aef4..ae8e0ed1c6a3 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte @@ -105,6 +105,7 @@ } try { + // @ts-expect-error await validated_batch_query_with_validation(123); status = 'error'; } catch (e) { From 5da377dd71cd60778f99091b87b9aaa8231be836 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 14:37:01 +0200 Subject: [PATCH 09/32] explain + simplify (no use in clearing a timeout for the next macrotask) --- .../kit/src/runtime/app/server/remote/query.js | 12 ++++-------- .../client/remote-functions/query.svelte.js | 16 +++++++--------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 47b55e85da17..93e958cec35d 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -150,8 +150,8 @@ function batch(validate_or_fn, maybe_fn) { /** @type {RemoteInfo} */ const __ = { type: 'query_batch', id: '', name: '' }; - /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}>, timeoutId: any }} */ - let batching = { args: [], resolvers: [], timeoutId: null }; + /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}> }} */ + let batching = { args: [], resolvers: [] }; /** @type {RemoteQueryFunction & { __: RemoteInfo }} */ const wrapper = (arg) => { @@ -171,13 +171,9 @@ function batch(validate_or_fn, maybe_fn) { batching.args.push(arg); batching.resolvers.push({ resolve, reject }); - if (batching.timeoutId) { - clearTimeout(batching.timeoutId); - } - - batching.timeoutId = setTimeout(async () => { + setTimeout(async () => { const batched = batching; - batching = { args: [], resolvers: [], timeoutId: null }; + batching = { args: [], resolvers: [] }; try { const results = await run_remote_function( diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 98b982629b73..2f088a0b0226 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -33,8 +33,8 @@ export function query(id) { * @returns {(arg: any) => Query} */ export function query_batch(id) { - /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}>, timeoutId: any }} */ - let batching = { args: [], resolvers: [], timeoutId: null }; + /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}> }} */ + let batching = { args: [], resolvers: [] }; return create_remote_function(id, (cache_key, payload) => { return new Query(cache_key, () => { @@ -51,13 +51,11 @@ export function query_batch(id) { batching.args.push(payload); batching.resolvers.push({ resolve, reject }); - if (batching.timeoutId) { - clearTimeout(batching.timeoutId); - } - - batching.timeoutId = setTimeout(async () => { + // 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 = { args: [], resolvers: [], timeoutId: null }; + batching = { args: [], resolvers: [] }; try { const response = await fetch(`${base}/${app_dir}/remote/${id}`, { @@ -98,7 +96,7 @@ export function query_batch(id) { resolver.reject(error); } } - }, 0); // Wait one macrotask + }, 0); }); }); }); From e036a279037d3eda6d031dee9f708011e339d31f Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 14:52:09 +0200 Subject: [PATCH 10/32] fast-path for remote client calls --- .../src/runtime/app/server/remote/query.js | 22 +++++++++++++++++-- .../client/remote-functions/query.svelte.js | 2 ++ packages/kit/src/runtime/server/remote.js | 4 +--- packages/kit/src/types/internal.d.ts | 9 +++++++- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 93e958cec35d..8c9d34275fec 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -147,8 +147,24 @@ function batch(validate_or_fn, maybe_fn) { /** @type {(arg?: any) => MaybePromise} */ const validate = create_validator(validate_or_fn, maybe_fn); - /** @type {RemoteInfo} */ - const __ = { type: 'query_batch', id: '', name: '' }; + /** @type {RemoteInfo & { type: 'query_batch' }} */ + const __ = { + type: 'query_batch', + id: '', + name: '', + run: (args) => { + const { event, state } = get_request_store(); + + return run_remote_function( + event, + state, + false, + args, + (array) => Promise.all(array.map(validate)), + fn + ); + } + }; /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}> }} */ let batching = { args: [], resolvers: [] }; @@ -171,6 +187,8 @@ function batch(validate_or_fn, maybe_fn) { batching.args.push(arg); batching.resolvers.push({ resolve, reject }); + if (batching.args.length > 1) return; + setTimeout(async () => { const batched = batching; batching = { args: [], resolvers: [] }; diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 2f088a0b0226..62b1e1bd5aa1 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -51,6 +51,8 @@ export function query_batch(id) { batching.args.push(payload); batching.resolvers.push({ resolve, reject }); + if (batching.args.length > 1) return; + // 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 () => { diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 5ed241b0ac58..cb602679de19 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -73,9 +73,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) const { payloads } = await event.request.json(); const args = payloads.map((payload) => parse_remote_arg(payload, transport)); - const results = await with_request_store({ event, state }, () => - Promise.all(args.map((arg) => fn(arg))) - ); + const results = await with_request_store({ event, state }, () => info.run(args)); return json( /** @type {RemoteFunctionResponse} */ ({ diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 0b3c726715df..a27406513980 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -547,13 +547,20 @@ export type ValidatedKitConfig = Omit, 'adapter'> & }; export type RemoteInfo = + | { + type: 'query' | 'command'; + id: string; + name: string; + } | { /** * Corresponds to the name of the client-side exports (that's why we use underscores and not dots) */ - type: 'query' | 'query_batch' | 'command'; + type: 'query_batch'; id: string; name: string; + /** Direct access to the function without batching etc logic, for remote functions called from the client */ + run: (args: any[]) => Promise; } | { type: 'form'; From afdae9700f018f5a0b744280dfcaa042e8763930 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 15:19:00 +0200 Subject: [PATCH 11/32] validate --- .../src/runtime/app/server/remote/query.js | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 8c9d34275fec..7bdd145e5a4d 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -147,15 +147,33 @@ function batch(validate_or_fn, maybe_fn) { /** @type {(arg?: any) => MaybePromise} */ const validate = create_validator(validate_or_fn, maybe_fn); + /** + * @param {any[]} input + * @param {any} output + */ + function validate_output(input, output) { + if (!Array.isArray(output)) { + throw new Error( + `Batch query '${__.name}' returned a result of type ${typeof output}. It must return an array of the same length as the input array` + ); + } + + if (input.length !== output.length) { + throw new Error( + `Batch query '${__.name}' was called with ${input.length} arguments, but returned ${output.length} results. Make sure to return an array of the same length as the input array` + ); + } + } + /** @type {RemoteInfo & { type: 'query_batch' }} */ const __ = { type: 'query_batch', id: '', name: '', - run: (args) => { + run: async (args) => { const { event, state } = get_request_store(); - return run_remote_function( + const results = await run_remote_function( event, state, false, @@ -163,6 +181,10 @@ function batch(validate_or_fn, maybe_fn) { (array) => Promise.all(array.map(validate)), fn ); + + validate_output(args, results); + + return results; } }; @@ -202,6 +224,9 @@ function batch(validate_or_fn, maybe_fn) { (array) => Promise.all(array.map(validate)), fn ); + + validate_output(batched.args, results); + for (let i = 0; i < batched.resolvers.length; i++) { batched.resolvers[i].resolve(results[i]); } From 6434cf37244a852c8ab7a0d899d09c63ecd3b285 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 15:21:05 +0200 Subject: [PATCH 12/32] note in docs about output shape --- documentation/docs/20-core-concepts/60-remote-functions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 49319511579f..a97860152125 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -176,6 +176,8 @@ Any query can be updated via its `refresh` method: `query.batch` works like `query` except that it batches requests that happen within the same macrotask. This solves the so-called n+1 problem where you have many calls to the same resource and it's more efficient to do one invocation with an array of arguments of the collected calls instead of doing one invocation per call. +Individual calls to the same `query.batch` function are put into an array, and the remote function is invoked with the collected calls that happened within one macrotask. You must return an array of the same length, with each output entry being at the same array position as its corresponding input. SvelteKit will then split this array back up and resolve the individual calls with their results. + ```js /// file: src/routes/blog/data.remote.js // @filename: ambient.d.ts From 3b54a4c7ad8aacebe475dd19732eb83feb92989c Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 15:37:03 +0200 Subject: [PATCH 13/32] make it treeshakeable --- packages/kit/src/exports/internal/remote-functions.js | 2 +- packages/kit/src/runtime/app/server/remote/query.js | 2 +- packages/kit/src/runtime/client/remote-functions/index.js | 2 +- .../kit/src/runtime/client/remote-functions/query.svelte.js | 4 +--- packages/kit/src/runtime/server/remote.js | 2 +- packages/kit/src/types/internal.d.ts | 5 ++++- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/kit/src/exports/internal/remote-functions.js b/packages/kit/src/exports/internal/remote-functions.js index 37dbc4caea83..31ee2da0e2d5 100644 --- a/packages/kit/src/exports/internal/remote-functions.js +++ b/packages/kit/src/exports/internal/remote-functions.js @@ -17,7 +17,7 @@ export function validate_remote_functions(module, file) { type !== 'command' && type !== 'query' && type !== 'query_batch' && - type !== 'query.stream' && + type !== 'query_stream' && type !== 'prerender' ) { throw new Error( diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index c89dca48d437..02118e75defe 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -321,7 +321,7 @@ function stream(validate_or_fn, maybe_fn) { const validate = create_validator(validate_or_fn, maybe_fn); /** @type {RemoteInfo} */ - const __ = { type: 'query.stream', id: '', name: '' }; + const __ = { type: 'query_stream', id: '', name: '' }; /** @type {RemoteQueryStreamFunction & { __: RemoteInfo }} */ const wrapper = (/** @type {Input} */ arg) => { diff --git a/packages/kit/src/runtime/client/remote-functions/index.js b/packages/kit/src/runtime/client/remote-functions/index.js index 4b20cabddd92..2e23fb4b0e1a 100644 --- a/packages/kit/src/runtime/client/remote-functions/index.js +++ b/packages/kit/src/runtime/client/remote-functions/index.js @@ -1,4 +1,4 @@ export { command } from './command.svelte.js'; export { form } from './form.svelte.js'; export { prerender } from './prerender.svelte.js'; -export { query, query_batch } from './query.svelte.js'; +export { query, query_batch, query_stream } from './query.svelte.js'; diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 87042458da0b..5bcdca500fd6 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -108,7 +108,7 @@ export function query_batch(id) { * @param {string} id * @returns {RemoteQueryStreamFunction} */ -function stream(id) { +export function query_stream(id) { return create_remote_function(id, (_, payload) => { const url = `${base}/${app_dir}/remote/${id}${payload ? `?payload=${payload}` : ''}`; return new QueryStream(url); @@ -313,8 +313,6 @@ class QueryStream { } } -Object.defineProperty(query, 'stream', { value: stream, enumerable: true }); - /** * @template T * @implements {Partial>} diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 12bf30040c69..eb23e6c47f1c 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -135,7 +135,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) ); } - if (info.type === 'query.stream') { + if (info.type === 'query_stream') { const payload = /** @type {string} */ ( new URL(event.request.url).searchParams.get('payload') ); diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 9aad668b1c99..a6dc091c6d3e 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -548,7 +548,10 @@ export type ValidatedKitConfig = Omit, 'adapter'> & export type RemoteInfo = | { - type: 'query' | 'query.stream' | 'command'; + /** + * Corresponds to the name of the client-side exports (that's why we use underscores and not dots) + */ + type: 'query' | 'query_stream' | 'command'; id: string; name: string; } From 96ea81cb592b6e25a74ad50d10bf3f17485743a1 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 15:39:51 +0200 Subject: [PATCH 14/32] oops --- .../src/routes/remote/stream/stream.remote.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/kit/test/apps/basics/src/routes/remote/stream/stream.remote.js b/packages/kit/test/apps/basics/src/routes/remote/stream/stream.remote.js index dc37661ac76d..5ff4d262febc 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/stream/stream.remote.js +++ b/packages/kit/test/apps/basics/src/routes/remote/stream/stream.remote.js @@ -1,12 +1,23 @@ import { command, query } from '$app/server'; +// TODO 3.0 remove this once we support a high enough version of Node.js +function withResolvers() { + /** @type {any} */ + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + let i = 0; -let p = Promise.withResolvers(); +let p = withResolvers(); export const next = command(() => { i++; p.resolve(); - p = Promise.withResolvers(); + p = withResolvers(); }); export const time = query.stream(async function* () { From 9656a7f7616540e0b7622927c96c3aba843d35fe Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 15:45:55 +0200 Subject: [PATCH 15/32] regenerate types --- packages/kit/types/index.d.ts | 309 ++++++++++------------------------ 1 file changed, 87 insertions(+), 222 deletions(-) diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index d4b39be365f7..3a66ffbfee0f 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -4,11 +4,7 @@ declare module '@sveltejs/kit' { import type { SvelteConfig } from '@sveltejs/vite-plugin-svelte'; import type { StandardSchemaV1 } from '@standard-schema/spec'; - import type { - RouteId as AppRouteId, - LayoutParams as AppLayoutParams, - ResolvedPathname - } from '$app/types'; + import type { RouteId as AppRouteId, LayoutParams as AppLayoutParams, ResolvedPathname } from '$app/types'; import type { Span } from '@opentelemetry/api'; /** * [Adapters](https://svelte.dev/docs/kit/adapters) are responsible for taking the production build and turning it into something that can be deployed to a platform of your choosing. @@ -273,10 +269,7 @@ declare module '@sveltejs/kit' { * @param name the name of the cookie * @param opts the options, passed directly to `cookie.serialize`. The `path` must match the path of the cookie you want to delete. See documentation [here](https://github.com/jshttp/cookie#cookieserializename-value-options) */ - delete: ( - name: string, - opts: import('cookie').CookieSerializeOptions & { path: string } - ) => void; + delete: (name: string, opts: import('cookie').CookieSerializeOptions & { path: string }) => void; /** * Serialize a cookie name-value pair into a `Set-Cookie` header string, but don't apply it to the response. @@ -1426,10 +1419,7 @@ declare module '@sveltejs/kit' { * but they will always be split at sensible boundaries such as `%sveltekit.head%` or layout/page components. * @param input the html chunk and the info if this is the last chunk */ - transformPageChunk?: (input: { - html: string; - done: boolean; - }) => MaybePromise; + transformPageChunk?: (input: { html: string; done: boolean }) => MaybePromise; /** * Determines which headers should be included in serialized responses when a `load` function loads a resource with `fetch`. * By default, none will be included. @@ -1811,20 +1801,7 @@ declare module '@sveltejs/kit' { /** * The return value of a remote `query.stream` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-stream) for full documentation. */ - export type RemoteQueryStream = RemoteResource & - AsyncIterable> & { - /** - * On the client, this function will re-fetch the query from the server. - * - * On the server, this can be called in the context of a `command` or `form` and the refreshed data will accompany the action response back to the client. - * This prevents SvelteKit needing to refresh all queries on the page in a second server round-trip. - */ - refresh(): Promise; - /** - * Temporarily override the value of a query. This is used with the `updates` method of a [command](https://svelte.dev/docs/kit/remote-functions#command-Updating-queries) or [enhanced form submission](https://svelte.dev/docs/kit/remote-functions#form-enhance) to provide optimistic updates. - */ - withOverride(update: (current: Awaited) => Awaited): RemoteQueryOverride; - }; + export type RemoteQueryStream = RemoteResource & AsyncIterable>; /** * The return value of a remote `query.stream` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-stream) for full documentation. @@ -1862,9 +1839,7 @@ declare module '@sveltejs/kit' { * A function that is invoked once the entry has been created. This is where you * should write the function to the filesystem and generate redirect manifests. */ - complete(entry: { - generateManifest(opts: { relativePath: string }): string; - }): MaybePromise; + complete(entry: { generateManifest(opts: { relativePath: string }): string }): MaybePromise; } // Based on https://github.com/josh-hemphill/csp-typed-directives/blob/latest/src/csp.types.ts @@ -2329,24 +2304,16 @@ declare module '@sveltejs/kit' { * @throws {HttpError} This error instructs SvelteKit to initiate HTTP error handling. * @throws {Error} If the provided status is invalid (not between 400 and 599). */ - export function error( - status: number, - body?: { - message: string; - } extends App.Error - ? App.Error | string | undefined - : never - ): never; + export function error(status: number, body?: { + message: string; + } extends App.Error ? App.Error | string | undefined : never): never; /** * Checks whether this is an error thrown by {@link error}. * @param status The status to filter for. * */ - export function isHttpError( - e: unknown, - status?: T - ): e is HttpError_1 & { + export function isHttpError(e: unknown, status?: T): e is (HttpError_1 & { status: T extends undefined ? never : T; - }; + }); /** * Redirect a request. When called during request handling, SvelteKit will return a redirect response. * Make sure you're not catching the thrown redirect, which would prevent SvelteKit from handling it. @@ -2363,10 +2330,7 @@ declare module '@sveltejs/kit' { * @throws {Redirect} This error instructs SvelteKit to redirect to the specified location. * @throws {Error} If the provided status is invalid. * */ - export function redirect( - status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | ({} & number), - location: string | URL - ): never; + export function redirect(status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | ({} & number), location: string | URL): never; /** * Checks whether this is a redirect thrown by {@link redirect}. * @param e The object to check. @@ -2418,31 +2382,20 @@ declare module '@sveltejs/kit' { wasNormalized: boolean; denormalize: (url?: string | URL) => URL; }; - export type LessThan< - TNumber extends number, - TArray extends any[] = [] - > = TNumber extends TArray['length'] - ? TArray[number] - : LessThan; - export type NumericRange = Exclude< - TEnd | LessThan, - LessThan - >; + export type LessThan = TNumber extends TArray["length"] ? TArray[number] : LessThan; + export type NumericRange = Exclude, LessThan>; export const VERSION: string; class HttpError_1 { - constructor( - status: number, - body: { - message: string; - } extends App.Error - ? App.Error | string | undefined - : App.Error - ); + + constructor(status: number, body: { + message: string; + } extends App.Error ? (App.Error | string | undefined) : App.Error); status: number; body: App.Error; toString(): string; } class Redirect_1 { + constructor(status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308, location: string); status: 301 | 302 | 303 | 307 | 308 | 300 | 304 | 305 | 306; location: string; @@ -2529,20 +2482,13 @@ declare module '@sveltejs/kit/hooks' { } declare module '@sveltejs/kit/node' { - export function getRequest({ - request, - base, - bodySizeLimit - }: { - request: import('http').IncomingMessage; + export function getRequest({ request, base, bodySizeLimit }: { + request: import("http").IncomingMessage; base: string; bodySizeLimit?: number; }): Promise; - export function setResponse( - res: import('http').ServerResponse, - response: Response - ): Promise; + export function setResponse(res: import("http").ServerResponse, response: Response): Promise; /** * Converts a file on disk to a readable stream * @since 2.4.0 @@ -2567,7 +2513,7 @@ declare module '@sveltejs/kit/vite' { /** * Returns the SvelteKit Vite plugins. * */ - export function sveltekit(): Promise; + export function sveltekit(): Promise; export {}; } @@ -2615,10 +2561,7 @@ declare module '$app/forms' { * } * ``` * */ - export function deserialize< - Success extends Record | undefined, - Failure extends Record | undefined - >(result: string): import('@sveltejs/kit').ActionResult; + export function deserialize | undefined, Failure extends Record | undefined>(result: string): import("@sveltejs/kit").ActionResult; /** * This action enhances a `` element that otherwise would work without JavaScript. * @@ -2642,23 +2585,14 @@ declare module '$app/forms' { * @param form_element The form element * @param submit Submit callback */ - export function enhance< - Success extends Record | undefined, - Failure extends Record | undefined - >( - form_element: HTMLFormElement, - submit?: import('@sveltejs/kit').SubmitFunction - ): { + export function enhance | undefined, Failure extends Record | undefined>(form_element: HTMLFormElement, submit?: import("@sveltejs/kit").SubmitFunction): { destroy(): void; }; /** * This action updates the `form` property of the current page with the given data and updates `page.status`. * In case of an error, it redirects to the nearest error page. * */ - export function applyAction< - Success extends Record | undefined, - Failure extends Record | undefined - >(result: import('@sveltejs/kit').ActionResult): Promise; + export function applyAction | undefined, Failure extends Record | undefined>(result: import("@sveltejs/kit").ActionResult): Promise; export {}; } @@ -2669,9 +2603,7 @@ declare module '$app/navigation' { * * `afterNavigate` must be called during a component initialization. It remains active as long as the component is mounted. * */ - export function afterNavigate( - callback: (navigation: import('@sveltejs/kit').AfterNavigate) => void - ): void; + export function afterNavigate(callback: (navigation: import("@sveltejs/kit").AfterNavigate) => void): void; /** * A navigation interceptor that triggers before we navigate to a URL, whether by clicking a link, calling `goto(...)`, or using the browser back/forward controls. * @@ -2683,9 +2615,7 @@ declare module '$app/navigation' { * * `beforeNavigate` must be called during a component initialization. It remains active as long as the component is mounted. * */ - export function beforeNavigate( - callback: (navigation: import('@sveltejs/kit').BeforeNavigate) => void - ): void; + export function beforeNavigate(callback: (navigation: import("@sveltejs/kit").BeforeNavigate) => void): void; /** * A lifecycle function that runs the supplied `callback` immediately before we navigate to a new URL except during full-page navigations. * @@ -2695,9 +2625,7 @@ declare module '$app/navigation' { * * `onNavigate` must be called during a component initialization. It remains active as long as the component is mounted. * */ - export function onNavigate( - callback: (navigation: import('@sveltejs/kit').OnNavigate) => MaybePromise<(() => void) | void> - ): void; + export function onNavigate(callback: (navigation: import("@sveltejs/kit").OnNavigate) => MaybePromise<(() => void) | void>): void; /** * If called when the page is being updated following a navigation (in `onMount` or `afterNavigate` or an action, for example), this disables SvelteKit's built-in scroll handling. * This is generally discouraged, since it breaks user expectations. @@ -2712,17 +2640,14 @@ declare module '$app/navigation' { * @param url Where to navigate to. Note that if you've set [`config.kit.paths.base`](https://svelte.dev/docs/kit/configuration#paths) and the URL is root-relative, you need to prepend the base path if you want to navigate within the app. * @param {Object} opts Options related to the navigation * */ - export function goto( - url: string | URL, - opts?: { - replaceState?: boolean | undefined; - noScroll?: boolean | undefined; - keepFocus?: boolean | undefined; - invalidateAll?: boolean | undefined; - invalidate?: (string | URL | ((url: URL) => boolean))[] | undefined; - state?: App.PageState | undefined; - } - ): Promise; + export function goto(url: string | URL, opts?: { + replaceState?: boolean | undefined; + noScroll?: boolean | undefined; + keepFocus?: boolean | undefined; + invalidateAll?: boolean | undefined; + invalidate?: (string | URL | ((url: URL) => boolean))[] | undefined; + state?: App.PageState | undefined; + }): Promise; /** * Causes any `load` functions belonging to the currently active page to re-run if they depend on the `url` in question, via `fetch` or `depends`. Returns a `Promise` that resolves when the page is subsequently updated. * @@ -2749,9 +2674,7 @@ declare module '$app/navigation' { * Causes all currently active remote functions to refresh, and all `load` functions belonging to the currently active page to re-run (unless disabled via the option argument). * Returns a `Promise` that resolves when the page is subsequently updated. * */ - export function refreshAll({ - includeLoadFunctions - }?: { + export function refreshAll({ includeLoadFunctions }?: { includeLoadFunctions?: boolean; }): Promise; /** @@ -2765,17 +2688,14 @@ declare module '$app/navigation' { * * @param href Page to preload * */ - export function preloadData(href: string): Promise< - | { - type: 'loaded'; - status: number; - data: Record; - } - | { - type: 'redirect'; - location: string; - } - >; + export function preloadData(href: string): Promise<{ + type: "loaded"; + status: number; + data: Record; + } | { + type: "redirect"; + location: string; + }>; /** * Programmatically imports the code for routes that haven't yet been fetched. * Typically, you might call this to speed up subsequent navigation. @@ -2877,14 +2797,7 @@ declare module '$app/paths' { } declare module '$app/server' { - import type { - RequestEvent, - RemoteCommand, - RemoteForm, - RemotePrerenderFunction, - RemoteQueryFunction, - RemoteQueryStreamFunction - } from '@sveltejs/kit'; + import type { RequestEvent, RemoteCommand, RemoteForm, RemotePrerenderFunction, RemoteQueryFunction, RemoteQueryStreamFunction } from '@sveltejs/kit'; import type { StandardSchemaV1 } from '@standard-schema/spec'; /** * Read the contents of an imported asset from the filesystem @@ -2922,10 +2835,7 @@ declare module '$app/server' { * * @since 2.27 */ - export function command( - validate: 'unchecked', - fn: (arg: Input) => Output - ): RemoteCommand; + export function command(validate: "unchecked", fn: (arg: Input) => Output): RemoteCommand; /** * Creates a remote command. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -2933,10 +2843,7 @@ declare module '$app/server' { * * @since 2.27 */ - export function command( - validate: Schema, - fn: (arg: StandardSchemaV1.InferOutput) => Output - ): RemoteCommand, Output>; + export function command(validate: Schema, fn: (arg: StandardSchemaV1.InferOutput) => Output): RemoteCommand, Output>; /** * Creates a form object that can be spread onto a `` element. * @@ -2952,15 +2859,10 @@ declare module '$app/server' { * * @since 2.27 */ - export function prerender( - fn: () => MaybePromise, - options?: - | { - inputs?: RemotePrerenderInputsGenerator; - dynamic?: boolean; - } - | undefined - ): RemotePrerenderFunction; + export function prerender(fn: () => MaybePromise, options?: { + inputs?: RemotePrerenderInputsGenerator; + dynamic?: boolean; + } | undefined): RemotePrerenderFunction; /** * Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -2968,16 +2870,10 @@ declare module '$app/server' { * * @since 2.27 */ - export function prerender( - validate: 'unchecked', - fn: (arg: Input) => MaybePromise, - options?: - | { - inputs?: RemotePrerenderInputsGenerator; - dynamic?: boolean; - } - | undefined - ): RemotePrerenderFunction; + export function prerender(validate: "unchecked", fn: (arg: Input) => MaybePromise, options?: { + inputs?: RemotePrerenderInputsGenerator; + dynamic?: boolean; + } | undefined): RemotePrerenderFunction; /** * Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -2985,16 +2881,10 @@ declare module '$app/server' { * * @since 2.27 */ - export function prerender( - schema: Schema, - fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise, - options?: - | { - inputs?: RemotePrerenderInputsGenerator>; - dynamic?: boolean; - } - | undefined - ): RemotePrerenderFunction, Output>; + export function prerender(schema: Schema, fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise, options?: { + inputs?: RemotePrerenderInputsGenerator>; + dynamic?: boolean; + } | undefined): RemotePrerenderFunction, Output>; /** * Creates a remote query. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -3010,10 +2900,7 @@ declare module '$app/server' { * * @since 2.27 */ - export function query( - validate: 'unchecked', - fn: (arg: Input) => MaybePromise - ): RemoteQueryFunction; + export function query(validate: "unchecked", fn: (arg: Input) => MaybePromise): RemoteQueryFunction; /** * Creates a remote query. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -3021,10 +2908,7 @@ declare module '$app/server' { * * @since 2.27 */ - export function query( - schema: Schema, - fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise - ): RemoteQueryFunction, Output>; + export function query(schema: Schema, fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise): RemoteQueryFunction, Output>; export namespace query { /** * Creates a batch query function that collects multiple calls and executes them in a single request @@ -3033,10 +2917,7 @@ declare module '$app/server' { * * @since 2.35 */ - function batch( - validate: 'unchecked', - fn: (args: Input[]) => MaybePromise - ): RemoteQueryFunction; + function batch(validate: "unchecked", fn: (args: Input[]) => MaybePromise): RemoteQueryFunction; /** * Creates a batch query function that collects multiple calls and executes them in a single request * @@ -3044,44 +2925,31 @@ declare module '$app/server' { * * @since 2.35 */ - function batch( - schema: Schema, - fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise - ): RemoteQueryFunction, Output>; + function batch(schema: Schema, fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise): RemoteQueryFunction, Output>; /** * Creates a streaming remote query. When called from the browser, the generator function will be invoked on the server and values will be streamed via Server-Sent Events. * - * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-stream) for full documentation. + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.stream) for full documentation. * * @since 2.36 */ - function stream( - fn: () => Generator | AsyncGenerator - ): RemoteQueryStreamFunction; + function stream(fn: () => Generator | AsyncGenerator): RemoteQueryStreamFunction; /** * Creates a streaming remote query. When called from the browser, the generator function will be invoked on the server and values will be streamed via Server-Sent Events. * - * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-stream) for full documentation. + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.stream) for full documentation. * * @since 2.36 */ - function stream( - validate: 'unchecked', - fn: (arg: Input) => Generator | AsyncGenerator - ): RemoteQueryStreamFunction; + function stream(validate: "unchecked", fn: (arg: Input) => Generator | AsyncGenerator): RemoteQueryStreamFunction; /** * Creates a streaming remote query. When called from the browser, the generator function will be invoked on the server and values will be streamed via Server-Sent Events. * - * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-stream) for full documentation. + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.stream) for full documentation. * * @since 2.36 */ - function stream( - schema: Schema, - fn: ( - arg: StandardSchemaV1.InferOutput - ) => Generator | AsyncGenerator - ): RemoteQueryStreamFunction, Output>; + function stream(schema: Schema, fn: (arg: StandardSchemaV1.InferOutput) => Generator | AsyncGenerator): RemoteQueryStreamFunction, Output>; } type RemotePrerenderInputsGenerator = () => MaybePromise; type MaybePromise = T | Promise; @@ -3126,21 +2994,19 @@ declare module '$app/state' { * On the server, values can only be read during rendering (in other words _not_ in e.g. `load` functions). In the browser, the values can be read at any time. * * */ - export const page: import('@sveltejs/kit').Page; + export const page: import("@sveltejs/kit").Page; /** * A read-only object representing an in-progress navigation, with `from`, `to`, `type` and (if `type === 'popstate'`) `delta` properties. * Values are `null` when no navigation is occurring, or during server rendering. * */ - export const navigating: - | import('@sveltejs/kit').Navigation - | { - from: null; - to: null; - type: null; - willUnload: null; - delta: null; - complete: null; - }; + export const navigating: import("@sveltejs/kit").Navigation | { + from: null; + to: null; + type: null; + willUnload: null; + delta: null; + complete: null; + }; /** * A read-only reactive value that's initially `false`. If [`version.pollInterval`](https://svelte.dev/docs/kit/configuration#version) is a non-zero value, SvelteKit will poll for new versions of the app and update `current` to `true` when it detects one. `updated.check()` will force an immediate check, regardless of polling. * */ @@ -3154,10 +3020,11 @@ declare module '$app/state' { declare module '$app/stores' { export function getStores(): { + page: typeof page; - + navigating: typeof navigating; - + updated: typeof updated; }; /** @@ -3167,7 +3034,7 @@ declare module '$app/stores' { * * @deprecated Use `page` from `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated)) * */ - export const page: import('svelte/store').Readable; + export const page: import("svelte/store").Readable; /** * A readable store. * When navigating starts, its value is a `Navigation` object with `from`, `to`, `type` and (if `type === 'popstate'`) `delta` properties. @@ -3177,9 +3044,7 @@ declare module '$app/stores' { * * @deprecated Use `navigating` from `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated)) * */ - export const navigating: import('svelte/store').Readable< - import('@sveltejs/kit').Navigation | null - >; + export const navigating: import("svelte/store").Readable; /** * A readable store whose initial value is `false`. If [`version.pollInterval`](https://svelte.dev/docs/kit/configuration#version) is a non-zero value, SvelteKit will poll for new versions of the app and update the store value to `true` when it detects one. `updated.check()` will force an immediate check, regardless of polling. * @@ -3187,12 +3052,12 @@ declare module '$app/stores' { * * @deprecated Use `updated` from `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated)) * */ - export const updated: import('svelte/store').Readable & { + export const updated: import("svelte/store").Readable & { check(): Promise; }; export {}; -} /** +}/** * It's possible to tell SvelteKit how to type objects inside your app by declaring the `App` namespace. By default, a new project will have a file called `src/app.d.ts` containing the following: * * ```ts @@ -3328,4 +3193,4 @@ declare module '$app/types' { export type Asset = ReturnType; } -//# sourceMappingURL=index.d.ts.map +//# sourceMappingURL=index.d.ts.map \ No newline at end of file From 6665ed6eed19aa129049d65ed1a98d488ea10d97 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 22:40:35 +0200 Subject: [PATCH 16/32] thanks for nothing service workers --- documentation/docs/20-core-concepts/60-remote-functions.md | 2 ++ packages/kit/test/apps/basics/src/service-worker.js | 4 ++++ packages/kit/test/apps/options-2/src/service-worker.js | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index d350504b52c6..fbba7304a56e 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -281,6 +281,8 @@ Apart from that you can iterate over it like any other async iterable, including Stream requests to the same resource with the same payload are deduplicated, i.e. you cannot start the same stream multiple times in parallel and it to start from the beginning each time. +> [!NOTE] Be careful when using `query.stream` in combination with service workers. Specifically, make sure to never pass the promise of a `ReadableStream` (which `query.stream` uses) to `event.respondWith(...)`, as the promise never settles. + ## form The `form` function makes it easy to write data to the server. It takes a callback that receives the current [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)... diff --git a/packages/kit/test/apps/basics/src/service-worker.js b/packages/kit/test/apps/basics/src/service-worker.js index fc3dbc27600f..e3a2aedd2b4b 100644 --- a/packages/kit/test/apps/basics/src/service-worker.js +++ b/packages/kit/test/apps/basics/src/service-worker.js @@ -25,6 +25,10 @@ self.addEventListener('fetch', (event) => { if (request.method !== 'GET' || request.headers.has('range')) return; + // Skip EventSource requests to prevent connection issues + const acceptHeader = request.headers.get('accept'); + if (acceptHeader && acceptHeader.includes('text/event-stream')) return; + const url = new URL(request.url); const cached = caches.match(request); diff --git a/packages/kit/test/apps/options-2/src/service-worker.js b/packages/kit/test/apps/options-2/src/service-worker.js index 5d2346ffc333..4c99909412a2 100644 --- a/packages/kit/test/apps/options-2/src/service-worker.js +++ b/packages/kit/test/apps/options-2/src/service-worker.js @@ -32,6 +32,10 @@ self.addEventListener('fetch', (event) => { if (request.method !== 'GET' || request.headers.has('range')) return; + // Skip EventSource requests to prevent connection issues + const acceptHeader = request.headers.get('accept'); + if (acceptHeader && acceptHeader.includes('text/event-stream')) return; + const url = new URL(request.url); const cached = caches.match(request); From 28a3564a60a1e9016d5bf6418203f4de24bf18b0 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 22:40:46 +0200 Subject: [PATCH 17/32] tweak readablestream implementation --- .../client/remote-functions/query.svelte.js | 4 +- packages/kit/src/runtime/server/remote.js | 39 +++++++++++-------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 5bcdca500fd6..2450087b33bd 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -216,8 +216,8 @@ class QueryStream { return; } - this.#current = parsed; - this.#resolve({ done: false, value: parsed }); + this.#current = parsed.value; + this.#resolve({ done: false, value: parsed.value }); this.#create_promise(); }; diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index eb23e6c47f1c..e4b5bbf8bc2b 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -144,29 +144,32 @@ async function handle_remote_call_internal(event, state, options, manifest, id) fn(parse_remote_arg(payload, transport)) ); - // Return a Server-Sent Events stream + // Return a Server-Sent Events stream using the pull method to consume the async iterator let cancelled = false; + const encoder = new TextEncoder(); + const iterator = generator[Symbol.asyncIterator](); return new Response( new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder(); - + async pull(controller) { try { - for await (const value of generator) { - if (cancelled) break; - const serialized = stringify(value, transport); - const chunk = `data: ${serialized}\n\n`; - controller.enqueue(encoder.encode(chunk)); + const { value, done } = await iterator.next(); + if (cancelled) return; + + if (done) { + // Send end marker + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + return; } - - // Send end marker - controller.enqueue(encoder.encode('data: [DONE]\n\n')); - controller.close(); + const serialized = stringify({ type: 'data', value }, transport); + const chunk = `data: ${serialized}\n\n`; + controller.enqueue(encoder.encode(chunk)); } catch (error) { - console.error(error); - // Send error and close const errorData = await handle_error_and_jsonify(event, state, options, error); + + if (cancelled) return; + const serialized = stringify( { type: 'error', @@ -186,13 +189,15 @@ async function handle_remote_call_internal(event, state, options, manifest, id) cancel() { cancelled = true; + if (iterator.return) { + iterator.return(); + } } }), { headers: { 'content-type': 'text/event-stream', - 'cache-control': 'private, no-store', - connection: 'keep-alive' + 'cache-control': 'private, no-store' } } ); From 531225cd0ad28b31e720e826938cda40b99c37ac Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 23:13:39 +0200 Subject: [PATCH 18/32] fix --- .../src/runtime/client/remote-functions/query.svelte.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 2450087b33bd..b210fadf4af7 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -186,6 +186,7 @@ class QueryStream { this.#resolve = resolve; this.#reject = reject; }); + this.#promise.catch(() => {}); // don't let unhandled rejections bubble up } #next() { @@ -244,10 +245,13 @@ class QueryStream { if (!this.#ready) { return this.#promise.then((v) => v.value).then(resolve, reject); } else { + // We return/reject right away instead of waiting on the promise, + // else we would end up in a constant pending state since the next + // promise is created right after the previous one is resolved. if (this.#error) { - return reject(this.#error); + return Promise.reject(this.#error).then(undefined, reject); } else { - return resolve(this.#current); + return Promise.resolve(this.#current).then(resolve); } } }; @@ -288,6 +292,7 @@ class QueryStream { this.#unused = true; if (this.#count === 0) { this.#source?.close(); + this.#reject?.(); } } From e630de7220202fc3fb6a099c26900ddcef23d227 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 23 Aug 2025 13:53:09 +0200 Subject: [PATCH 19/32] adjust API --- .../20-core-concepts/60-remote-functions.md | 43 ++++++++++++------- .../src/runtime/app/server/remote/query.js | 40 ++++------------- packages/kit/src/runtime/server/remote.js | 3 +- packages/kit/src/types/internal.d.ts | 2 +- .../src/routes/remote/batch/batch.remote.js | 3 +- packages/kit/types/index.d.ts | 4 +- 6 files changed, 42 insertions(+), 53 deletions(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index a97860152125..73b1e966a7b6 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -174,12 +174,12 @@ Any query can be updated via its `refresh` method: ## query.batch -`query.batch` works like `query` except that it batches requests that happen within the same macrotask. This solves the so-called n+1 problem where you have many calls to the same resource and it's more efficient to do one invocation with an array of arguments of the collected calls instead of doing one invocation per call. +`query.batch` works like `query` except that it batches requests that happen within the same macrotask. This solves the so-called n+1 problem: rather than each query resulting in a separate database call (for example), simultaneous queries are grouped together. -Individual calls to the same `query.batch` function are put into an array, and the remote function is invoked with the collected calls that happened within one macrotask. You must return an array of the same length, with each output entry being at the same array position as its corresponding input. SvelteKit will then split this array back up and resolve the individual calls with their results. +On the server, the callback receives an array of the arguments the function was called with. It must return a function of the form `(input: Input, index: number) => Output`. SvelteKit will then call this with each of the input arguments to resolve the individual calls with their results. ```js -/// file: src/routes/blog/data.remote.js +/// file: weather.remote.js // @filename: ambient.d.ts declare module '$lib/server/database' { export function sql(strings: TemplateStringsArray, ...values: any[]): Promise; @@ -190,29 +190,40 @@ import * as v from 'valibot'; import { query } from '$app/server'; import * as db from '$lib/server/database'; -export const getPost = query.batch(v.string(), async (slugs) => { - const posts = await db.sql` - SELECT * FROM post - WHERE slug = ANY(${slugs}) +export const getWeather = query.batch(v.string(), async (cities) => { + const weather = await db.sql` + SELECT * FROM weather + WHERE city = ANY(${cities}) `; - return posts; + const weatherMap = new Map(weather.map(w => [w.city, w])); + + return (city) => weatherMap.get(city); }); ``` ```svelte + -

All my posts

+

Weather

-
    - {#each posts as post} -
  • {await getPost(post.id).summary}
  • - {/each} -
+{#each cities.slice(0, limit) as city} +

{city.name}

+ +{/each} + + ``` ## form diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 7bdd145e5a4d..d9d77ca36dde 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -114,7 +114,7 @@ export function query(validate_or_fn, maybe_fn) { * @template Output * @overload * @param {'unchecked'} validate - * @param {(args: Input[]) => MaybePromise} fn + * @param {(args: Input[]) => MaybePromise<(arg: Input, idx: number) => Output>} fn * @returns {RemoteQueryFunction} * @since 2.35 */ @@ -127,7 +127,7 @@ export function query(validate_or_fn, maybe_fn) { * @template Output * @overload * @param {Schema} schema - * @param {(args: StandardSchemaV1.InferOutput[]) => MaybePromise} fn + * @param {(args: StandardSchemaV1.InferOutput[]) => MaybePromise<(arg: StandardSchemaV1.InferOutput, idx: number) => Output>} fn * @returns {RemoteQueryFunction, Output>} * @since 2.35 */ @@ -135,45 +135,27 @@ export function query(validate_or_fn, maybe_fn) { * @template Input * @template Output * @param {any} validate_or_fn - * @param {(args?: Input[]) => MaybePromise} [maybe_fn] + * @param {(args?: Input[]) => MaybePromise<(arg: Input, idx: number) => Output>} [maybe_fn] * @returns {RemoteQueryFunction} * @since 2.35 */ /*@__NO_SIDE_EFFECTS__*/ function batch(validate_or_fn, maybe_fn) { - /** @type {(args?: Input[]) => Output[]} */ + /** @type {(args?: Input[]) => (arg: Input, idx: number) => Output} */ const fn = maybe_fn ?? validate_or_fn; /** @type {(arg?: any) => MaybePromise} */ const validate = create_validator(validate_or_fn, maybe_fn); - /** - * @param {any[]} input - * @param {any} output - */ - function validate_output(input, output) { - if (!Array.isArray(output)) { - throw new Error( - `Batch query '${__.name}' returned a result of type ${typeof output}. It must return an array of the same length as the input array` - ); - } - - if (input.length !== output.length) { - throw new Error( - `Batch query '${__.name}' was called with ${input.length} arguments, but returned ${output.length} results. Make sure to return an array of the same length as the input array` - ); - } - } - /** @type {RemoteInfo & { type: 'query_batch' }} */ const __ = { type: 'query_batch', id: '', name: '', - run: async (args) => { + run: (args) => { const { event, state } = get_request_store(); - const results = await run_remote_function( + return run_remote_function( event, state, false, @@ -181,10 +163,6 @@ function batch(validate_or_fn, maybe_fn) { (array) => Promise.all(array.map(validate)), fn ); - - validate_output(args, results); - - return results; } }; @@ -216,7 +194,7 @@ function batch(validate_or_fn, maybe_fn) { batching = { args: [], resolvers: [] }; try { - const results = await run_remote_function( + const get_result = await run_remote_function( event, state, false, @@ -225,10 +203,8 @@ function batch(validate_or_fn, maybe_fn) { fn ); - validate_output(batched.args, results); - for (let i = 0; i < batched.resolvers.length; i++) { - batched.resolvers[i].resolve(results[i]); + batched.resolvers[i].resolve(get_result(batched.args[i], i)); } } catch (error) { for (const resolver of batched.resolvers) { diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index cb602679de19..4146d71a3ff7 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -73,7 +73,8 @@ async function handle_remote_call_internal(event, state, options, manifest, id) const { payloads } = await event.request.json(); const args = payloads.map((payload) => parse_remote_arg(payload, transport)); - const results = await with_request_store({ event, state }, () => info.run(args)); + const get_result = await with_request_store({ event, state }, () => info.run(args)); + const results = args.map((arg, i) => get_result(arg, i)); return json( /** @type {RemoteFunctionResponse} */ ({ diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index a27406513980..d770abda5290 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -560,7 +560,7 @@ export type RemoteInfo = id: string; name: string; /** Direct access to the function without batching etc logic, for remote functions called from the client */ - run: (args: any[]) => Promise; + run: (args: any[]) => Promise<(arg: any, idx: number) => any>; } | { type: 'form'; diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js index 484880f13d46..ec5c90d245bd 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js @@ -8,5 +8,6 @@ const mock_data = [ ]; export const get_todo = query.batch('unchecked', (ids) => { - return ids.map((id) => mock_data.find((todo) => todo.id === id)); + const results = ids.map((id) => mock_data.find((todo) => todo.id === id)); + return (id) => results.find((todo) => todo?.id === id); }); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 1c322e087beb..5552121c449b 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2907,7 +2907,7 @@ declare module '$app/server' { * * @since 2.35 */ - function batch(validate: "unchecked", fn: (args: Input[]) => MaybePromise): RemoteQueryFunction; + function batch(validate: "unchecked", fn: (args: Input[]) => MaybePromise<(arg: Input, idx: number) => Output>): RemoteQueryFunction; /** * Creates a batch query function that collects multiple calls and executes them in a single request * @@ -2915,7 +2915,7 @@ declare module '$app/server' { * * @since 2.35 */ - function batch(schema: Schema, fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise): RemoteQueryFunction, Output>; + function batch(schema: Schema, fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise<(arg: StandardSchemaV1.InferOutput, idx: number) => Output>): RemoteQueryFunction, Output>; } type RemotePrerenderInputsGenerator = () => MaybePromise; type MaybePromise = T | Promise; From cbfa1cf8a95434a7f7cfc08b9ad009164252a1af Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 23 Aug 2025 14:08:43 +0200 Subject: [PATCH 20/32] deduplicate --- .../src/runtime/app/server/remote/query.js | 1 + .../client/remote-functions/query.svelte.js | 33 ++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index d9d77ca36dde..4ab86e1d9205 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -184,6 +184,7 @@ function batch(validate_or_fn, maybe_fn) { // Collect all the calls to the same query in the same macrotask, // then execute them as one backend request. return new Promise((resolve, reject) => { + // We don't need to deduplicate args here, because get_response already caches/reuses identical calls batching.args.push(arg); batching.resolvers.push({ resolve, reject }); diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 62b1e1bd5aa1..64b501e54312 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -33,8 +33,8 @@ export function query(id) { * @returns {(arg: any) => Query} */ export function query_batch(id) { - /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}> }} */ - let batching = { args: [], resolvers: [] }; + /** @type {Map void, reject: (error: any) => void}> }>} */ + let batching = new Map(); return create_remote_function(id, (cache_key, payload) => { return new Query(cache_key, () => { @@ -48,22 +48,25 @@ export function query_batch(id) { // Collect all the calls to the same query in the same macrotask, // then execute them as one backend request. return new Promise((resolve, reject) => { - batching.args.push(payload); - batching.resolvers.push({ 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.args.length > 1) return; + if (batching.size > 1) return; // 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 = { args: [], resolvers: [] }; + batching = new Map(); try { const response = await fetch(`${base}/${app_dir}/remote/${id}`, { method: 'POST', body: JSON.stringify({ - payloads: batched.args + payloads: Array.from(batched.keys()) }), headers: { 'Content-Type': 'application/json' @@ -89,13 +92,21 @@ export function query_batch(id) { const results = devalue.parse(result.result, app.decoders); // Resolve individual queries - for (let i = 0; i < batched.resolvers.length; i++) { - batched.resolvers[i].resolve(results[i]); + // Maps guarantee insertion order so we can do it like this + let i = 0; + + for (const resolvers of batched.values()) { + for (const { resolve } of resolvers) { + resolve(results[i]); + } + i++; } } catch (error) { // Reject all queries in the batch - for (const resolver of batched.resolvers) { - resolver.reject(error); + for (const resolver of batched.values()) { + for (const { reject } of resolver) { + reject(error); + } } } }, 0); From aa7cd56e6b97dd0a7db1297b7163e0570f2d0f8e Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 23 Aug 2025 14:11:12 +0200 Subject: [PATCH 21/32] test deduplication --- .../kit/test/apps/basics/src/routes/remote/batch/+page.svelte | 2 +- .../test/apps/basics/src/routes/remote/batch/batch.remote.js | 4 ++++ packages/kit/test/apps/basics/test/client.test.js | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte index 54650e293a3d..16cf84886dfe 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte @@ -1,7 +1,7 @@

Query Batch Test

diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js index ec5c90d245bd..0103bc7dd8aa 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js @@ -8,6 +8,10 @@ const mock_data = [ ]; export const get_todo = query.batch('unchecked', (ids) => { + if (ids.length !== 2) { + throw new Error(`Expected 2 IDs (deduplicated), got ${JSON.stringify(ids)}`); + } + const results = ids.map((id) => mock_data.find((todo) => todo.id === id)); return (id) => results.find((todo) => todo?.id === id); }); diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index c06dd870050f..a7a8e319c656 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1924,6 +1924,7 @@ test.describe('remote functions', () => { await expect(page.locator('#batch-result-1')).toHaveText('Buy groceries'); await expect(page.locator('#batch-result-2')).toHaveText('Walk the dog'); + await expect(page.locator('#batch-result-3')).toHaveText('Buy groceries'); let request_count = 0; page.on('request', (r) => (request_count += r.url().includes('/_app/remote') ? 1 : 0)); From 910125c48f30e5183636d1e7c32dae577b110b6d Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 23 Aug 2025 14:21:46 +0200 Subject: [PATCH 22/32] fix --- .../runtime/client/remote-functions/query.svelte.js | 2 +- .../src/routes/remote/validation/validation.remote.js | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 64b501e54312..f58cd2861072 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -33,7 +33,7 @@ export function query(id) { * @returns {(arg: any) => Query} */ export function query_batch(id) { - /** @type {Map void, reject: (error: any) => void}> }>} */ + /** @type {Map void, reject: (error: any) => void}>>} */ let batching = new Map(); return create_remote_function(id, (cache_key, payload) => { diff --git a/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js b/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js index 7a2e734a05be..c4050a7bc471 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js +++ b/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js @@ -36,10 +36,12 @@ export const validated_command_with_arg = command(schema, (...arg) => typeof arg[0] === 'string' && arg.length === 1 ? 'success' : 'failure' ); -export const validated_batch_query_no_validation = query.batch('unchecked', (items) => - items.map((item) => (item === 'valid' ? 'success' : 'failure')) +export const validated_batch_query_no_validation = query.batch( + 'unchecked', + (_) => (item) => (item === 'valid' ? 'success' : 'failure') ); -export const validated_batch_query_with_validation = query.batch(schema, (items) => - items.map((item) => (typeof item === 'string' ? 'success' : 'failure')) +export const validated_batch_query_with_validation = query.batch( + schema, + (_) => (item) => (typeof item === 'string' ? 'success' : 'failure') ); From 2ca6eb1157ebec1d4b30419d6feeb328bfec8892 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 23 Aug 2025 15:38:11 +0200 Subject: [PATCH 23/32] oops --- .../kit/test/apps/basics/src/routes/remote/batch/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte index 16cf84886dfe..2fca0537918b 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte @@ -7,12 +7,12 @@

Query Batch Test

    - {#each todoIds as id} + {#each todoIds as id, idx}
  • {#await get_todo(id)} Loading todo {id}... {:then todo} - {todo.title} + {todo.title} {:catch error} Error loading todo {id}: {error.message} {/await} From dfa4cb45516c06f83753d49241c4443aa10bb731 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 23 Aug 2025 15:48:13 +0200 Subject: [PATCH 24/32] omg lol --- .../kit/test/apps/basics/src/routes/remote/batch/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte index 2fca0537918b..676a1bb3cb41 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte @@ -12,7 +12,7 @@ {#await get_todo(id)} Loading todo {id}... {:then todo} - {todo.title} + {todo.title} {:catch error} Error loading todo {id}: {error.message} {/await} From 44f60b811d0fb71f85fd4aac279d0ae6a3829018 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Sat, 23 Aug 2025 18:59:17 +0200 Subject: [PATCH 25/32] Update documentation/docs/20-core-concepts/60-remote-functions.md Co-authored-by: Rich Harris --- .../docs/20-core-concepts/60-remote-functions.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 73b1e966a7b6..0204441ec44c 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -218,12 +218,11 @@ export const getWeather = query.batch(v.string(), async (cities) => { {/each} - +{#if cities.length > limit} + +{/if} ``` ## form From d8d02bc4e797b9f3697c89f5fec7d86c58efa2f8 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 23 Aug 2025 23:12:45 +0200 Subject: [PATCH 26/32] per-item error handling --- .../src/runtime/app/server/remote/query.js | 6 +++++- .../client/remote-functions/query.svelte.js | 8 ++++++-- packages/kit/src/runtime/server/remote.js | 15 +++++++++++++- .../basics/src/routes/remote/batch/+page.js | 2 ++ .../src/routes/remote/batch/+page.svelte | 6 +++--- .../src/routes/remote/batch/batch.remote.js | 20 +++++++++---------- .../kit/test/apps/basics/test/client.test.js | 1 + 7 files changed, 40 insertions(+), 18 deletions(-) create mode 100644 packages/kit/test/apps/basics/src/routes/remote/batch/+page.js diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 4ab86e1d9205..bd9fb0e4c6e5 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -205,7 +205,11 @@ function batch(validate_or_fn, maybe_fn) { ); for (let i = 0; i < batched.resolvers.length; i++) { - batched.resolvers[i].resolve(get_result(batched.args[i], i)); + try { + batched.resolvers[i].resolve(get_result(batched.args[i], i)); + } catch (error) { + batched.resolvers[i].reject(error); + } } } catch (error) { for (const resolver of batched.resolvers) { diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index f58cd2861072..b7be26ef39eb 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -96,8 +96,12 @@ export function query_batch(id) { let i = 0; for (const resolvers of batched.values()) { - for (const { resolve } of resolvers) { - resolve(results[i]); + 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); + } } i++; } diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 4146d71a3ff7..7f11bd97ca27 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -74,7 +74,20 @@ async function handle_remote_call_internal(event, state, options, manifest, id) const args = payloads.map((payload) => parse_remote_arg(payload, transport)); const get_result = await with_request_store({ event, state }, () => info.run(args)); - const results = args.map((arg, i) => get_result(arg, i)); + const results = await Promise.all( + args.map(async (arg, i) => { + try { + return { type: 'result', data: get_result(arg, i) }; + } catch (error) { + return { + type: 'error', + error: await handle_error_and_jsonify(event, state, options, error), + status: + error instanceof HttpError || error instanceof SvelteKitError ? error.status : 500 + }; + } + }) + ); return json( /** @type {RemoteFunctionResponse} */ ({ diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.js b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.js new file mode 100644 index 000000000000..d87494778d07 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.js @@ -0,0 +1,2 @@ +// TODO remove once we have async SSR +export const ssr = false; diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte index 676a1bb3cb41..dbe085847835 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte @@ -1,7 +1,7 @@

    Query Batch Test

    @@ -10,11 +10,11 @@ {#each todoIds as id, idx}
  • {#await get_todo(id)} - Loading todo {id}... + Loading todo {id}... {:then todo} {todo.title} {:catch error} - Error loading todo {id}: {error.message} + Error loading todo {id}: {error.body.message} {/await}
  • {/each} diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js index 0103bc7dd8aa..917729e1c412 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js @@ -1,17 +1,15 @@ import { query } from '$app/server'; - -const mock_data = [ - { id: '1', title: 'Buy groceries' }, - { id: '2', title: 'Walk the dog' }, - { id: '3', title: 'Write code' }, - { id: '4', title: 'Read book' } -]; +import { error } from '@sveltejs/kit'; export const get_todo = query.batch('unchecked', (ids) => { - if (ids.length !== 2) { - throw new Error(`Expected 2 IDs (deduplicated), got ${JSON.stringify(ids)}`); + if (JSON.stringify(ids) !== JSON.stringify(['1', '2', 'error'])) { + throw new Error(`Expected 3 IDs (deduplicated), got ${JSON.stringify(ids)}`); } - const results = ids.map((id) => mock_data.find((todo) => todo.id === id)); - return (id) => results.find((todo) => todo?.id === id); + return (id) => + id === '1' + ? { id: '1', title: 'Buy groceries' } + : id === '2' + ? { id: '2', title: 'Walk the dog' } + : error(404, { message: 'Not found' }); }); diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index a7a8e319c656..e385ac175fd5 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1925,6 +1925,7 @@ test.describe('remote functions', () => { await expect(page.locator('#batch-result-1')).toHaveText('Buy groceries'); await expect(page.locator('#batch-result-2')).toHaveText('Walk the dog'); await expect(page.locator('#batch-result-3')).toHaveText('Buy groceries'); + await expect(page.locator('#batch-result-4')).toHaveText('Error loading todo error: Not found'); let request_count = 0; page.on('request', (r) => (request_count += r.url().includes('/_app/remote') ? 1 : 0)); From a649cf0095c0a77482dca0b4e3de6a271b5cfcbd Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 25 Aug 2025 21:36:07 +0200 Subject: [PATCH 27/32] don't share stream() --- .../20-core-concepts/60-remote-functions.md | 18 +++++++++++++++++- .../client/remote-functions/query.svelte.js | 14 ++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 6a968be35075..853314cab47b 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -289,7 +289,23 @@ Apart from that you can iterate over it like any other async iterable, including {/each} ``` -Stream requests to the same resource with the same payload are deduplicated, i.e. you cannot start the same stream multiple times in parallel and it to start from the beginning each time. +Unlike other `query` methods, stream requests to the same resource with the same payload are _not_ deduplicated. That means you can start the same stream multiple times in parallel and it will start from the beginning each time. + +```svelte + + + + +{#await stream()} +{#await stream()} + + +{await oneToTen()} +``` > [!NOTE] Be careful when using `query.stream` in combination with service workers. Specifically, make sure to never pass the promise of a `ReadableStream` (which `query.stream` uses) to `event.respondWith(...)`, as the promise never settles. diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 51e5efd947af..d168ac44707b 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -6,6 +6,7 @@ import { tick } from 'svelte'; import { create_remote_function, remote_request } from './shared.svelte.js'; import * as devalue from 'devalue'; import { HttpError, Redirect } from '@sveltejs/kit/internal'; +import { stringify_remote_arg } from '../../shared.js'; /** * @param {string} id @@ -124,10 +125,11 @@ export function query_batch(id) { * @returns {RemoteQueryStreamFunction} */ export function query_stream(id) { - return create_remote_function(id, (_, payload) => { - const url = `${base}/${app_dir}/remote/${id}${payload ? `?payload=${payload}` : ''}`; + // @ts-expect-error [Symbol.toStringTag] missing + return (payload) => { + const url = `${base}/${app_dir}/remote/${id}${payload ? `?payload=${stringify_remote_arg(payload, app.hooks.transport)}` : ''}`; return new QueryStream(url); - }); + }; } /** @@ -247,7 +249,7 @@ class QueryStream { this.#source.addEventListener('error', onError); } - get then() { + #then = $derived.by(() => { this.#current; /** @@ -270,6 +272,10 @@ class QueryStream { } } }; + }); + + get then() { + return this.#then; } get catch() { From ac02bc1a0c35557772237d1375e5effce8c5a7c4 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Sat, 30 Aug 2025 20:29:06 +0200 Subject: [PATCH 28/32] Update documentation/docs/20-core-concepts/60-remote-functions.md Co-authored-by: Rich Harris --- documentation/docs/20-core-concepts/60-remote-functions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 853314cab47b..2a7d996429f3 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -300,8 +300,8 @@ Unlike other `query` methods, stream requests to the same resource with the same -{#await stream()} -{#await stream()} +{#await stream} +{#await stream} {await oneToTen()} From cead45e603bceb8511c8caf8c867a988d9c9e44c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 10 Sep 2025 09:30:51 -0400 Subject: [PATCH 29/32] remove duplicated docs --- .../20-core-concepts/60-remote-functions.md | 53 ------------------- 1 file changed, 53 deletions(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index b22c2bdb66cb..db37e75ce296 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -225,59 +225,6 @@ export const getWeather = query.batch(v.string(), async (cities) => { {/if} ``` -## query.batch - -`query.batch` works like `query` except that it batches requests that happen within the same macrotask. This solves the so-called n+1 problem: rather than each query resulting in a separate database call (for example), simultaneous queries are grouped together. - -On the server, the callback receives an array of the arguments the function was called with. It must return a function of the form `(input: Input, index: number) => Output`. SvelteKit will then call this with each of the input arguments to resolve the individual calls with their results. - -```js -/// file: weather.remote.js -// @filename: ambient.d.ts -declare module '$lib/server/database' { - export function sql(strings: TemplateStringsArray, ...values: any[]): Promise; -} -// @filename: index.js -// ---cut--- -import * as v from 'valibot'; -import { query } from '$app/server'; -import * as db from '$lib/server/database'; - -export const getWeather = query.batch(v.string(), async (cities) => { - const weather = await db.sql` - SELECT * FROM weather - WHERE city = ANY(${cities}) - `; - const weatherMap = new Map(weather.map(w => [w.city, w])); - - return (city) => weatherMap.get(city); -}); -``` - -```svelte - - - -

    Weather

    - -{#each cities.slice(0, limit) as city} -

    {city.name}

    - -{/each} - -{#if cities.length > limit} - -{/if} -``` - ## query.stream `query.stream` allows you to stream continuous data from the server to the client. From 5e7a5c71fed9afd03cec15c36d5c73849556b455 Mon Sep 17 00:00:00 2001 From: Ben McCann <322311+benmccann@users.noreply.github.com> Date: Sat, 4 Oct 2025 10:35:26 -0700 Subject: [PATCH 30/32] Update packages/kit/src/runtime/app/server/remote/query.js --- packages/kit/src/runtime/app/server/remote/query.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index d2cb1247457f..0e1f2b3dbb15 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -270,7 +270,7 @@ function batch(validate_or_fn, maybe_fn) { } /** - * Creates a streaming remote query. When called from the browser, the generator function will be invoked on the server and values will be streamed via Server-Sent Events. + * Creates a streaming remote query. When called from the browser, the generator function will be invoked on the server and values will be streamed via Server-Sent Events (SSE). * * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.stream) for full documentation. * From e234707f5b572453a8fb707584c5c543cf8bd21e Mon Sep 17 00:00:00 2001 From: Ben McCann <322311+benmccann@users.noreply.github.com> Date: Sat, 4 Oct 2025 10:35:37 -0700 Subject: [PATCH 31/32] format --- packages/kit/test/apps/basics/test/client.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index c67051d6571a..1f5fb190552c 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -2004,7 +2004,7 @@ test.describe('remote functions', () => { expect(request_count).toBe(1); }); - // TODO ditto + // TODO ditto test('query works with transport', async ({ page }) => { await page.goto('/remote/transport'); From a1de6dcb2d4bd99d0957146e197d11a742f7f41a Mon Sep 17 00:00:00 2001 From: Ben McCann <322311+benmccann@users.noreply.github.com> Date: Sat, 4 Oct 2025 10:36:50 -0700 Subject: [PATCH 32/32] generate types --- packages/kit/types/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 25c15c703d41..d4a850cceea2 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -3163,7 +3163,7 @@ declare module '$app/server' { */ function batch(schema: Schema, fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise<(arg: StandardSchemaV1.InferOutput, idx: number) => Output>): RemoteQueryFunction, Output>; /** - * Creates a streaming remote query. When called from the browser, the generator function will be invoked on the server and values will be streamed via Server-Sent Events. + * Creates a streaming remote query. When called from the browser, the generator function will be invoked on the server and values will be streamed via Server-Sent Events (SSE). * * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.stream) for full documentation. *