@sveltejs/kit 2.57.1 → 2.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +2 -2
  2. package/src/exports/internal/remote-functions.js +1 -1
  3. package/src/exports/public.d.ts +107 -16
  4. package/src/runtime/app/paths/client.js +7 -0
  5. package/src/runtime/app/paths/server.js +7 -0
  6. package/src/runtime/app/server/remote/command.js +7 -6
  7. package/src/runtime/app/server/remote/form.js +2 -1
  8. package/src/runtime/app/server/remote/prerender.js +3 -4
  9. package/src/runtime/app/server/remote/query.js +327 -113
  10. package/src/runtime/app/server/remote/requested.js +127 -32
  11. package/src/runtime/app/server/remote/shared.js +89 -20
  12. package/src/runtime/client/client.js +92 -62
  13. package/src/runtime/client/ndjson.js +42 -0
  14. package/src/runtime/client/remote-functions/command.svelte.js +8 -3
  15. package/src/runtime/client/remote-functions/form.svelte.js +24 -6
  16. package/src/runtime/client/remote-functions/index.js +3 -1
  17. package/src/runtime/client/remote-functions/query-batch.svelte.js +105 -0
  18. package/src/runtime/client/remote-functions/query-live.svelte.js +636 -0
  19. package/src/runtime/client/remote-functions/query.svelte.js +48 -148
  20. package/src/runtime/client/remote-functions/shared.svelte.js +76 -23
  21. package/src/runtime/form-utils.js +33 -12
  22. package/src/runtime/server/page/index.js +26 -16
  23. package/src/runtime/server/page/load_data.js +4 -2
  24. package/src/runtime/server/page/render.js +21 -18
  25. package/src/runtime/server/remote.js +117 -9
  26. package/src/runtime/server/respond.js +11 -8
  27. package/src/runtime/server/utils.js +10 -0
  28. package/src/runtime/shared.js +3 -3
  29. package/src/runtime/utils.js +0 -1
  30. package/src/types/internal.d.ts +54 -14
  31. package/src/utils/page_nodes.js +1 -0
  32. package/src/utils/url.js +3 -3
  33. package/src/version.js +1 -1
  34. package/types/index.d.ts +182 -30
  35. package/types/index.d.ts.map +8 -4
