@sveltejs/kit
Version:
SvelteKit is the fastest way to build Svelte apps
637 lines (533 loc) • 15.4 kB
JavaScript
/** @import { RemoteLiveQuery, RemoteLiveQueryFunction } from '@sveltejs/kit' */
/** @import { PromiseWithResolvers } from '../../../utils/promise.js' */
import { app_dir, base } from '$app/paths/internal/client';
import { app, live_query_map } from '../client.js';
import {
get_remote_request_headers,
handle_side_channel_response,
is_in_effect,
QUERY_FUNCTION_ID,
QUERY_RESOURCE_KEY
} from './shared.svelte.js';
import * as devalue from 'devalue';
import { HttpError, Redirect } from '@sveltejs/kit/internal';
import { DEV } from 'esm-env';
import { noop, once } from '../../../utils/functions.js';
import { with_resolvers } from '../../../utils/promise.js';
import { tick } from 'svelte';
import { create_remote_key, stringify_remote_arg, unfriendly_hydratable } from '../../shared.js';
import { read_ndjson } from '../ndjson.js';
/**
* @template T
* @typedef {{
* count: number;
* resource: LiveQuery<T>;
* cleanup: () => void;
* }} RemoteLiveQueryCacheEntry
*/
/**
* @param {string} id
* @returns {RemoteLiveQueryFunction<any, any>}
*/
export function query_live(id) {
if (DEV) {
// If this reruns as part of HMR, refresh the query
const entries = live_query_map.get(id);
if (entries) {
for (const entry of entries.values()) {
void entry.resource.reconnect();
}
}
}
/** @type {RemoteLiveQueryFunction<any, any>} */
const wrapper = (arg) => /** @type {RemoteLiveQuery<any>} */ (new LiveQueryProxy(id, arg));
Object.defineProperty(wrapper, QUERY_FUNCTION_ID, { value: id });
return wrapper;
}
/**
* @param {Response} response
* @returns {Promise<ReadableStreamDefaultReader<Uint8Array>>}
*/
async function get_stream_reader(response) {
const content_type = response.headers.get('content-type') ?? '';
if (response.ok && content_type.includes('application/json')) {
// we can end up here if we e.g. redirect in `handle`
const result = await response.json();
await handle_side_channel_response(result);
throw new HttpError(500, 'Invalid query.live response');
}
if (!response.ok) {
const result = await response.json().catch(() => ({
type: 'error',
status: response.status,
error: response.statusText
}));
throw new HttpError(result.status ?? response.status ?? 500, result.error);
}
if (!response.body) {
throw new Error('Expected query.live response body to be a ReadableStream');
}
return response.body.getReader();
}
/**
* Yields deserialized results from a ReadableStream of newline-delimited JSON
* @param {ReadableStreamDefaultReader<Uint8Array>} reader
*/
async function* read_live_ndjson(reader) {
for await (const node of read_ndjson(reader)) {
if (node.type === 'result') {
yield devalue.parse(node.result, app.decoders);
continue;
}
await handle_side_channel_response(node);
throw new HttpError(500, 'Invalid query.live response');
}
}
/**
* @template T
* @param {string} id
* @param {string} payload
* @param {AbortController} [controller]
* @param {() => void} [on_connect]
* @returns {AsyncGenerator<T>}
*/
export async function* create_live_iterator(
id,
payload,
controller = new AbortController(),
on_connect = noop
) {
const url = `${base}/${app_dir}/remote/${id}${payload ? `?payload=${payload}` : ''}`;
/** @type {ReadableStreamDefaultReader<Uint8Array> | null} */
let reader = null;
try {
const response = await fetch(url, {
headers: get_remote_request_headers(),
signal: controller.signal
});
reader = await get_stream_reader(response);
on_connect();
yield* read_live_ndjson(reader);
} finally {
try {
await reader?.cancel();
} catch {
// already closed
}
}
}
/**
* @template T
* @implements {Promise<T>}
*/
export class LiveQuery {
#id;
#payload;
#loading = $state(true);
#ready = $state(false);
/** Is there a current connection to the server? */
#connected = $state(false);
/**
* Have we been told by the server that we have completed iteration and are done?
* When this is `true`, the only way to start live-updating again is to call `.reconnect()`.
*/
#done = $state(false);
/** @type {T | undefined} */
#raw = $state.raw();
/** @type {any} */
#error = $state.raw(undefined);
/** @type {Promise<void>} */
#promise;
/** @type {((value: void) => void) | null} */
#resolve_first = null;
/** @type {((reason?: any) => void) | null} */
#reject_first = null;
/**
* Interrupt the main loop, causing the current connection (if active) to be closed
* and unscheduling any future automatic reconnection attempts. Returns a promise that
* resolves when the main loop has fully stopped.
* @type {(() => Promise<void>) | null}
*/
#interrupt = null;
#attempt = 0;
/** @type {Promise<T>['then']} */
// @ts-expect-error TS doesn't understand that the promise returns something
#then = $derived.by(() => {
const p = /** @type {Promise<T>} */ (this.#promise);
return (resolve, reject) => {
const result = p.then(tick).then(() => /** @type {T} */ (this.#raw));
if (resolve || reject) {
return result.then(resolve, reject);
}
return result;
};
});
/**
* @param {string} id
* @param {string} key
* @param {string} payload
*/
constructor(id, key, payload) {
this.#id = id;
this.#payload = payload;
// the semantics of awaiting a live query are a bit weird, but it's basically:
// - It's a promise that resolves to the first value from the server
// - Thereafter, it's a promise that immediately resolves to the current value
const { promise, resolve, reject } = with_resolvers();
this.#promise = $state.raw(promise);
this.#resolve_first = resolve;
this.#reject_first = reject;
const serialized = unfriendly_hydratable(key, () => undefined);
if (serialized !== undefined) {
this.set(devalue.parse(serialized, app.decoders));
}
}
/**
* @param {number} attempt
* @returns {number}
*/
static #calculate_retry_delay(attempt) {
const base_delay = Math.min(250 * 2 ** attempt, 10_000);
const jitter = base_delay * (Math.random() * 0.4 - 0.2);
return Math.max(0, Math.round(base_delay + jitter));
}
/** @param {{ on_connect: () => void, on_connect_failed: (reason: any) => void }} [on_connect_handlers] */
async #main({ on_connect, on_connect_failed } = { on_connect: noop, on_connect_failed: noop }) {
// this means we're already running the main loop
if (this.#interrupt) return;
/** @type {PromiseWithResolvers<void>} */
const { promise: stopped, resolve: on_stop } = with_resolvers();
while (!this.#done) {
const controller = new AbortController();
this.#interrupt = () => {
controller.abort();
return stopped;
};
const generator = create_live_iterator(this.#id, this.#payload, controller, () => {
this.#connected = true;
this.#attempt = 0;
on_connect();
});
try {
const { done, value } = await generator.next();
// TODO how much special handling does this need?
// should we even try to reconnect if this is the case?
if (done && !this.#ready) {
throw new Error('Live query completed before yielding a value');
}
this.set(value);
for await (const value of generator) {
this.set(value);
}
this.#done = true;
} catch (error) {
if (controller.signal.aborted) break;
if (error instanceof Redirect) {
// goto() was already called by handle_side_channel_response.
// Reconnect promptly
// TODO this really needs to hook into the router so the reconnect can
// finish before applying the navigation
this.#attempt = 0;
continue;
}
if (!this.#ready) {
// If we haven't successfully connected and received a value yet, surface the error
this.fail(error);
this.#done = true;
on_connect_failed(error);
break;
}
if (error instanceof HttpError) {
// Server intentionally sent an error. Surface it and stop.
this.fail(error);
this.#done = true;
break;
}
// Network/transport error — transient.
// Preserve last good value (or keep initial promise pending).
if (typeof navigator !== 'undefined' && !navigator.onLine) break;
const delay = LiveQuery.#calculate_retry_delay(this.#attempt++);
/** @type {boolean} */
const interrupted = await new Promise((resolve) => {
this.#interrupt = () => {
resolve(true);
return stopped;
};
setTimeout(() => resolve(false), delay);
});
if (interrupted) break;
} finally {
this.#connected = false;
}
}
this.#interrupt = null;
on_stop();
}
#on_online = () => {
if (this.#done) return;
if (this.#interrupt) return;
this.#main().catch(noop);
};
#on_offline = () => {
void this.#interrupt?.();
};
#on_pagehide = () => {
void this.#interrupt?.();
};
#on_pageshow = (/** @type {PageTransitionEvent} */ e) => {
if (e.persisted) {
this.#on_online();
}
};
#start = once(() => {
if (typeof window !== 'undefined') {
window.addEventListener('online', this.#on_online);
window.addEventListener('offline', this.#on_offline);
window.addEventListener('pagehide', this.#on_pagehide);
window.addEventListener('beforeunload', this.#on_pagehide);
window.addEventListener('pageshow', this.#on_pageshow);
}
this.#main().catch(noop);
});
destroy() {
if (typeof window !== 'undefined') {
window.removeEventListener('online', this.#on_online);
window.removeEventListener('offline', this.#on_offline);
window.removeEventListener('pagehide', this.#on_pagehide);
window.removeEventListener('beforeunload', this.#on_pagehide);
window.removeEventListener('pageshow', this.#on_pageshow);
}
void this.#interrupt?.();
}
get then() {
this.#start();
return this.#then;
}
get catch() {
this.#start();
this.#then;
return (/** @type {any} */ reject) => {
return this.#then(undefined, reject);
};
}
get finally() {
this.#start();
this.#then;
return (/** @type {any} */ fn) => {
return this.#then(
(value) => {
fn();
return value;
},
(error) => {
fn();
throw error;
}
);
};
}
get current() {
this.#start();
return this.#raw;
}
get error() {
this.#start();
return this.#error;
}
get loading() {
this.#start();
return this.#loading;
}
get ready() {
this.#start();
return this.#ready;
}
get connected() {
this.#start();
return this.#connected;
}
get done() {
this.#start();
return this.#done;
}
async reconnect() {
await this.#interrupt?.();
/** @type {PromiseWithResolvers<void>} */
const { promise, resolve: on_connect, reject: on_connect_failed } = with_resolvers();
promise.catch(noop);
this.#done = false;
this.#attempt = 0;
this.#main({ on_connect, on_connect_failed }).catch(noop);
await promise;
}
/** @param {T} value */
set(value) {
this.#ready = true;
this.#loading = false;
this.#error = undefined;
this.#raw = value;
if (this.#resolve_first) {
this.#resolve_first();
this.#resolve_first = null;
this.#reject_first = null;
} else {
this.#promise = Promise.resolve();
}
}
/** @param {unknown} error */
fail(error) {
this.#loading = false;
this.#error = error;
if (this.#reject_first) {
this.#reject_first(error);
this.#resolve_first = null;
this.#reject_first = null;
} else {
const promise = Promise.reject(error);
promise.catch(noop);
this.#promise = promise;
}
}
get [Symbol.toStringTag]() {
return 'LiveQuery';
}
}
/**
* @template T
* @implements {Promise<T>}
*/
class LiveQueryProxy {
#key;
#id;
#payload;
#active = true;
#tracking = is_in_effect();
/**
* @param {string} id
* @param {any} arg
*/
constructor(id, arg) {
this.#id = id;
this.#payload = stringify_remote_arg(arg, app.hooks.transport);
this.#key = create_remote_key(id, this.#payload);
Object.defineProperty(this, QUERY_RESOURCE_KEY, { value: this.#key });
if (!this.#tracking) {
this.#active = false;
return;
}
const entry = this.#get_or_create_cache_entry();
$effect.pre(() => () => {
const die = this.#release(entry);
void tick().then(die);
});
}
/** @returns {RemoteLiveQueryCacheEntry<T>} */
#get_or_create_cache_entry() {
let query_instances = live_query_map.get(this.#id);
if (!query_instances) {
query_instances = new Map();
live_query_map.set(this.#id, query_instances);
}
let this_instance = query_instances.get(this.#payload);
if (!this_instance) {
const c = (this_instance = {
count: 0,
resource: /** @type {LiveQuery<T>} */ (/** @type {unknown} */ (null)),
cleanup: /** @type {() => void} */ (/** @type {unknown} */ (null))
});
c.cleanup = $effect.root(() => {
c.resource = new LiveQuery(this.#id, this.#key, this.#payload);
});
query_instances.set(this.#payload, this_instance);
}
this_instance.count += 1;
return this_instance;
}
/**
* @param {RemoteLiveQueryCacheEntry<T>} entry
* @param {boolean} [deactivate]
*/
#release(entry, deactivate = true) {
this.#active &&= !deactivate;
entry.count -= 1;
return () => {
const query_instances = live_query_map.get(this.#id);
const this_instance = query_instances?.get(this.#payload);
if (this_instance?.count === 0) {
this_instance.resource.destroy();
this_instance.cleanup();
query_instances?.delete(this.#payload);
}
if (query_instances?.size === 0) {
live_query_map.delete(this.#id);
}
};
}
#get_cached_query() {
if (!this.#tracking) {
throw new Error(
'This live query was not created in a reactive context and is limited to calling `.run` and `.reconnect`.'
);
}
if (!this.#active) {
throw new Error(
'This query instance is no longer active and can no longer be used for reactive state access. ' +
'This typically means you created the query in a tracking context and stashed it somewhere outside of a tracking context.'
);
}
const cached = live_query_map.get(this.#id)?.get(this.#payload);
if (!cached) {
throw new Error(
'No cached query found. This should be impossible. Please file a bug report.'
);
}
return cached.resource;
}
#safe_get_cached_query() {
return live_query_map.get(this.#id)?.get(this.#payload)?.resource;
}
get current() {
return this.#safe_get_cached_query()?.current;
}
get error() {
return this.#safe_get_cached_query()?.error;
}
get loading() {
return this.#safe_get_cached_query()?.loading ?? false;
}
get ready() {
return this.#safe_get_cached_query()?.ready ?? false;
}
get connected() {
return this.#safe_get_cached_query()?.connected ?? false;
}
get done() {
return this.#safe_get_cached_query()?.done ?? false;
}
run() {
if (is_in_effect()) {
throw new Error(
'On the client, .run() can only be called outside render, e.g. in universal `load` functions and event handlers. In render, await the query directly'
);
}
return create_live_iterator(this.#id, this.#payload);
}
reconnect() {
return this.#safe_get_cached_query()?.reconnect() ?? Promise.resolve();
}
get then() {
const cached = this.#get_cached_query();
return cached.then.bind(cached);
}
get catch() {
const cached = this.#get_cached_query();
return cached.catch.bind(cached);
}
get finally() {
const cached = this.#get_cached_query();
return cached.finally.bind(cached);
}
get [Symbol.toStringTag]() {
return 'LiveQueryProxy';
}
}