@sveltejs/kit 2.58.0 → 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.
@@ -1,5 +1,5 @@
1
1
  /** @import { RequestEvent } from '@sveltejs/kit' */
2
- /** @import { ServerHooks, MaybePromise, RequestState, RemoteInternals, RequestStore } from 'types' */
2
+ /** @import { ServerHooks, MaybePromise, RequestState, RemoteInternals, RequestStore, RemoteLiveQueryUserFunctionReturnType } from 'types' */
3
3
  import { parse } from 'devalue';
4
4
  import { error } from '@sveltejs/kit';
5
5
  import { with_request_store, get_request_store } from '@sveltejs/kit/internal/server';
@@ -8,7 +8,7 @@ import { create_remote_key, stringify, unfriendly_hydratable } from '../../../sh
8
8
 
9
9
  /**
10
10
  * @param {any} validate_or_fn
11
- * @param {(arg?: any) => any} [maybe_fn]
11
+ * @param {((arg?: any) => any) | undefined} [maybe_fn]
12
12
  * @returns {(arg?: any) => MaybePromise<any>}
13
13
  */
14
14
  export function create_validator(validate_or_fn, maybe_fn) {
@@ -31,6 +31,7 @@ export function create_validator(validate_or_fn, maybe_fn) {
31
31
  return async (arg) => {
32
32
  // Get event before async validation to ensure it's available in server environments without AsyncLocalStorage, too
33
33
  const { event, state } = get_request_store();
34
+
34
35
  // access property and call method in one go to preserve potential this context
35
36
  const result = await validate_or_fn['~standard'].validate(arg);
36
37
 
@@ -109,17 +110,13 @@ export function parse_remote_response(data, transport) {
109
110
  }
110
111
 
111
112
  /**
112
- * Like `with_event` but removes things from `event` you cannot see/call in remote functions, such as `setHeaders`.
113
- * @template T
114
113
  * @param {RequestEvent} event
115
114
  * @param {RequestState} state
116
115
  * @param {boolean} allow_cookies
117
- * @param {() => any} get_input
118
- * @param {(arg?: any) => T} fn
116
+ * @returns {RequestStore}
119
117
  */
120
- export async function run_remote_function(event, state, allow_cookies, get_input, fn) {
121
- /** @type {RequestStore} */
122
- const store = {
118
+ function derive_remote_function_event(event, state, allow_cookies) {
119
+ return {
123
120
  event: {
124
121
  ...event,
125
122
  setHeaders: () => {
@@ -156,12 +153,90 @@ export async function run_remote_function(event, state, allow_cookies, get_input
156
153
  is_in_remote_function: true
157
154
  }
158
155
  };
156
+ }
157
+
158
+ /**
159
+ * Like `with_event` but removes things from `event` you cannot see/call in remote functions, such as `setHeaders`.
160
+ * @template T
161
+ * @param {RequestEvent} event
162
+ * @param {RequestState} state
163
+ * @param {boolean} allow_cookies
164
+ * @param {() => any} get_input
165
+ * @param {(arg?: any) => T} fn
166
+ */
167
+ export async function run_remote_function(event, state, allow_cookies, get_input, fn) {
168
+ const store = derive_remote_function_event(event, state, allow_cookies);
159
169
 
160
170
  // In two parts, each with_event, so that runtimes without async local storage can still get the event at the start of the function
161
171
  const input = await with_request_store(store, get_input);
162
172
  return with_request_store(store, () => fn(input));
163
173
  }
164
174
 
175
+ /**
176
+ * Like `with_event` but removes things from `event` you cannot see/call in remote functions, such as `setHeaders`.
177
+ * @template T
178
+ * @param {RequestEvent} event
179
+ * @param {RequestState} state
180
+ * @param {boolean} allow_cookies
181
+ * @param {() => any} get_input
182
+ * @param {(arg?: any) => RemoteLiveQueryUserFunctionReturnType<T>} fn
183
+ * @param {string} name
184
+ */
185
+ export async function* run_remote_generator(event, state, allow_cookies, get_input, fn, name) {
186
+ const store = derive_remote_function_event(event, state, allow_cookies);
187
+
188
+ // In two parts, each with_event, so that runtimes without async local storage can still get the event at the start of the function / calls to next
189
+ const input = await with_request_store(store, get_input);
190
+ const source = await with_request_store(store, () => fn(input));
191
+ const iterator = to_iterator(source, name);
192
+ let done = false;
193
+
194
+ try {
195
+ while (true) {
196
+ // the code of a generator function is basically chopped apart at each
197
+ // yield, and each part is an invocation of `.next`. So, to provide
198
+ // access to the request context in generator functions, we have to
199
+ // provide it to every invocation of `.next`. (It's more obvious that
200
+ // this is necessary with plain iterators.)
201
+ const result = await with_request_store(store, () => iterator.next());
202
+ if (result.done) {
203
+ done = true;
204
+ return result.value;
205
+ }
206
+ yield result.value;
207
+ }
208
+ } finally {
209
+ if (!done && typeof iterator.return === 'function') {
210
+ await with_request_store(store, () => iterator.return?.(undefined));
211
+ }
212
+ }
213
+ }
214
+
215
+ /**
216
+ * @template T
217
+ * @param {Awaited<RemoteLiveQueryUserFunctionReturnType<T>>} source
218
+ * @param {string} name
219
+ * @returns {Iterator<T> | AsyncIterator<T>}
220
+ */
221
+ function to_iterator(source, name) {
222
+ // intentionally using `in` because these could be inherited
223
+ if ('next' in source && typeof source.next === 'function') {
224
+ return source;
225
+ }
226
+
227
+ if (Symbol.asyncIterator in source && typeof source[Symbol.asyncIterator] === 'function') {
228
+ return source[Symbol.asyncIterator]();
229
+ }
230
+
231
+ if (Symbol.iterator in source && typeof source[Symbol.iterator] === 'function') {
232
+ return source[Symbol.iterator]();
233
+ }
234
+
235
+ throw new Error(
236
+ `query.live '${name}' must return an Iterator, Iterable, AsyncIterator or AsyncIterable`
237
+ );
238
+ }
239
+
165
240
  /**
166
241
  * @param {RemoteInternals} internals
167
242
  * @param {RequestState} state
@@ -1,4 +1,5 @@
1
1
  /** @import { RemoteQueryCacheEntry } from './remote-functions/query.svelte.js' */
2
+ /** @import { RemoteLiveQueryCacheEntry } from './remote-functions/query-live.svelte.js' */
2
3
  import { BROWSER, DEV } from 'esm-env';
3
4
  import * as svelte from 'svelte';
4
5
  import { HttpError, Redirect, SvelteKitError } from '@sveltejs/kit/internal';
@@ -44,6 +45,7 @@ import { compact } from '../../utils/array.js';
44
45
  import {
45
46
  INVALIDATED_PARAM,
46
47
  TRAILING_SLASH_PARAM,
48
+ create_remote_key,
47
49
  validate_depends,
48
50
  validate_load_response
49
51
  } from '../shared.js';
@@ -52,7 +54,7 @@ import { writable } from 'svelte/store';
52
54
  import { page, update, navigating } from './state.svelte.js';
53
55
  import { add_data_suffix, add_resolution_suffix } from '../pathname.js';
54
56
  import { noop_span } from '../telemetry/noop.js';
55
- import { text_decoder } from '../utils.js';
57
+ import { read_ndjson } from './ndjson.js';
56
58
 
57
59
  export { load_css };
58
60
  const ICON_REL_ATTRIBUTES = new Set(['icon', 'shortcut icon', 'apple-touch-icon']);
@@ -305,6 +307,12 @@ export let pending_invalidate;
305
307
  */
306
308
  export const query_map = new Map();
307
309
 
310
+ /**
311
+ * @type {Map<string, Map<string, RemoteLiveQueryCacheEntry<any>>>}
312
+ * A map of id -> payload -> live query internals for all active queries.
313
+ */
314
+ export const live_query_map = new Map();
315
+
308
316
  /**
309
317
  * @param {import('./types.js').SvelteKitApp} _app
310
318
  * @param {HTMLElement} _target
@@ -409,12 +417,23 @@ async function _invalidate(include_load_functions = true, reset_page_state = tru
409
417
  discard_load_cache();
410
418
 
411
419
  // Rerun queries
420
+ /** @type {Map<string, Promise<void>>} */
421
+ const live_query_reconnects = new Map();
412
422
  if (force_invalidation) {
413
- query_map.forEach((entries) => {
414
- entries.forEach(({ resource }) => {
415
- void resource.refresh?.();
416
- });
417
- });
423
+ for (const entries of query_map.values()) {
424
+ for (const { resource } of entries.values()) {
425
+ void resource.refresh();
426
+ }
427
+ }
428
+
429
+ for (const [query_id, entries] of live_query_map) {
430
+ for (const [payload, { resource }] of entries) {
431
+ const key = create_remote_key(query_id, payload);
432
+ const promise = resource.reconnect();
433
+ promise.catch(noop);
434
+ live_query_reconnects.set(key, promise);
435
+ }
436
+ }
418
437
  }
419
438
 
420
439
  if (include_load_functions) {
@@ -443,12 +462,26 @@ async function _invalidate(include_load_functions = true, reset_page_state = tru
443
462
  reset_invalidation();
444
463
  }
445
464
 
465
+ // only wait for promises that are connected to queries that still exist
466
+ /** @type {Promise<any>[]} */
467
+ const promises = [];
468
+ for (const entries of query_map.values()) {
469
+ for (const { resource } of entries.values()) {
470
+ promises.push(resource);
471
+ }
472
+ }
473
+ for (const [query_id, entries] of live_query_map) {
474
+ for (const payload of entries.keys()) {
475
+ const key = create_remote_key(query_id, payload);
476
+ const promise = live_query_reconnects.get(key);
477
+ if (promise) {
478
+ promises.push(promise);
479
+ }
480
+ }
481
+ }
482
+
446
483
  // Don't use allSettled yet because it's too new
447
- await Promise.all(
448
- [...query_map.values()].flatMap((entries) =>
449
- [...entries.values()].map(({ resource }) => resource)
450
- )
451
- ).catch(noop);
484
+ await Promise.all(promises).catch(noop);
452
485
  }
453
486
 
454
487
  function reset_invalidation() {
@@ -485,8 +518,10 @@ function persist_state() {
485
518
  * @param {{}} [nav_token]
486
519
  */
487
520
  export async function _goto(url, options, redirect_count, nav_token) {
488
- /** @type {string[]} */
521
+ /** @type {Set<string>} */
489
522
  let query_keys;
523
+ /** @type {Set<string>} */
524
+ let live_query_keys;
490
525
 
491
526
  // Clear preload cache when invalidateAll is true to ensure fresh data
492
527
  // after form submissions or explicit invalidations
@@ -506,12 +541,18 @@ export async function _goto(url, options, redirect_count, nav_token) {
506
541
  accept: () => {
507
542
  if (options.invalidateAll) {
508
543
  force_invalidation = true;
509
- query_keys = [];
510
- query_map.forEach((entries, id) => {
544
+ query_keys = new Set();
545
+ for (const [id, entries] of query_map) {
511
546
  for (const payload of entries.keys()) {
512
- query_keys.push(id + '/' + payload);
547
+ query_keys.add(create_remote_key(id, payload));
513
548
  }
514
- });
549
+ }
550
+ live_query_keys = new Set();
551
+ for (const [id, entries] of live_query_map) {
552
+ for (const payload of entries.keys()) {
553
+ live_query_keys.add(create_remote_key(id, payload));
554
+ }
555
+ }
515
556
  }
516
557
 
517
558
  if (options.invalidate) {
@@ -527,13 +568,20 @@ export async function _goto(url, options, redirect_count, nav_token) {
527
568
  .tick()
528
569
  .then(svelte.tick)
529
570
  .then(() => {
530
- query_map.forEach((entries, id) => {
531
- entries.forEach(({ resource }, payload) => {
532
- if (query_keys?.includes(id + '/' + payload)) {
533
- void resource.refresh?.();
571
+ for (const [id, entries] of query_map) {
572
+ for (const [payload, { resource }] of entries) {
573
+ if (query_keys?.has(create_remote_key(id, payload))) {
574
+ void resource.refresh();
534
575
  }
535
- });
536
- });
576
+ }
577
+ }
578
+ for (const [id, entries] of live_query_map) {
579
+ for (const [payload, { resource }] of entries) {
580
+ if (live_query_keys?.has(create_remote_key(id, payload))) {
581
+ void resource.reconnect();
582
+ }
583
+ }
584
+ }
537
585
  });
538
586
  }
539
587
  }
@@ -3035,49 +3083,31 @@ async function load_data(url, invalid) {
3035
3083
  });
3036
3084
  }
3037
3085
 
3038
- let text = '';
3039
-
3040
- while (true) {
3041
- // Format follows ndjson (each line is a JSON object) or regular JSON spec
3042
- const { done, value } = await reader.read();
3043
- if (done && !text) break;
3044
-
3045
- text += !value && text ? '\n' : text_decoder.decode(value, { stream: true }); // no value -> final chunk -> add a new line to trigger the last parse
3046
-
3047
- while (true) {
3048
- const split = text.indexOf('\n');
3049
- if (split === -1) {
3050
- break;
3051
- }
3052
-
3053
- const node = JSON.parse(text.slice(0, split));
3054
- text = text.slice(split + 1);
3086
+ for await (const node of read_ndjson(reader)) {
3087
+ if (node.type === 'redirect') {
3088
+ return resolve(node);
3089
+ }
3055
3090
 
3056
- if (node.type === 'redirect') {
3057
- return resolve(node);
3058
- }
3091
+ if (node.type === 'data') {
3092
+ // This is the first (and possibly only, if no pending promises) chunk
3093
+ node.nodes?.forEach((/** @type {any} */ node) => {
3094
+ if (node?.type === 'data') {
3095
+ node.uses = deserialize_uses(node.uses);
3096
+ node.data = deserialize(node.data);
3097
+ }
3098
+ });
3059
3099
 
3060
- if (node.type === 'data') {
3061
- // This is the first (and possibly only, if no pending promises) chunk
3062
- node.nodes?.forEach((/** @type {any} */ node) => {
3063
- if (node?.type === 'data') {
3064
- node.uses = deserialize_uses(node.uses);
3065
- node.data = deserialize(node.data);
3066
- }
3067
- });
3100
+ resolve(node);
3101
+ } else if (node.type === 'chunk') {
3102
+ // This is a subsequent chunk containing deferred data
3103
+ const { id, data, error } = node;
3104
+ const deferred = /** @type {import('types').Deferred} */ (deferreds.get(id));
3105
+ deferreds.delete(id);
3068
3106
 
3069
- resolve(node);
3070
- } else if (node.type === 'chunk') {
3071
- // This is a subsequent chunk containing deferred data
3072
- const { id, data, error } = node;
3073
- const deferred = /** @type {import('types').Deferred} */ (deferreds.get(id));
3074
- deferreds.delete(id);
3075
-
3076
- if (error) {
3077
- deferred.reject(deserialize(error));
3078
- } else {
3079
- deferred.fulfil(deserialize(data));
3080
- }
3107
+ if (error) {
3108
+ deferred.reject(deserialize(error));
3109
+ } else {
3110
+ deferred.fulfil(deserialize(data));
3081
3111
  }
3082
3112
  }
3083
3113
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Yields parsed JSON objects from a ReadableStream of newline-delimited JSON.
3
+ * Each yielded value is the raw `JSON.parse`'d object — callers handle deserialization.
4
+ * @param {ReadableStreamDefaultReader<Uint8Array>} reader
5
+ */
6
+ export async function* read_ndjson(reader) {
7
+ let done = false;
8
+ let buffer = '';
9
+ const decoder = new TextDecoder();
10
+
11
+ while (true) {
12
+ let split = buffer.indexOf('\n');
13
+ while (split !== -1) {
14
+ const line = buffer.slice(0, split).trim();
15
+ buffer = buffer.slice(split + 1);
16
+
17
+ if (line) {
18
+ yield JSON.parse(line);
19
+ }
20
+
21
+ split = buffer.indexOf('\n');
22
+ }
23
+
24
+ if (done) {
25
+ const line = buffer.trim();
26
+ if (line) {
27
+ yield JSON.parse(line);
28
+ }
29
+ return;
30
+ }
31
+
32
+ const chunk = await reader.read();
33
+ done = chunk.done;
34
+ if (chunk.value) {
35
+ buffer += decoder.decode(chunk.value, { stream: true });
36
+ }
37
+
38
+ if (done) {
39
+ buffer += decoder.decode();
40
+ }
41
+ }
42
+ }
@@ -8,7 +8,8 @@ import { stringify_remote_arg } from '../../shared.js';
8
8
  import {
9
9
  get_remote_request_headers,
10
10
  apply_refreshes,
11
- categorize_updates
11
+ categorize_updates,
12
+ apply_reconnections
12
13
  } from './shared.svelte.js';
13
14
 
14
15
  /**
@@ -79,6 +80,10 @@ export function command(id) {
79
80
  apply_refreshes(result.refreshes);
80
81
  }
81
82
 
83
+ if (result.reconnects) {
84
+ apply_reconnections(result.reconnects);
85
+ }
86
+
82
87
  return devalue.parse(result.result, app.decoders);
83
88
  }
84
89
  } finally {
@@ -7,7 +7,7 @@ import { DEV } from 'esm-env';
7
7
  import { HttpError } from '@sveltejs/kit/internal';
8
8
  import { app, query_responses, _goto, set_nearest_error_page, invalidateAll } from '../client.js';
9
9
  import { tick } from 'svelte';
10
- import { apply_refreshes, categorize_updates } from './shared.svelte.js';
10
+ import { apply_refreshes, categorize_updates, apply_reconnections } from './shared.svelte.js';
11
11
  import { createAttachmentKey } from 'svelte/attachments';
12
12
  import {
13
13
  convert_formdata,
@@ -156,7 +156,6 @@ export function form(id) {
156
156
  }
157
157
 
158
158
  try {
159
- // eslint-disable-next-line @typescript-eslint/await-thenable -- `callback` is typed as returning `void` to allow returning e.g. `Promise<boolean>`
160
159
  await callback({
161
160
  form,
162
161
  data,
@@ -235,21 +234,39 @@ export function form(id) {
235
234
  const succeeded = raw_issues.length === 0;
236
235
 
237
236
  if (succeeded) {
238
- if (form_result.refreshes) {
239
- apply_refreshes(form_result.refreshes);
240
- } else {
237
+ if (refreshes === null && !form_result.refreshes && !form_result.reconnects) {
241
238
  void invalidateAll();
239
+ } else {
240
+ if (form_result.refreshes) {
241
+ apply_refreshes(form_result.refreshes);
242
+ }
243
+ if (form_result.reconnects) {
244
+ apply_reconnections(form_result.reconnects);
245
+ }
242
246
  }
243
247
  }
244
248
 
245
249
  return succeeded;
246
250
  } else if (form_result.type === 'redirect') {
247
251
  const stringified_refreshes = form_result.refreshes ?? '';
252
+ const stringified_reconnects = form_result.reconnects ?? '';
248
253
  if (stringified_refreshes) {
249
254
  apply_refreshes(stringified_refreshes);
250
255
  }
256
+
257
+ if (stringified_reconnects) {
258
+ apply_reconnections(stringified_reconnects);
259
+ }
260
+
251
261
  // Use internal version to allow redirects to external URLs
252
- void _goto(form_result.location, { invalidateAll: !stringified_refreshes }, 0);
262
+ void _goto(
263
+ form_result.location,
264
+ {
265
+ invalidateAll:
266
+ refreshes === null && !stringified_refreshes && !stringified_reconnects
267
+ },
268
+ 0
269
+ );
253
270
  return true;
254
271
  } else {
255
272
  throw new HttpError(form_result.status ?? 500, form_result.error);
@@ -1,4 +1,6 @@
1
1
  export { command } from './command.svelte.js';
2
2
  export { form } from './form.svelte.js';
3
3
  export { prerender } from './prerender.svelte.js';
4
- export { query, query_batch } from './query.svelte.js';
4
+ export { query } from './query.svelte.js';
5
+ export { query_batch } from './query-batch.svelte.js';
6
+ export { query_live } from './query-live.svelte.js';
@@ -0,0 +1,105 @@
1
+ /** @import { RemoteQueryFunction } from '@sveltejs/kit' */
2
+ /** @import { RemoteFunctionResponse } from 'types' */
3
+ import { app_dir, base } from '$app/paths/internal/client';
4
+ import { app, goto } from '../client.js';
5
+ import { get_remote_request_headers, QUERY_FUNCTION_ID } from './shared.svelte.js';
6
+ import { QueryProxy } from './query.svelte.js';
7
+ import * as devalue from 'devalue';
8
+ import { HttpError, Redirect } from '@sveltejs/kit/internal';
9
+ import { unfriendly_hydratable } from '../../shared.js';
10
+
11
+ /**
12
+ * @param {string} id
13
+ * @returns {RemoteQueryFunction<any, any>}
14
+ */
15
+ export function query_batch(id) {
16
+ /** @type {Map<string, Array<{resolve: (value: any) => void, reject: (error: any) => void}>>} */
17
+ let batching = new Map();
18
+
19
+ /** @type {RemoteQueryFunction<any, any>} */
20
+ const wrapper = (arg) => {
21
+ return new QueryProxy(id, arg, async (key, payload) => {
22
+ const serialized = await unfriendly_hydratable(key, () => {
23
+ return new Promise((resolve, reject) => {
24
+ // create_remote_function caches identical calls, but in case a refresh to the same query is called multiple times this function
25
+ // is invoked multiple times with the same payload, so we need to deduplicate here
26
+ const entry = batching.get(payload) ?? [];
27
+ entry.push({ resolve, reject });
28
+ batching.set(payload, entry);
29
+
30
+ if (batching.size > 1) return;
31
+
32
+ // Do this here, after await Svelte' reactivity context is gone.
33
+ // TODO is it possible to have batches of the same key
34
+ // but in different forks/async contexts and in the same macrotask?
35
+ // If so this would potentially be buggy
36
+ const headers = {
37
+ 'Content-Type': 'application/json',
38
+ ...get_remote_request_headers()
39
+ };
40
+
41
+ // Wait for the next macrotask - don't use microtask as Svelte runtime uses these to collect changes and flush them,
42
+ // and flushes could reveal more queries that should be batched.
43
+ setTimeout(async () => {
44
+ const batched = batching;
45
+ batching = new Map();
46
+
47
+ try {
48
+ const response = await fetch(`${base}/${app_dir}/remote/${id}`, {
49
+ method: 'POST',
50
+ body: JSON.stringify({
51
+ payloads: Array.from(batched.keys())
52
+ }),
53
+ headers
54
+ });
55
+
56
+ if (!response.ok) {
57
+ throw new Error('Failed to execute batch query');
58
+ }
59
+
60
+ const result = /** @type {RemoteFunctionResponse} */ (await response.json());
61
+ if (result.type === 'error') {
62
+ throw new HttpError(result.status ?? 500, result.error);
63
+ }
64
+
65
+ if (result.type === 'redirect') {
66
+ await goto(result.location);
67
+ throw new Redirect(307, result.location);
68
+ }
69
+
70
+ const results = devalue.parse(result.result, app.decoders);
71
+
72
+ // Resolve individual queries
73
+ // Maps guarantee insertion order so we can do it like this
74
+ let i = 0;
75
+
76
+ for (const resolvers of batched.values()) {
77
+ for (const { resolve, reject } of resolvers) {
78
+ if (results[i].type === 'error') {
79
+ reject(new HttpError(results[i].status, results[i].error));
80
+ } else {
81
+ resolve(results[i].data);
82
+ }
83
+ }
84
+ i++;
85
+ }
86
+ } catch (error) {
87
+ // Reject all queries in the batch
88
+ for (const resolver of batched.values()) {
89
+ for (const { reject } of resolver) {
90
+ reject(error);
91
+ }
92
+ }
93
+ }
94
+ }, 0);
95
+ });
96
+ });
97
+
98
+ return devalue.parse(serialized, app.decoders);
99
+ });
100
+ };
101
+
102
+ Object.defineProperty(wrapper, QUERY_FUNCTION_ID, { value: id });
103
+
104
+ return wrapper;
105
+ }