@@ -0,0 +1,636 @@
1
+ /** @import { RemoteLiveQuery, RemoteLiveQueryFunction } from '@sveltejs/kit' */
2
+ /** @import { PromiseWithResolvers } from '../../../utils/promise.js' */
3
+ import { app_dir, base } from '$app/paths/internal/client';
4
+ import { app, live_query_map } from '../client.js';
5
+ import {
6
+ get_remote_request_headers,
7
+ handle_side_channel_response,
8
+ is_in_effect,
9
+ QUERY_FUNCTION_ID,
10
+ QUERY_RESOURCE_KEY
11
+ } from './shared.svelte.js';
12
+ import * as devalue from 'devalue';
13
+ import { HttpError, Redirect } from '@sveltejs/kit/internal';
14
+ import { DEV } from 'esm-env';
15
+ import { noop, once } from '../../../utils/functions.js';
16
+ import { with_resolvers } from '../../../utils/promise.js';
17
+ import { tick } from 'svelte';
18
+ import { create_remote_key, stringify_remote_arg, unfriendly_hydratable } from '../../shared.js';
19
+ import { read_ndjson } from '../ndjson.js';
20
+
21
+ /**
22
+ * @template T
23
+ * @typedef {{
24
+ * count: number;
25
+ * resource: LiveQuery<T>;
26
+ * cleanup: () => void;
27
+ * }} RemoteLiveQueryCacheEntry
28
+ */
29
+
30
+ /**
31
+ * @param {string} id
32
+ * @returns {RemoteLiveQueryFunction<any, any>}
33
+ */
34
+ export function query_live(id) {
35
+ if (DEV) {
36
+ // If this reruns as part of HMR, refresh the query
37
+ const entries = live_query_map.get(id);
38
+
39
+ if (entries) {
40
+ for (const entry of entries.values()) {
41
+ void entry.resource.reconnect();
42
+ }
43
+ }
44
+ }
45
+
46
+ /** @type {RemoteLiveQueryFunction<any, any>} */
47
+ const wrapper = (arg) => /** @type {RemoteLiveQuery<any>} */ (new LiveQueryProxy(id, arg));
48
+
49
+ Object.defineProperty(wrapper, QUERY_FUNCTION_ID, { value: id });
50
+
51
+ return wrapper;
52
+ }
53
+
54
+ /**
55
+ * @param {Response} response
56
+ * @returns {Promise<ReadableStreamDefaultReader<Uint8Array>>}
57
+ */
58
+ async function get_stream_reader(response) {
59
+ const content_type = response.headers.get('content-type') ?? '';
60
+
61
+ if (response.ok && content_type.includes('application/json')) {
62
+ // we can end up here if we e.g. redirect in `handle`
63
+ const result = await response.json();
64
+ await handle_side_channel_response(result);
65
+ throw new HttpError(500, 'Invalid query.live response');
66
+ }
67
+
68
+ if (!response.ok) {
69
+ const result = await response.json().catch(() => ({
70
+ type: 'error',
71
+ status: response.status,
72
+ error: response.statusText
73
+ }));
74
+
75
+ throw new HttpError(result.status ?? response.status ?? 500, result.error);
76
+ }
77
+
78
+ if (!response.body) {
79
+ throw new Error('Expected query.live response body to be a ReadableStream');
80
+ }
81
+
82
+ return response.body.getReader();
83
+ }
84
+
85
+ /**
86
+ * Yields deserialized results from a ReadableStream of newline-delimited JSON
87
+ * @param {ReadableStreamDefaultReader<Uint8Array>} reader
88
+ */
89
+ async function* read_live_ndjson(reader) {
90
+ for await (const node of read_ndjson(reader)) {
91
+ if (node.type === 'result') {
92
+ yield devalue.parse(node.result, app.decoders);
93
+ continue;
94
+ }
95
+
96
+ await handle_side_channel_response(node);
97
+ throw new HttpError(500, 'Invalid query.live response');
98
+ }
99
+ }
100
+
101
+ /**
102
+ * @template T
103
+ * @param {string} id
104
+ * @param {string} payload
105
+ * @param {AbortController} [controller]
106
+ * @param {() => void} [on_connect]
107
+ * @returns {AsyncGenerator<T>}
108
+ */
109
+ export async function* create_live_iterator(
110
+ id,
111
+ payload,
112
+ controller = new AbortController(),
113
+ on_connect = noop
114
+ ) {
115
+ const url = `${base}/${app_dir}/remote/${id}${payload ? `?payload=${payload}` : ''}`;
116
+ /** @type {ReadableStreamDefaultReader<Uint8Array> | null} */
117
+ let reader = null;
118
+
119
+ try {
120
+ const response = await fetch(url, {
121
+ headers: get_remote_request_headers(),
122
+ signal: controller.signal
123
+ });
124
+ reader = await get_stream_reader(response);
125
+
126
+ on_connect();
127
+
128
+ yield* read_live_ndjson(reader);
129
+ } finally {
130
+ try {
131
+ await reader?.cancel();
132
+ } catch {
133
+ // already closed
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * @template T
140
+ * @implements {Promise<T>}
141
+ */
142
+ export class LiveQuery {
143
+ #id;
144
+ #payload;
145
+ #loading = $state(true);
146
+ #ready = $state(false);
147
+ /** Is there a current connection to the server? */
148
+ #connected = $state(false);
149
+ /**
150
+ * Have we been told by the server that we have completed iteration and are done?
151
+ * When this is `true`, the only way to start live-updating again is to call `.reconnect()`.
152
+ */
153
+ #done = $state(false);
154
+ /** @type {T | undefined} */
155
+ #raw = $state.raw();
156
+ /** @type {any} */
157
+ #error = $state.raw(undefined);
158
+ /** @type {Promise<void>} */
159
+ #promise;
160
+ /** @type {((value: void) => void) | null} */
161
+ #resolve_first = null;
162
+ /** @type {((reason?: any) => void) | null} */
163
+ #reject_first = null;
164
+ /**
165
+ * Interrupt the main loop, causing the current connection (if active) to be closed
166
+ * and unscheduling any future automatic reconnection attempts. Returns a promise that
167
+ * resolves when the main loop has fully stopped.
168
+ * @type {(() => Promise<void>) | null}
169
+ */
170
+ #interrupt = null;
171
+ #attempt = 0;
172
+
173
+ /** @type {Promise<T>['then']} */
174
+ // @ts-expect-error TS doesn't understand that the promise returns something
175
+ #then = $derived.by(() => {
176
+ const p = /** @type {Promise<T>} */ (this.#promise);
177
+
178
+ return (resolve, reject) => {
179
+ const result = p.then(tick).then(() => /** @type {T} */ (this.#raw));
180
+
181
+ if (resolve || reject) {
182
+ return result.then(resolve, reject);
183
+ }
184
+
185
+ return result;
186
+ };
187
+ });
188
+
189
+ /**
190
+ * @param {string} id
191
+ * @param {string} key
192
+ * @param {string} payload
193
+ */
194
+ constructor(id, key, payload) {
195
+ this.#id = id;
196
+ this.#payload = payload;
197
+
198
+ // the semantics of awaiting a live query are a bit weird, but it's basically:
199
+ // - It's a promise that resolves to the first value from the server
200
+ // - Thereafter, it's a promise that immediately resolves to the current value
201
+ const { promise, resolve, reject } = with_resolvers();
202
+ this.#promise = $state.raw(promise);
203
+ this.#resolve_first = resolve;
204
+ this.#reject_first = reject;
205
+
206
+ const serialized = unfriendly_hydratable(key, () => undefined);
207
+ if (serialized !== undefined) {
208
+ this.set(devalue.parse(serialized, app.decoders));
209
+ }
210
+ }
211
+
212
+ /**
213
+ * @param {number} attempt
214
+ * @returns {number}
215
+ */
216
+ static #calculate_retry_delay(attempt) {
217
+ const base_delay = Math.min(250 * 2 ** attempt, 10_000);
218
+ const jitter = base_delay * (Math.random() * 0.4 - 0.2);
219
+ return Math.max(0, Math.round(base_delay + jitter));
220
+ }
221
+
222
+ /** @param {{ on_connect: () => void, on_connect_failed: (reason: any) => void }} [on_connect_handlers] */
223
+ async #main({ on_connect, on_connect_failed } = { on_connect: noop, on_connect_failed: noop }) {
224
+ // this means we're already running the main loop
225
+ if (this.#interrupt) return;
226
+
227
+ /** @type {PromiseWithResolvers<void>} */
228
+ const { promise: stopped, resolve: on_stop } = with_resolvers();
229
+
230
+ while (!this.#done) {
231
+ const controller = new AbortController();
232
+
233
+ this.#interrupt = () => {
234
+ controller.abort();
235
+ return stopped;
236
+ };
237
+
238
+ const generator = create_live_iterator(this.#id, this.#payload, controller, () => {
239
+ this.#connected = true;
240
+ this.#attempt = 0;
241
+ on_connect();
242
+ });
243
+
244
+ try {
245
+ const { done, value } = await generator.next();
246
+
247
+ // TODO how much special handling does this need?
248
+ // should we even try to reconnect if this is the case?
249
+ if (done && !this.#ready) {
250
+ throw new Error('Live query completed before yielding a value');
251
+ }
252
+
253
+ this.set(value);
254
+
255
+ for await (const value of generator) {
256
+ this.set(value);
257
+ }
258
+
259
+ this.#done = true;
260
+ } catch (error) {
261
+ if (controller.signal.aborted) break;
262
+
263
+ if (error instanceof Redirect) {
264
+ // goto() was already called by handle_side_channel_response.
265
+ // Reconnect promptly
266
+ // TODO this really needs to hook into the router so the reconnect can
267
+ // finish before applying the navigation
268
+ this.#attempt = 0;
269
+ continue;
270
+ }
271
+
272
+ if (!this.#ready) {
273
+ // If we haven't successfully connected and received a value yet, surface the error
274
+ this.fail(error);
275
+ this.#done = true;
276
+ on_connect_failed(error);
277
+ break;
278
+ }
279
+
280
+ if (error instanceof HttpError) {
281
+ // Server intentionally sent an error. Surface it and stop.
282
+ this.fail(error);
283
+ this.#done = true;
284
+ break;
285
+ }
286
+
287
+ // Network/transport error — transient.
288
+ // Preserve last good value (or keep initial promise pending).
289
+
290
+ if (typeof navigator !== 'undefined' && !navigator.onLine) break;
291
+
292
+ const delay = LiveQuery.#calculate_retry_delay(this.#attempt++);
293
+ /** @type {boolean} */
294
+ const interrupted = await new Promise((resolve) => {
295
+ this.#interrupt = () => {
296
+ resolve(true);
297
+ return stopped;
298
+ };
299
+ setTimeout(() => resolve(false), delay);
300
+ });
301
+ if (interrupted) break;
302
+ } finally {
303
+ this.#connected = false;
304
+ }
305
+ }
306
+
307
+ this.#interrupt = null;
308
+ on_stop();
309
+ }
310
+
311
+ #on_online = () => {
312
+ if (this.#done) return;
313
+ if (this.#interrupt) return;
314
+ this.#main().catch(noop);
315
+ };
316
+
317
+ #on_offline = () => {
318
+ void this.#interrupt?.();
319
+ };
320
+
321
+ #on_pagehide = () => {
322
+ void this.#interrupt?.();
323
+ };
324
+
325
+ #on_pageshow = (/** @type {PageTransitionEvent} */ e) => {
326
+ if (e.persisted) {
327
+ this.#on_online();
328
+ }
329
+ };
330
+
331
+ #start = once(() => {
332
+ if (typeof window !== 'undefined') {
333
+ window.addEventListener('online', this.#on_online);
334
+ window.addEventListener('offline', this.#on_offline);
335
+ window.addEventListener('pagehide', this.#on_pagehide);
336
+ window.addEventListener('beforeunload', this.#on_pagehide);
337
+ window.addEventListener('pageshow', this.#on_pageshow);
338
+ }
339
+
340
+ this.#main().catch(noop);
341
+ });
342
+
343
+ destroy() {
344
+ if (typeof window !== 'undefined') {
345
+ window.removeEventListener('online', this.#on_online);
346
+ window.removeEventListener('offline', this.#on_offline);
347
+ window.removeEventListener('pagehide', this.#on_pagehide);
348
+ window.removeEventListener('beforeunload', this.#on_pagehide);
349
+ window.removeEventListener('pageshow', this.#on_pageshow);
350
+ }
351
+
352
+ void this.#interrupt?.();
353
+ }
354
+
355
+ get then() {
356
+ this.#start();
357
+ return this.#then;
358
+ }
359
+
360
+ get catch() {
361
+ this.#start();
362
+ this.#then;
363
+ return (/** @type {any} */ reject) => {
364
+ return this.#then(undefined, reject);
365
+ };
366
+ }
367
+
368
+ get finally() {
369
+ this.#start();
370
+ this.#then;
371
+ return (/** @type {any} */ fn) => {
372
+ return this.#then(
373
+ (value) => {
374
+ fn();
375
+ return value;
376
+ },
377
+ (error) => {
378
+ fn();
379
+ throw error;
380
+ }
381
+ );
382
+ };
383
+ }
384
+
385
+ get current() {
386
+ this.#start();
387
+ return this.#raw;
388
+ }
389
+
390
+ get error() {
391
+ this.#start();
392
+ return this.#error;
393
+ }
394
+
395
+ get loading() {
396
+ this.#start();
397
+ return this.#loading;
398
+ }
399
+
400
+ get ready() {
401
+ this.#start();
402
+ return this.#ready;
403
+ }
404
+
405
+ get connected() {
406
+ this.#start();
407
+ return this.#connected;
408
+ }
409
+
410
+ get done() {
411
+ this.#start();
412
+ return this.#done;
413
+ }
414
+
415
+ async reconnect() {
416
+ await this.#interrupt?.();
417
+ /** @type {PromiseWithResolvers<void>} */
418
+ const { promise, resolve: on_connect, reject: on_connect_failed } = with_resolvers();
419
+ promise.catch(noop);
420
+ this.#done = false;
421
+ this.#attempt = 0;
422
+ this.#main({ on_connect, on_connect_failed }).catch(noop);
423
+ await promise;
424
+ }
425
+
426
+ /** @param {T} value */
427
+ set(value) {
428
+ this.#ready = true;
429
+ this.#loading = false;
430
+ this.#error = undefined;
431
+ this.#raw = value;
432
+
433
+ if (this.#resolve_first) {
434
+ this.#resolve_first();
435
+ this.#resolve_first = null;
436
+ this.#reject_first = null;
437
+ } else {
438
+ this.#promise = Promise.resolve();
439
+ }
440
+ }
441
+
442
+ /** @param {unknown} error */
443
+ fail(error) {
444
+ this.#loading = false;
445
+ this.#error = error;
446
+
447
+ if (this.#reject_first) {
448
+ this.#reject_first(error);
449
+ this.#resolve_first = null;
450
+ this.#reject_first = null;
451
+ } else {
452
+ const promise = Promise.reject(error);
453
+ promise.catch(noop);
454
+ this.#promise = promise;
455
+ }
456
+ }
457
+
458
+ get [Symbol.toStringTag]() {
459
+ return 'LiveQuery';
460
+ }
461
+ }
462
+
463
+ /**
464
+ * @template T
465
+ * @implements {Promise<T>}
466
+ */
467
+ class LiveQueryProxy {
468
+ #key;
469
+ #id;
470
+ #payload;
471
+ #active = true;
472
+ #tracking = is_in_effect();
473
+
474
+ /**
475
+ * @param {string} id
476
+ * @param {any} arg
477
+ */
478
+ constructor(id, arg) {
479
+ this.#id = id;
480
+ this.#payload = stringify_remote_arg(arg, app.hooks.transport);
481
+ this.#key = create_remote_key(id, this.#payload);
482
+ Object.defineProperty(this, QUERY_RESOURCE_KEY, { value: this.#key });
483
+
484
+ if (!this.#tracking) {
485
+ this.#active = false;
486
+ return;
487
+ }
488
+
489
+ const entry = this.#get_or_create_cache_entry();
490
+
491
+ $effect.pre(() => () => {
492
+ const die = this.#release(entry);
493
+ void tick().then(die);
494
+ });
495
+ }
496
+
497
+ /** @returns {RemoteLiveQueryCacheEntry<T>} */
498
+ #get_or_create_cache_entry() {
499
+ let query_instances = live_query_map.get(this.#id);
500
+
501
+ if (!query_instances) {
502
+ query_instances = new Map();
503
+ live_query_map.set(this.#id, query_instances);
504
+ }
505
+
506
+ let this_instance = query_instances.get(this.#payload);
507
+
508
+ if (!this_instance) {
509
+ const c = (this_instance = {
510
+ count: 0,
511
+ resource: /** @type {LiveQuery<T>} */ (/** @type {unknown} */ (null)),
512
+ cleanup: /** @type {() => void} */ (/** @type {unknown} */ (null))
513
+ });
514
+
515
+ c.cleanup = $effect.root(() => {
516
+ c.resource = new LiveQuery(this.#id, this.#key, this.#payload);
517
+ });
518
+
519
+ query_instances.set(this.#payload, this_instance);
520
+ }
521
+
522
+ this_instance.count += 1;
523
+
524
+ return this_instance;
525
+ }
526
+
527
+ /**
528
+ * @param {RemoteLiveQueryCacheEntry<T>} entry
529
+ * @param {boolean} [deactivate]
530
+ */
531
+ #release(entry, deactivate = true) {
532
+ this.#active &&= !deactivate;
533
+ entry.count -= 1;
534
+
535
+ return () => {
536
+ const query_instances = live_query_map.get(this.#id);
537
+ const this_instance = query_instances?.get(this.#payload);
538
+
539
+ if (this_instance?.count === 0) {
540
+ this_instance.resource.destroy();
541
+ this_instance.cleanup();
542
+ query_instances?.delete(this.#payload);
543
+ }
544
+
545
+ if (query_instances?.size === 0) {
546
+ live_query_map.delete(this.#id);
547
+ }
548
+ };
549
+ }
550
+
551
+ #get_cached_query() {
552
+ if (!this.#tracking) {
553
+ throw new Error(
554
+ 'This live query was not created in a reactive context and is limited to calling `.run` and `.reconnect`.'
555
+ );
556
+ }
557
+
558
+ if (!this.#active) {
559
+ throw new Error(
560
+ 'This query instance is no longer active and can no longer be used for reactive state access. ' +
561
+ 'This typically means you created the query in a tracking context and stashed it somewhere outside of a tracking context.'
562
+ );
563
+ }
564
+
565
+ const cached = live_query_map.get(this.#id)?.get(this.#payload);
566
+
567
+ if (!cached) {
568
+ throw new Error(
569
+ 'No cached query found. This should be impossible. Please file a bug report.'
570
+ );
571
+ }
572
+
573
+ return cached.resource;
574
+ }
575
+
576
+ #safe_get_cached_query() {
577
+ return live_query_map.get(this.#id)?.get(this.#payload)?.resource;
578
+ }
579
+
580
+ get current() {
581
+ return this.#safe_get_cached_query()?.current;
582
+ }
583
+
584
+ get error() {
585
+ return this.#safe_get_cached_query()?.error;
586
+ }
587
+
588
+ get loading() {
589
+ return this.#safe_get_cached_query()?.loading ?? false;
590
+ }
591
+
592
+ get ready() {
593
+ return this.#safe_get_cached_query()?.ready ?? false;
594
+ }
595
+
596
+ get connected() {
597
+ return this.#safe_get_cached_query()?.connected ?? false;
598
+ }
599
+
600
+ get done() {
601
+ return this.#safe_get_cached_query()?.done ?? false;
602
+ }
603
+
604
+ run() {
605
+ if (is_in_effect()) {
606
+ throw new Error(
607
+ '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'
608
+ );
609
+ }
610
+
611
+ return create_live_iterator(this.#id, this.#payload);
612
+ }
613
+
614
+ reconnect() {
615
+ return this.#safe_get_cached_query()?.reconnect() ?? Promise.resolve();
616
+ }
617
+
618
+ get then() {
619
+ const cached = this.#get_cached_query();
620
+ return cached.then.bind(cached);
621
+ }
622
+
623
+ get catch() {
624
+ const cached = this.#get_cached_query();
625
+ return cached.catch.bind(cached);
626
+ }
627
+
628
+ get finally() {
629
+ const cached = this.#get_cached_query();
630
+ return cached.finally.bind(cached);
631
+ }
632
+
633
+ get [Symbol.toStringTag]() {
634
+ return 'LiveQueryProxy';
635
+ }
636
+ }