UNPKG

@sveltejs/kit

Version:

SvelteKit is the fastest way to build Svelte apps

299 lines (261 loc) • 9.87 kB
/** @import { RemoteFunctionResponse, RemoteFunctionData, RemoteFunctionDataNode } from 'types' */ /** @import { RemoteQueryUpdate } from '$app/server' */ /** @import { CacheEntry } from './cache.svelte.js' */ import * as devalue from 'devalue'; import { app, _goto, live_query_map, query_map, query_responses } from '../client.js'; import { HttpError, Redirect, HandledHttpError } from '@sveltejs/kit/internal'; import { untrack } from 'svelte'; import { create_remote_key, split_remote_key } from '../../shared.js'; import { navigating, page, notify_version } from '#app/state/client'; import * as e from '../../../messages/client-errors.js'; /** Indicates a query function, as opposed to a query instance */ export const QUERY_FUNCTION_ID = Symbol('sveltekit.query_function_id'); /** Indicates a query override callback, used to release the override */ export const QUERY_OVERRIDE_KEY = Symbol('sveltekit.query_override_key'); /** Indicates a query instance */ export const QUERY_RESOURCE_KEY = Symbol('sveltekit.query_resource_key'); /** * If we're inside a reactive context, pin a cache entry for as long as the * surrounding effect is alive. Without this, a transiently-referenced * `QueryProxy`/`LiveQueryProxy` (e.g. one produced by `{await fn()}` in a * template or by `$derived(await fn())`) would be eligible for GC as soon as * the awaited value has been read, after which the FinalizationRegistry * would evict the cache entry — even though the consuming effect is still * alive and may rely on the entry being refreshed (e.g. via `refreshAll()` * or a server-initiated single-flight refresh). * * @template TResource * @param {Map<string, Map<string, { resource: TResource }>>} cache_map * @param {{ manual_ref: (entry: any, id: string, payload: string) => () => void }} cache * @param {string} id * @param {string} payload */ export function pin_in_effect(cache_map, cache, id, payload) { try { $effect.pre(() => { const entry = cache_map.get(id)?.get(payload); if (!entry) return; return cache.manual_ref(entry, id, payload); }); } catch { // not in an effect context — nothing to pin } } /** * Wrap a proxy's `then`/`catch`/`finally` function so that the underlying * cache entry stays pinned for the lifetime of the awaited promise. Without * this, a proxy awaited outside any effect (e.g. in an event handler) could * be GC'd between the `.then` getter returning the thenable and the * underlying promise settling, causing the FinalizationRegistry to evict the * cache entry mid-flight and the awaited value to resolve to Svelte's * `UNINITIALIZED` sentinel from a torn-down `$derived`. * * @template TResource * @template {(...args: any[]) => Promise<any>} TThen * @param {Map<string, Map<string, { resource: TResource }>>} cache_map * @param {{ manual_ref: (entry: any, id: string, payload: string) => () => void }} cache * @param {string} id * @param {string} payload * @param {TThen} then * @returns {TThen} */ export function pin_while_resolving(cache_map, cache, id, payload, then) { return /** @type {TThen} */ ( (...a) => { const entry = cache_map.get(id)?.get(payload); const release = entry ? cache.manual_ref(entry, id, payload) : undefined; const promise = then(...a); if (release) { promise.then(release, release); } return promise; } ); } /** * @returns {{ 'x-sveltekit-pathname': string, 'x-sveltekit-search': string }} */ /** * Unwraps a `RemoteFunctionDataNode` that was serialized during SSR, * rethrowing serialized errors so the consuming resource ends up * in the same failed state it had on the server * @param {RemoteFunctionDataNode} node */ export function unwrap_node(node) { if (node.e) { throw new HandledHttpError(node.e); } return node.v; } export function get_remote_request_headers() { // This will be the correct value of the current or soon-current url, // even in forks because it's state-based - therefore not using window.location. // Use untrack(...) to Avoid accidental reactive dependency on pathname/search return untrack(() => { const url = navigating?.to?.url ?? page.url; return { 'x-sveltekit-pathname': url.pathname, 'x-sveltekit-search': url.search }; }); } /** * @param {string} url * @param {RequestInit} [init] * @param {Set<string> | null} [refreshes] */ export async function remote_request(url, init, refreshes) { const response = await fetch(url, init); const status = response.status; // detect new deployments from the response header notify_version(response.headers.get('x-sveltekit-version')); if (!response.ok) { const result = await response.json().catch(() => undefined); throw result?.type === 'error' ? new HandledHttpError({ status, ...result.error }) : new HttpError({ status, message: response.statusText }); } const result = /** @type {RemoteFunctionResponse} */ (await response.json()); if (result.type === 'error') { throw new HandledHttpError(result.error); } const data = /** @type {RemoteFunctionData} */ ( result.data ? devalue.parse(result.data, app.decoders) : {} ); /** * @param {string} key * @param {CacheEntry | undefined} entry * @param {any} result */ function refresh(key, entry, result) { if (entry?.resource) { if (result.e) { entry.resource.fail(new HandledHttpError(result.e)); } else { entry.resource.set(result.v); } } else if (!result.e) { // `query_responses` stores `{ v }`/`{ e }` nodes, not raw values. // Errors are deliberately dropped here: they are responses to a specific // refresh, not durable state a future resource should initialize with query_responses[key] = result; } } // update queries with refreshed data if (data.q) { for (const key in data.q) { refreshes?.delete(key); const parts = split_remote_key(key); const entry = query_map.get(parts.id)?.get(parts.payload); refresh(key, entry, data.q[key]); } } // reconnect live queries if (data.l) { for (const key in data.l) { refreshes?.delete(key); const parts = split_remote_key(key); const entry = live_query_map.get(parts.id)?.get(parts.payload); refresh(key, entry, data.l[key]); // `fail()` is terminal, so only reconnect on the success path — // reconnecting after a hard failure would wipe the error state and // restart the stream (see commit 63a3e83 regression). if (!data.l[key].e) { void entry?.resource.reconnect(); } } } for (const key of data.i ?? []) refreshes?.delete(key); return data; } /** @param {Set<string> | null} refreshes */ export function fail_unhandled_refreshes(refreshes) { for (const key of refreshes ?? []) { const parts = split_remote_key(key); const entry = query_map.get(parts.id)?.get(parts.payload) ?? live_query_map.get(parts.id)?.get(parts.payload); entry?.resource.fail( new HttpError({ status: 400, message: 'Requested update was not handled by the remote function' }) ); } } /** * @param {RemoteFunctionResponse} response * @returns {Promise<Extract<RemoteFunctionResponse, { type: 'result' }>>} */ export async function handle_side_channel_response(response) { if (response.type === 'redirect') { // Use internal version to allow redirects to external URLs await _goto(response.location); throw new Redirect(307, response.location); } if (response.type === 'error') { throw new HandledHttpError(response.error); } return response; } /** * Given an array of updates, which could be query instances, query functions, or query override release functions, * categorize them into overrides (which need to be released after the command completes), refreshes (which * just need to be refreshed after the command completes), or both. * * @param {RemoteQueryUpdate[]} updates * @returns {{ overrides: Array<() => void>, refreshes: Set<string> }} */ export function categorize_updates(updates) { /** @type {Set<string>} */ const override_keys = new Set(); /** @type {Array<() => void>} */ const overrides = []; /** @type {Set<string>} */ const refreshes = new Set(); for (const update of updates) { if (typeof update === 'function') { if (Object.hasOwn(update, QUERY_FUNCTION_ID)) { // this is a query function (not instance), so we need to find all active instances // of this function and request that they be refreshed/reconnected by the command handler // @ts-expect-error const id = /** @type {string} */ (update[QUERY_FUNCTION_ID]); const entries = query_map.get(id) ?? live_query_map.get(id); if (entries) { for (const payload of entries.keys()) { refreshes.add(create_remote_key(id, payload)); } } continue; } if (Object.hasOwn(update, QUERY_OVERRIDE_KEY)) { // this is a query override release function, so we need to both request that the query instance // be refreshed _and_ stash the release function so we can release the override after the command completes // @ts-expect-error const key = /** @type {string} */ (update[QUERY_OVERRIDE_KEY]); refreshes.add(key); if (override_keys.has(key)) { e.remote_updates_duplicate_override(); } override_keys.add(key); overrides.push(/** @type {() => void} */ (update)); continue; } // this is just a regular function provided by some user integration, so we can just stash it in the overrides array overrides.push(/** @type {() => void} */ (update)); continue; } if ( typeof update === 'object' && update !== null && Object.hasOwn(update, QUERY_RESOURCE_KEY) ) { // this is a query instance, so we just need to request that it be refreshed // @ts-expect-error refreshes.add(/** @type {string} */ (update[QUERY_RESOURCE_KEY])); continue; } e.remote_updates_invalid_argument(); } return { overrides, refreshes }; }