@sveltejs/kit 3.0.0-next.4 → 3.0.0-next.7

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 (91) hide show
  1. package/package.json +23 -18
  2. package/src/core/adapt/builder.js +29 -43
  3. package/src/core/adapt/index.js +1 -4
  4. package/src/core/config/index.js +135 -71
  5. package/src/core/config/options.js +291 -244
  6. package/src/core/config/types.d.ts +1 -1
  7. package/src/core/env.js +120 -5
  8. package/src/core/generate_manifest/index.js +12 -15
  9. package/src/core/postbuild/analyse.js +8 -12
  10. package/src/core/postbuild/crawl.js +22 -6
  11. package/src/core/postbuild/prerender.js +40 -23
  12. package/src/core/sync/create_manifest_data/index.js +29 -49
  13. package/src/core/sync/write_client_manifest.js +14 -14
  14. package/src/core/sync/write_non_ambient.js +10 -7
  15. package/src/core/sync/write_root.js +9 -30
  16. package/src/core/sync/write_server.js +13 -11
  17. package/src/core/sync/write_tsconfig.js +2 -4
  18. package/src/core/sync/write_types/index.js +11 -10
  19. package/src/exports/index.js +34 -12
  20. package/src/exports/internal/client.js +5 -0
  21. package/src/exports/internal/env.js +1 -1
  22. package/src/exports/internal/index.js +1 -90
  23. package/src/exports/internal/{event.js → server/event.js} +1 -2
  24. package/src/exports/internal/server/index.js +33 -0
  25. package/src/exports/internal/shared.js +89 -0
  26. package/src/exports/node/index.js +58 -7
  27. package/src/exports/params.js +63 -0
  28. package/src/exports/public.d.ts +299 -145
  29. package/src/exports/url.js +84 -0
  30. package/src/exports/vite/build/build_server.js +6 -1
  31. package/src/exports/vite/dev/index.js +38 -37
  32. package/src/exports/vite/index.js +498 -353
  33. package/src/exports/vite/preview/index.js +16 -8
  34. package/src/exports/vite/utils.js +7 -11
  35. package/src/runtime/app/env/types.d.ts +1 -1
  36. package/src/runtime/app/forms.js +22 -5
  37. package/src/runtime/app/paths/client.js +4 -10
  38. package/src/runtime/app/paths/public.d.ts +0 -28
  39. package/src/runtime/app/paths/server.js +8 -4
  40. package/src/runtime/app/server/remote/form.js +10 -3
  41. package/src/runtime/app/server/remote/query.js +9 -18
  42. package/src/runtime/app/server/remote/requested.js +8 -4
  43. package/src/runtime/app/server/remote/shared.js +5 -7
  44. package/src/runtime/app/state/client.js +1 -2
  45. package/src/runtime/app/stores.js +13 -76
  46. package/src/runtime/client/client.js +205 -167
  47. package/src/runtime/client/fetcher.js +3 -2
  48. package/src/runtime/client/remote-functions/cache.svelte.js +3 -1
  49. package/src/runtime/client/remote-functions/form.svelte.js +122 -31
  50. package/src/runtime/client/remote-functions/prerender.svelte.js +17 -4
  51. package/src/runtime/client/remote-functions/query/index.js +3 -2
  52. package/src/runtime/client/remote-functions/query/instance.svelte.js +27 -11
  53. package/src/runtime/client/remote-functions/query-batch.svelte.js +4 -3
  54. package/src/runtime/client/remote-functions/query-live/instance.svelte.js +42 -15
  55. package/src/runtime/client/remote-functions/shared.svelte.js +18 -9
  56. package/src/runtime/client/state.svelte.js +66 -49
  57. package/src/runtime/client/types.d.ts +10 -2
  58. package/src/runtime/client/utils.js +0 -96
  59. package/src/runtime/form-utils.js +99 -18
  60. package/src/runtime/server/cookie.js +22 -33
  61. package/src/runtime/server/csrf.js +65 -0
  62. package/src/runtime/server/data/index.js +7 -7
  63. package/src/runtime/server/env_module.js +0 -5
  64. package/src/runtime/server/index.js +2 -2
  65. package/src/runtime/server/page/actions.js +41 -26
  66. package/src/runtime/server/page/index.js +2 -1
  67. package/src/runtime/server/page/load_data.js +1 -1
  68. package/src/runtime/server/page/render.js +36 -47
  69. package/src/runtime/server/page/respond_with_error.js +7 -8
  70. package/src/runtime/server/page/server_routing.js +13 -9
  71. package/src/runtime/server/remote.js +85 -39
  72. package/src/runtime/server/respond.js +92 -58
  73. package/src/runtime/server/utils.js +7 -7
  74. package/src/runtime/telemetry/otel.js +1 -1
  75. package/src/types/ambient.d.ts +10 -5
  76. package/src/types/global-private.d.ts +4 -4
  77. package/src/types/internal.d.ts +17 -20
  78. package/src/types/private.d.ts +33 -1
  79. package/src/types/synthetic/$lib.md +1 -1
  80. package/src/utils/error.js +12 -4
  81. package/src/utils/mime.js +9 -0
  82. package/src/utils/params.js +66 -0
  83. package/src/utils/routing.js +84 -38
  84. package/src/utils/shared-iterator.js +5 -0
  85. package/src/utils/streaming.js +14 -4
  86. package/src/utils/url.js +20 -2
  87. package/src/version.js +1 -1
  88. package/types/index.d.ts +434 -269
  89. package/types/index.d.ts.map +19 -15
  90. package/src/exports/internal/server.js +0 -22
  91. /package/src/exports/internal/{remote-functions.js → server/remote-functions.js} +0 -0
