UNPKG

@sveltejs/kit

Version:

SvelteKit is the fastest way to build Svelte apps

419 lines (361 loc) 11.4 kB
/** @import { PromiseWithResolvers } from '../../../../utils/promise.js' */ import { query_responses } from '../../client.js'; import { HttpError, Redirect } from '@sveltejs/kit/internal'; import { noop, once } from '../../../../utils/functions.js'; import { with_resolvers } from '../../../../utils/promise.js'; import { SharedIterator } from '../../../../utils/shared-iterator.js'; import { tick } from 'svelte'; import { create_live_iterator } from './iterator.js'; /** * @template T * @implements {Promise<T>} * @implements {AsyncIterable<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; /** * Fan-out for `for await` consumers attached to this LiveQuery's shared * stream. New subscribers see the most-recently-emitted value (if any) as * their first yield. Subsequent yields fire whenever `set()` is called. * * @type {SharedIterator<T>} */ #fan_out = new SharedIterator(); /** @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; if (Object.hasOwn(query_responses, key)) { const node = query_responses[key]; delete query_responses[key]; if (node.e) { // the query failed during SSR — seed the failed state (mirroring `fail()`, // minus its terminal `#done`), so the main loop still connects as usual // and the query can recover const error = new HttpError(node.e[0] ?? 500, node.e[1]); this.#loading = false; this.#error = error; promise.catch(noop); this.#reject_first?.(error); this.#resolve_first = null; this.#reject_first = null; } else { this.set(node.v); } } } /** * @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(); let connected = false; 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; connected = true; on_connect(); }); try { const { done, value } = await generator.next(); if (done) { if (!this.#ready) { throw new Error('Live query completed before yielding a value'); } // stream completed without yielding (e.g. the generator returned // immediately on reconnect) — keep the last good value this.#done = true; this.#fan_out.done(); break; } this.set(value); for await (const value of generator) { this.set(value); } this.#done = true; this.#fan_out.done(); } 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); on_connect_failed(error); break; } if (error instanceof HttpError) { // Server intentionally sent an error. Surface it and stop. this.fail(error); 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; // If the loop exited without ever successfully connecting, settle the // reconnect handshake so callers (e.g. `invalidateAll()`) never await // a forever-pending promise. if (!connected) { on_connect_failed(this.#error ?? new Error('Live query connection was interrupted')); } 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); } this.#fan_out.done(); void this.#interrupt?.(); } /** * Iterate the stream of values yielded by this live query. Multiple * iterators share the underlying connection; the most-recently emitted value * (if any) is yielded first to each new iterator, mirroring the semantics * of awaiting the query directly. * * Backpressure note: if values arrive faster than the consumer drains, only * the latest pending value is kept. This matches the reactive `.current` * semantics — live streams are not event logs. * * @returns {AsyncGenerator<T, void, void>} */ [Symbol.asyncIterator]() { this.#start(); // Seed the new iterator with the current value (if any) so the first // `.next()` resolves synchronously — mirroring `await liveQuery()` // semantics. If the query has hard-failed, `#fan_out` is already closed // with the terminal error and `subscribe()` returns an iterator whose // first `.next()` rejects. return this.#fan_out.subscribe( this.#ready && this.#error === undefined ? { initial_value: { value: /** @type {T} */ (this.#raw) } } : undefined ); } 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; // Keep the existing fan-out open so active `for await` consumers // continue receiving values from the new connection without interruption. // Only replace it if it was already closed by a prior `done()`/`fail()` // (e.g. reconnecting after a finite or hard-failed stream) if (this.#fan_out.closed) { this.#fan_out = new SharedIterator(); } 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(); } this.#fan_out.push(value); } /** @param {unknown} error */ fail(error) { this.#loading = false; this.#error = error; // `fail` is terminal — once a live query has hard-failed, the only way to start // streaming again is via `reconnect()`. Mark it done and abort any in-flight // request so that callers from outside the main loop (e.g. `apply_reconnections`) // don't leave the loop spinning. this.#done = true; void this.#interrupt?.(); if (this.#reject_first) { this.#promise.catch(noop); this.#reject_first(error); this.#resolve_first = null; this.#reject_first = null; } else { const promise = Promise.reject(error); promise.catch(noop); this.#promise = promise; } this.#fan_out.fail(error); } get [Symbol.toStringTag]() { return 'LiveQuery'; } }