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 4d7e637b1d2b..09b05afe6fd0 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -225,6 +225,90 @@ export const getWeather = query.batch(v.string(), async (cities) => { {/if} ``` +## 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} +``` + +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. + ## form The `form` function makes it easy to write data to the server. It takes a callback that receives `data` constructed from the submitted [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)... diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index f06e771a400d..fc65882354cb 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -2047,6 +2047,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 22eed9a10b01..0e1f2b3dbb15 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'; @@ -269,5 +269,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 (SSE). + * + * 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/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 1f4d5b7db537..4fe72bf9b154 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -1,12 +1,13 @@ -/** @import { RemoteQueryFunction } from '@sveltejs/kit' */ +/** @import { RemoteQueryFunction, RemoteQueryStreamFunction } from '@sveltejs/kit' */ /** @import { RemoteFunctionResponse } from 'types' */ import { app_dir, base } from '$app/paths/internal/client'; import { app, goto, query_map, remote_responses } from '../client.js'; -import { tick } from 'svelte'; +import { stringify_remote_arg } from '../../shared.js'; import { create_remote_function, remote_request } from './shared.svelte.js'; -import * as devalue from 'devalue'; import { HttpError, Redirect } from '@sveltejs/kit/internal'; +import * as devalue from 'devalue'; import { DEV } from 'esm-env'; +import { tick } from 'svelte'; /** * @param {string} id @@ -122,6 +123,225 @@ export function query_batch(id) { }); } +/** + * @param {string} id + * @returns {RemoteQueryStreamFunction} + */ +export function query_stream(id) { + // @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); + }; +} + +/** + * 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; + }); + this.#promise.catch(() => {}); // don't let unhandled rejections bubble up + } + + #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.value; + this.#resolve({ done: false, value: parsed.value }); + 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); + } + + #then = $derived.by(() => { + 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 { + // 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 Promise.reject(this.#error).then(undefined, reject); + } else { + return Promise.resolve(this.#current).then(resolve); + } + } + }; + }); + + get then() { + return this.#then; + } + + 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(); + this.#reject?.(); + } + } + + [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 }); + } + }; + } +} + /** * @template T * @implements {Partial>} 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 08d195c5f3d8..c7c70a6c96ce 100644 --- a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js @@ -53,6 +53,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); } }); @@ -87,6 +88,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); } }); @@ -94,6 +96,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 9e3dab46f16a..8b26145229bd 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -150,6 +150,74 @@ 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 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 pull(controller) { + try { + 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; + } + const serialized = stringify({ type: 'data', value }, transport); + const chunk = `data: ${serialized}\n\n`; + controller.enqueue(encoder.encode(chunk)); + } catch (error) { + const errorData = await handle_error_and_jsonify(event, state, options, error); + + if (cancelled) return; + + 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; + if (iterator.return) { + iterator.return(); + } + } + }), + { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'private, no-store' + } + } + ); + } + 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 ed0ceed4cf4b..0a3009b1bcb4 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -551,7 +551,10 @@ export type ValidatedKitConfig = Omit, 'adapter'> & export type RemoteInfo = | { - type: 'query' | '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; } 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..5ff4d262febc --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/stream/stream.remote.js @@ -0,0 +1,28 @@ +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 = withResolvers(); + +export const next = command(() => { + i++; + p.resolve(); + p = withResolvers(); +}); + +export const time = query.stream(async function* () { + while (i < 2) { + yield i; + await p.promise; + } +}); 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/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index fec98e4a214e..1f5fb190552c 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -2010,4 +2010,33 @@ test.describe('remote functions', () => { await expect(page.locator('h1')).toHaveText('hello from remote function!'); }); + + 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/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); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 7a5a4afc2449..d4a850cceea2 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2023,6 +2023,16 @@ 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>; + + /** + * 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. */ @@ -3007,7 +3017,7 @@ declare module '$app/paths' { } declare module '$app/server' { - import type { RequestEvent, RemoteCommand, RemoteForm, RemoteFormInput, RemotePrerenderFunction, RemoteQueryFunction } from '@sveltejs/kit'; + import type { RequestEvent, RemoteCommand, RemoteForm, RemoteFormInput, RemotePrerenderFunction, RemoteQueryFunction, RemoteQueryStreamFunction } from '@sveltejs/kit'; import type { StandardSchemaV1 } from '@standard-schema/spec'; /** * Read the contents of an imported asset from the filesystem @@ -3152,6 +3162,30 @@ declare module '$app/server' { * @since 2.35 */ 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 (SSE). + * + * 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;