@@ -94,8 +94,6 @@ export function initial_fetch(resource, opts) {
94
94
  script.remove(); // In case multiple script tags match the same selector
95
95
  let { body, ...init } = JSON.parse(script.textContent);
96
96
 
97
- const ttl = script.getAttribute('data-ttl');
98
- if (ttl) cache.set(selector, { body, init, ttl: 1000 * Number(ttl) });
99
97
  const b64 = script.getAttribute('data-b64');
100
98
  if (b64 !== null) {
101
99
  // Can't use native_fetch('data:...;base64,${body}')
@@ -103,6 +101,9 @@ export function initial_fetch(resource, opts) {
103
101
  body = base64_decode(body);
104
102
  }
105
103
 
104
+ const ttl = script.getAttribute('data-ttl');
105
+ if (ttl) cache.set(selector, { body, init, ttl: 1000 * Number(ttl) });
106
+
106
107
  return Promise.resolve(new Response(body, init));
107
108
  }
108
109
 
@@ -1,8 +1,10 @@
1
+ /** @import { Query } from './query/instance.svelte.js' */
2
+ /** @import { LiveQuery } from './query-live/instance.svelte.js' */
1
3
  import { tick } from 'svelte';
2
4
  import { once } from '../../../utils/functions.js';
3
5
 
4
6
  /**
5
- * @template R
7
+ * @template [R=Query<any> | LiveQuery<any>]
6
8
  * @typedef {object} CacheEntry
7
9
  * @property {number} proxy_count The number of live proxy instances referencing this
8
10
  * entry. The entry is eligible for eviction when this hits zero.
@@ -4,7 +4,13 @@
4
4
  import { app_dir, base } from '$app/paths/internal/client';
5
5
  import { DEV } from 'esm-env';
6
6
  import { HttpError } from '@sveltejs/kit/internal';
7
- import { query_responses, _goto, set_nearest_error_page, invalidateAll } from '../client.js';
7
+ import {
8
+ query_responses,
9
+ _goto,
10
+ set_nearest_error_page,
11
+ invalidateAll,
12
+ handle_error
13
+ } from '../client.js';
8
14
  import { tick } from 'svelte';
9
15
  import { categorize_updates, remote_request } from './shared.svelte.js';
10
16
  import { createAttachmentKey } from 'svelte/attachments';
@@ -17,6 +23,8 @@ import {
17
23
  build_path_string,
18
24
  normalize_issue,
19
25
  serialize_binary_form,
26
+ deep_get,
27
+ DELETE_KEY,
20
28
  BINARY_FORM_CONTENT_TYPE
21
29
  } from '../../form-utils.js';
22
30
 
@@ -52,6 +60,9 @@ export function form(id) {
52
60
  /** @type {Map<any, { count: number, instance: RemoteForm<T, U> }>} */
53
61
  const instances = new Map();
54
62
 
63
+ /** @type {StandardSchemaV1 | null} */
64
+ let shared_preflight_schema = null;
65
+
55
66
  /** @param {string | number | boolean} [key] */
56
67
  function create_instance(key) {
57
68
  const action_id_without_key = id;
@@ -89,7 +100,9 @@ export function form(id) {
89
100
  */
90
101
  let enhance_callback = async (instance) => {
91
102
  if (await instance.submit()) {
92
- instance.element.reset();
103
+ await tick();
104
+ // We call reset from the prototype to avoid DOM clobbering
105
+ HTMLFormElement.prototype.reset.call(instance.element);
93
106
  }
94
107
  };
95
108
 
@@ -97,13 +110,22 @@ export function form(id) {
97
110
  let element = null;
98
111
 
99
112
  /** @type {Record<string, boolean>} */
100
- let touched = {};
113
+ let touched = $state({});
114
+
115
+ /** @type {Record<string, boolean>} */
116
+ let dirty = $state({});
117
+
118
+ /** @type {Record<string, boolean>} */
119
+ let can_validate = {};
101
120
 
102
- let submitted = false;
121
+ let submitted = $state(false);
103
122
 
104
123
  /** @type {InternalRemoteFormIssue[] | null} */
105
124
  let unread_issues = null;
106
125
 
126
+ /** @type {string | null} */
127
+ let previous_submitter_name = null;
128
+
107
129
  /**
108
130
  * In dev, warn if there are validation issues going unread
109
131
  */
@@ -302,7 +324,8 @@ export function form(id) {
302
324
  */
303
325
  async function preflight(form_data) {
304
326
  const data = convert(form_data);
305
- const validated = await preflight_schema?.['~standard'].validate(data);
327
+ const schema = preflight_schema ?? shared_preflight_schema;
328
+ const validated = await schema?.['~standard'].validate(data);
306
329
 
307
330
  if (validated?.issues) {
308
331
  raw_issues = merge_with_server_issues(
@@ -346,6 +369,8 @@ export function form(id) {
346
369
  element = form;
347
370
 
348
371
  touched = {};
372
+ dirty = {};
373
+ can_validate = {};
349
374
 
350
375
  /** @param {SubmitEvent} event */
351
376
  const handle_submit = async (event) => {
@@ -379,6 +404,29 @@ export function form(id) {
379
404
 
380
405
  const form_data = new FormData(form, event.submitter);
381
406
 
407
+ if (
408
+ previous_submitter_name !== null &&
409
+ !Array.from(form_data.keys()).map(strip_prefix).includes(previous_submitter_name)
410
+ ) {
411
+ // Strip any `n:`/`b:` type prefix before clearing, otherwise
412
+ // `set_nested_value` would coerce `undefined` to `NaN`/`false`
413
+ // instead of clearing the previously-submitted value.
414
+ set_nested_value(input, previous_submitter_name, undefined);
415
+ }
416
+
417
+ if (event.submitter) {
418
+ const name = event.submitter.getAttribute('name');
419
+ const value = /** @type {any} */ (event.submitter).value;
420
+
421
+ if (name !== null && value !== undefined) {
422
+ set_nested_value(input, name, value);
423
+ }
424
+
425
+ previous_submitter_name = strip_prefix(name);
426
+ } else {
427
+ previous_submitter_name = null;
428
+ }
429
+
382
430
  if (DEV) {
383
431
  validate_form_data(form_data, clone(form).enctype);
384
432
  }
@@ -396,9 +444,14 @@ export function form(id) {
396
444
  await enhance_callback(create_enhance_callback_instance(form, form_data));
397
445
  } catch (e) {
398
446
  const error =
399
- e instanceof HttpError ? e.body : { message: /** @type {any} */ (e).message };
400
- const status = e instanceof HttpError ? e.status : 500;
401
- void set_nearest_error_page(error, status);
447
+ e instanceof HttpError
448
+ ? e.body
449
+ : await handle_error(e, {
450
+ params: {},
451
+ route: { id: null },
452
+ url: new URL(location.href)
453
+ });
454
+ void set_nearest_error_page(error);
402
455
  } finally {
403
456
  pending_count--;
404
457
  }
@@ -418,8 +471,6 @@ export function form(id) {
418
471
 
419
472
  const is_file = element.type === 'file';
420
473
 
421
- touched[name] = true;
422
-
423
474
  if (is_array) {
424
475
  let value;
425
476
 
@@ -464,14 +515,7 @@ export function form(id) {
464
515
  if (file) {
465
516
  set_nested_value(input, name, file);
466
517
  } else {
467
- // Remove the property by setting to undefined and clean up
468
- const path_parts = name.split(/\.|\[|\]/).filter(Boolean);
469
- let current = /** @type {any} */ (input);
470
- for (let i = 0; i < path_parts.length - 1; i++) {
471
- if (current[path_parts[i]] == null) return;
472
- current = current[path_parts[i]];
473
- }
474
- delete current[path_parts[path_parts.length - 1]];
518
+ set_nested_value(input, name, DELETE_KEY);
475
519
  }
476
520
  } else {
477
521
  set_nested_value(
@@ -481,9 +525,9 @@ export function form(id) {
481
525
  );
482
526
  }
483
527
 
484
- name = name.replace(/^[nb]:/, '');
528
+ name = strip_prefix(name);
485
529
 
486
- touched[name] = true;
530
+ dirty[name] = true;
487
531
  };
488
532
 
489
533
  const handle_reset = async () => {
@@ -492,18 +536,38 @@ export function form(id) {
492
536
  await tick();
493
537
 
494
538
  input = convert_formdata(new FormData(form));
539
+ raw_issues = [];
540
+ touched = {};
541
+ dirty = {};
542
+ can_validate = {};
543
+ submitted = false;
544
+ };
545
+
546
+ /** @param {Event} e */
547
+ const handle_focusout = (e) => {
548
+ let name = /** @type {HTMLInputElement} */ (e.target).name;
549
+ if (!name) return;
550
+
551
+ name = strip_prefix(name).replace(/\[\]$/, '');
552
+
553
+ touched[name] = true;
554
+
555
+ if (Object.hasOwn(dirty, name)) {
556
+ can_validate[name] = true;
557
+ }
495
558
  };
496
559
 
497
560
  form.addEventListener('submit', handle_submit);
498
561
  form.addEventListener('input', handle_input);
562
+ form.addEventListener('focusout', handle_focusout);
499
563
  form.addEventListener('reset', handle_reset);
500
564
 
501
565
  return () => {
502
566
  form.removeEventListener('submit', handle_submit);
503
567
  form.removeEventListener('input', handle_input);
568
+ form.removeEventListener('focusout', handle_focusout);
504
569
  form.removeEventListener('reset', handle_reset);
505
570
  element = null;
506
- preflight_schema = undefined;
507
571
  };
508
572
  };
509
573
 
@@ -534,9 +598,10 @@ export function form(id) {
534
598
 
535
599
  const submission = submit(form_data, true);
536
600
 
537
- void submission.finally(() => {
601
+ const decrement = () => {
538
602
  pending_count--;
539
- });
603
+ };
604
+ void submission.then(decrement, decrement);
540
605
 
541
606
  return submission;
542
607
  }
@@ -549,11 +614,16 @@ export function form(id) {
549
614
  (path, value) => {
550
615
  if (path.length === 0) {
551
616
  input = value;
552
- } else {
617
+ } else if (value !== deep_get(input, path)) {
553
618
  deep_set(input, path.map(String), value);
554
619
 
555
620
  const key = build_path_string(path);
556
- touched[key] = true;
621
+
622
+ if (element) {
623
+ touched[key] = true;
624
+ dirty[key] = true;
625
+ can_validate[key] = true;
626
+ }
557
627
  }
558
628
  },
559
629
  (path, all) => {
@@ -567,7 +637,10 @@ export function form(id) {
567
637
  }
568
638
 
569
639
  return issues;
570
- }
640
+ },
641
+ () => touched,
642
+ () => dirty,
643
+ []
571
644
  )
572
645
  },
573
646
  result: {
@@ -576,16 +649,24 @@ export function form(id) {
576
649
  pending: {
577
650
  get: () => pending_count
578
651
  },
652
+ submitted: {
653
+ get: () => submitted
654
+ },
579
655
  preflight: {
580
656
  /** @type {RemoteForm<T, U>['preflight']} */
581
657
  value: (schema) => {
582
658
  preflight_schema = schema;
659
+
660
+ if (key === undefined) {
661
+ shared_preflight_schema = schema;
662
+ }
663
+
583
664
  return instance;
584
665
  }
585
666
  },
586
667
  validate: {
587
668
  /** @type {RemoteForm<any, any>['validate']} */
588
- value: async ({ includeUntouched = false, preflightOnly = false } = {}) => {
669
+ value: async ({ all = false, preflightOnly = false } = {}) => {
589
670
  if (!element) return;
590
671
 
591
672
  const id = ++validate_id;
@@ -603,8 +684,8 @@ export function form(id) {
603
684
  let array = [];
604
685
 
605
686
  const data = convert(form_data);
606
-
607
- const validated = await preflight_schema?.['~standard'].validate(data);
687
+ const schema = preflight_schema ?? shared_preflight_schema;
688
+ const validated = await schema?.['~standard'].validate(data);
608
689
 
609
690
  if (validate_id !== id) {
610
691
  return;
@@ -636,8 +717,8 @@ export function form(id) {
636
717
  array = /** @type {InternalRemoteFormIssue[]} */ (result._);
637
718
  }
638
719
 
639
- if (!includeUntouched && !submitted) {
640
- array = array.filter((issue) => touched[issue.name]);
720
+ if (!all && !submitted) {
721
+ array = array.filter((issue) => can_validate[issue.name]);
641
722
  }
642
723
 
643
724
  const is_server_validation = !validated?.issues && !preflightOnly;
@@ -728,3 +809,13 @@ function validate_form_data(form_data, enctype) {
728
809
  }
729
810
  }
730
811
  }
812
+
813
+ /**
814
+ * Remove the `n:` or `b:` prefix from a field name
815
+ * @template {string | null} T
816
+ * @param {T} name
817
+ * @returns {T}
818
+ */
819
+ function strip_prefix(name) {
820
+ return /** @type {T} */ (name && name.replace(/^[nb]:/, ''));
821
+ }
@@ -2,9 +2,11 @@
2
2
  import { app_dir, base } from '$app/paths/internal/client';
3
3
  import { version } from '$app/env';
4
4
  import * as devalue from 'devalue';
5
- import { app, goto, prerender_responses } from '../client.js';
5
+ import { app, _goto, handle_error, prerender_responses } from '../client.js';
6
6
  import { get_remote_request_headers, remote_request, unwrap_node } from './shared.svelte.js';
7
7
  import { create_remote_key, stringify_remote_arg } from '../../shared.js';
8
+ import { noop } from '../../../utils/functions.js';
9
+ import { HttpError } from '@sveltejs/kit/internal';
8
10
 
9
11
  // Initialize Cache API for prerender functions
10
12
  const CACHE_NAME = __SVELTEKIT_DEV__ ? `sveltekit:${Date.now()}` : `sveltekit:${version}`;
@@ -97,7 +99,8 @@ export function prerender(id) {
97
99
  const result = await remote_request(url, { headers });
98
100
 
99
101
  if (result.redirect) {
100
- void goto(result.redirect);
102
+ // Use internal version to allow redirects to external URLs
103
+ void _goto(result.redirect, {}, 0);
101
104
  return;
102
105
  }
103
106
 
@@ -146,6 +149,7 @@ class Prerender {
146
149
  /** @type {T | undefined} */
147
150
  #current = $state.raw();
148
151
 
152
+ /** @type {App.Error | undefined} */
149
153
  #error = $state.raw(undefined);
150
154
 
151
155
  /**
@@ -160,12 +164,21 @@ class Prerender {
160
164
  this.#error = undefined;
161
165
  return value;
162
166
  },
163
- (error) => {
167
+ async (e) => {
168
+ const error = await handle_error(e, {
169
+ params: {},
170
+ route: { id: null },
171
+ url: new URL(location.href)
172
+ });
164
173
  this.#loading = false;
165
174
  this.#error = error;
166
- throw error;
175
+ throw new HttpError(error.status, error); // so that transformError doesn't transform it again
167
176
  }
168
177
  );
178
+
179
+ // rejections are surfaced via `.error` for reactive consumers — make sure the
180
+ // stored promise (consumed without `await`) never becomes an unhandled rejection
181
+ this.#promise.catch(noop);
169
182
  }
170
183
 
171
184
  /**
@@ -1,6 +1,6 @@
1
1
  /** @import { RemoteQueryFunction } from '@sveltejs/kit' */
2
2
  import { app_dir, base } from '$app/paths/internal/client';
3
- import { goto, query_map } from '../../client.js';
3
+ import { _goto, query_map } from '../../client.js';
4
4
  import { get_remote_request_headers, QUERY_FUNCTION_ID, remote_request } from '../shared.svelte.js';
5
5
  import { DEV } from 'esm-env';
6
6
  import { QueryProxy } from './proxy.js';
@@ -29,7 +29,8 @@ export function query(id) {
29
29
  const result = await remote_request(url, { headers: get_remote_request_headers() });
30
30
 
31
31
  if (result.redirect) {
32
- await goto(result.redirect);
32
+ // Use internal version to allow redirects to external URLs
33
+ await _goto(result.redirect, {}, 0);
33
34
  }
34
35
  });
35
36
  };
@@ -1,4 +1,4 @@
1
- import { query_responses } from '../../client.js';
1
+ import { query_responses, handle_error } from '../../client.js';
2
2
  import { HttpError } from '@sveltejs/kit/internal';
3
3
  import { QUERY_OVERRIDE_KEY } from '../shared.svelte.js';
4
4
  import { noop } from '../../../../utils/functions.js';
@@ -37,7 +37,7 @@ export class Query {
37
37
  return this.#overrides.reduce((v, r) => r(v), /** @type {T} */ (this.#raw));
38
38
  });
39
39
 
40
- /** @type {any} */
40
+ /** @type {App.Error | undefined} */
41
41
  #error = $state.raw(undefined);
42
42
 
43
43
  /** @type {Promise<T>['then']} */
@@ -70,7 +70,7 @@ export class Query {
70
70
  delete query_responses[key];
71
71
 
72
72
  if (node.e) {
73
- this.fail(new HttpError(node.e[0] ?? 500, node.e[1]));
73
+ this.fail(new HttpError(node.e.status, node.e));
74
74
  } else {
75
75
  this.set(/** @type {T} */ (node.v));
76
76
  }
@@ -87,7 +87,9 @@ export class Query {
87
87
  // every time you see this comment, try removing the `tick.then` here and see
88
88
  // if all the tests still pass with the latest svelte version
89
89
  // if they do, congrats, you can remove tick.then
90
- void tick().then(() => this.#get_promise());
90
+ void tick()
91
+ .then(() => this.#get_promise())
92
+ .catch(noop);
91
93
  }
92
94
 
93
95
  #clear_pending() {
@@ -100,6 +102,11 @@ export class Query {
100
102
 
101
103
  const { promise, resolve, reject } = Promise.withResolvers();
102
104
 
105
+ // the rejection is surfaced via `.error` / the `then` getter for awaiting
106
+ // consumers — a purely reactive consumer (`.current`) attaches no handler,
107
+ // so make sure the stored promise can never become an unhandled rejection
108
+ promise.catch(noop);
109
+
103
110
  this.#latest.push(resolve);
104
111
 
105
112
  Promise.resolve(this.#fn())
@@ -119,22 +126,33 @@ export class Query {
119
126
 
120
127
  resolve(undefined);
121
128
  })
122
- .catch((e) => {
129
+ .catch(async (e) => {
123
130
  // TODO: Our behavior here could be better:
124
131
  // - We should not reject on redirects, but should hook into the router
125
132
  // to ensure the query is properly refreshed before the navigation completes
126
133
  // - Instead of failing on transport-level errors, we should probably do what
127
134
  // LiveQuery does and preserve the last known good value and retry the connection
135
+ if (this.#latest.indexOf(resolve) === -1) return;
136
+
137
+ const error = await handle_error(e, {
138
+ params: {},
139
+ route: { id: null },
140
+ url: new URL(location.href)
141
+ });
142
+
143
+ // Re-check after the async `handle_error` gap: a later request may have
144
+ // resolved/rejected while we were awaiting and superseded this one, so
145
+ // recompute the index and bail out if this request is no longer current
128
146
  const idx = this.#latest.indexOf(resolve);
129
147
  if (idx === -1) return;
130
148
 
131
149
  untrack(() => {
132
150
  this.#latest.splice(0, idx).forEach((r) => r(undefined));
133
- this.#error = e;
151
+ this.#error = error;
134
152
  this.#loading = false;
135
153
  });
136
154
 
137
- reject(e);
155
+ reject(new HttpError(error.status, error)); // so that transformError doesn't transform it again
138
156
  });
139
157
 
140
158
  return promise;
@@ -222,9 +240,7 @@ export class Query {
222
240
  this.#promise = Promise.resolve();
223
241
  }
224
242
 
225
- /**
226
- * @param {unknown} error
227
- */
243
+ /** @param {HttpError} error */
228
244
  fail(error) {
229
245
  // normally consumed in the constructor, but make sure a leftover
230
246
  // SSR record can never shadow the newly-set error
@@ -232,7 +248,7 @@ export class Query {
232
248
 
233
249
  this.#clear_pending();
234
250
  this.#loading = false;
235
- this.#error = error;
251
+ this.#error = error.body;
236
252
 
237
253
  const promise = Promise.reject(error);
238
254
 
@@ -1,6 +1,6 @@
1
1
  /** @import { RemoteQueryFunction } from '@sveltejs/kit' */
2
2
  import { app_dir, base } from '$app/paths/internal/client';
3
- import { goto } from '../client.js';
3
+ import { _goto } from '../client.js';
4
4
  import { get_remote_request_headers, QUERY_FUNCTION_ID, remote_request } from './shared.svelte.js';
5
5
  import { QueryProxy } from './query/proxy.js';
6
6
  import { HttpError } from '@sveltejs/kit/internal';
@@ -50,7 +50,8 @@ export function query_batch(id) {
50
50
  });
51
51
 
52
52
  if (response.redirect) {
53
- await goto(response.redirect);
53
+ // Use internal version to allow redirects to external URLs
54
+ await _goto(response.redirect, {}, 0);
54
55
 
55
56
  // settle all batched promises (with `undefined`, like a redirect
56
57
  // from a non-batched query) so that callers don't hang forever
@@ -71,7 +72,7 @@ export function query_batch(id) {
71
72
 
72
73
  for (const { resolve, reject } of resolvers) {
73
74
  if (result.type === 'error') {
74
- reject(new HttpError(result.status, result.error));
75
+ reject(new HttpError(result.error.status, result.error));
75
76
  } else {
76
77
  resolve(result.data);
77
78
  }
@@ -1,4 +1,4 @@
1
- import { query_responses } from '../../client.js';
1
+ import { query_responses, handle_error } from '../../client.js';
2
2
  import { HttpError, Redirect } from '@sveltejs/kit/internal';
3
3
  import { noop, once } from '../../../../utils/functions.js';
4
4
  import { SharedIterator } from '../../../../utils/shared-iterator.js';
@@ -24,7 +24,7 @@ export class LiveQuery {
24
24
  #done = $state(false);
25
25
  /** @type {T | undefined} */
26
26
  #raw = $state.raw();
27
- /** @type {any} */
27
+ /** @type {App.Error | undefined} */
28
28
  #error = $state.raw(undefined);
29
29
  /** @type {Promise<void>} */
30
30
  #promise;
@@ -91,9 +91,9 @@ export class LiveQuery {
91
91
  // the query failed during SSR — seed the failed state (mirroring `fail()`,
92
92
  // minus its terminal `#done`), so the main loop still connects as usual
93
93
  // and the query can recover
94
- const error = new HttpError(node.e[0] ?? 500, node.e[1]);
94
+ const error = new HttpError(node.e.status, node.e);
95
95
  this.#loading = false;
96
- this.#error = error;
96
+ this.#error = error.body;
97
97
 
98
98
  promise.catch(noop);
99
99
  this.#reject_first?.(error);
@@ -122,6 +122,7 @@ export class LiveQuery {
122
122
 
123
123
  /** @type {PromiseWithResolvers<void>} */
124
124
  const { promise: stopped, resolve: on_stop } = Promise.withResolvers();
125
+ let connected = false;
125
126
 
126
127
  while (!this.#done) {
127
128
  const controller = new AbortController();
@@ -134,16 +135,22 @@ export class LiveQuery {
134
135
  const generator = create_live_iterator(this.#id, this.#payload, controller, () => {
135
136
  this.#connected = true;
136
137
  this.#attempt = 0;
138
+ connected = true;
137
139
  on_connect();
138
140
  });
139
141
 
140
142
  try {
141
143
  const { done, value } = await generator.next();
142
144
 
143
- // TODO how much special handling does this need?
144
- // should we even try to reconnect if this is the case?
145
- if (done && !this.#ready) {
146
- throw new Error('Live query completed before yielding a value');
145
+ if (done) {
146
+ if (!this.#ready) {
147
+ throw new Error('Live query completed before yielding a value');
148
+ }
149
+ // stream completed without yielding (e.g. the generator returned
150
+ // immediately on reconnect) — keep the last good value
151
+ this.#done = true;
152
+ this.#fan_out.done();
153
+ break;
147
154
  }
148
155
 
149
156
  this.set(value);
@@ -168,7 +175,7 @@ export class LiveQuery {
168
175
 
169
176
  if (!this.#ready) {
170
177
  // If we haven't successfully connected and received a value yet, surface the error
171
- this.fail(error);
178
+ await this.#fail(error);
172
179
  on_connect_failed(error);
173
180
  break;
174
181
  }
@@ -200,6 +207,12 @@ export class LiveQuery {
200
207
  }
201
208
 
202
209
  this.#interrupt = null;
210
+ // If the loop exited without ever successfully connecting, settle the
211
+ // reconnect handshake so callers (e.g. `invalidateAll()`) never await
212
+ // a forever-pending promise.
213
+ if (!connected) {
214
+ on_connect_failed(this.#error ?? new Error('Live query connection was interrupted'));
215
+ }
203
216
  on_stop();
204
217
  }
205
218
 
@@ -343,10 +356,13 @@ export class LiveQuery {
343
356
  promise.catch(noop);
344
357
  this.#done = false;
345
358
  this.#attempt = 0;
346
- // The previous fan-out may have been closed by `done()`/`fail()`. Future
347
- // `for await` consumers need a fresh, open fan-out attached to the new
348
- // `#main` lifetime.
349
- this.#fan_out = new SharedIterator();
359
+ // Keep the existing fan-out open so active `for await` consumers
360
+ // continue receiving values from the new connection without interruption.
361
+ // Only replace it if it was already closed by a prior `done()`/`fail()`
362
+ // (e.g. reconnecting after a finite or hard-failed stream)
363
+ if (this.#fan_out.closed) {
364
+ this.#fan_out = new SharedIterator();
365
+ }
350
366
  this.#main({ on_connect, on_connect_failed }).catch(noop);
351
367
  await promise;
352
368
  }
@@ -369,10 +385,10 @@ export class LiveQuery {
369
385
  this.#fan_out.push(value);
370
386
  }
371
387
 
372
- /** @param {unknown} error */
388
+ /** @param {HttpError} error */
373
389
  fail(error) {
374
390
  this.#loading = false;
375
- this.#error = error;
391
+ this.#error = error.body;
376
392
  // `fail` is terminal — once a live query has hard-failed, the only way to start
377
393
  // streaming again is via `reconnect()`. Mark it done and abort any in-flight
378
394
  // request so that callers from outside the main loop (e.g. `apply_reconnections`)
@@ -381,6 +397,7 @@ export class LiveQuery {
381
397
  void this.#interrupt?.();
382
398
 
383
399
  if (this.#reject_first) {
400
+ this.#promise.catch(noop);
384
401
  this.#reject_first(error);
385
402
  this.#resolve_first = null;
386
403
  this.#reject_first = null;
@@ -393,6 +410,16 @@ export class LiveQuery {
393
410
  this.#fan_out.fail(error);
394
411
  }
395
412
 
413
+ /** @param {unknown} e */
414
+ async #fail(e) {
415
+ const error = await handle_error(e, {
416
+ params: {},
417
+ route: { id: null },
418
+ url: new URL(location.href)
419
+ });
420
+ this.fail(new HttpError(error.status, error));
421
+ }
422
+
396
423
  get [Symbol.toStringTag]() {
397
424
  return 'LiveQuery';
398
425
  }