@blacklizard/inertia-express 3.0.0 → 3.1.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,49 +1,39 @@
1
1
  import { createHash } from "node:crypto";
2
- //#region src/core/cache.ts
3
- /**
4
- * Recursively normalize a value so equal-by-value inputs serialize identically:
5
- * object keys are sorted, `undefined` properties are dropped, and non-finite
6
- * numbers collapse to `null`.
7
- *
8
- * @param value Arbitrary JSON-shaped input.
9
- */
10
- function canonicalize(value) {
11
- if (value === null) return null;
12
- if (typeof value !== "object") {
13
- if (typeof value === "number" && !Number.isFinite(value)) return null;
14
- return value;
15
- }
16
- if (Array.isArray(value)) return value.map(canonicalize);
17
- const obj = value;
18
- return Object.fromEntries(Object.keys(obj).sort().filter((key) => obj[key] !== void 0).map((key) => [key, canonicalize(obj[key])]));
19
- }
2
+ //#region src/core/prop-descriptor.ts
20
3
  /**
21
- * Stable JSON encoding: keys sorted recursively, undefined dropped, NaN/Inf
22
- * normalized. Two equal-by-value objects always produce the same string,
23
- * so they hash to the same cache key.
24
- *
25
- * @param value Arbitrary JSON-shaped input.
4
+ * Wrap a value in a list the way Laravel's `Arr::wrap` does: `undefined` and
5
+ * `null` (a plain-JS caller omitting an option) become an empty list.
26
6
  */
27
- function canonicalStringify(value) {
28
- return JSON.stringify(canonicalize(value));
7
+ function wrapList(value) {
8
+ if (value === void 0 || value === null) return [];
9
+ return Array.isArray(value) ? [...value] : [value];
29
10
  }
11
+ const MERGE_LIVE_MESSAGE = "Live updates are not supported on merge properties.";
12
+ /** Name prefix the broadcaster adds for each channel type; never sent to the client. */
13
+ const CHANNEL_PREFIXES = {
14
+ public: "",
15
+ private: "private-",
16
+ presence: "presence-",
17
+ "encrypted-private": "private-encrypted-"
18
+ };
30
19
  /**
31
- * Build the cache key for an SSR cache entry.
32
- *
33
- * Shape: `${prefix}:${version}:${component}:${sha256(props[+discriminator])}`
34
- *
35
- * Including `version` in the key namespace means a deploy that bumps the
36
- * asset version atomically retires every old key — no flush, no race.
20
+ * Normalise one channel input into a {@link LiveChannel}: a bare string is
21
+ * public; an object's type is validated and its type prefix stripped from the name.
37
22
  *
38
- * @param input Prefix, version, component, resolved props, optional discriminator.
23
+ * @param input Bare channel name or explicit channel.
39
24
  */
40
- function computeSsrCacheKey(input) {
41
- const versionPart = input.version ?? "_";
42
- const propsHash = createHash("sha256").update(canonicalStringify(input.props)).update(input.discriminator ?? "").digest("hex").slice(0, 32);
43
- return `${input.prefix}:${versionPart}:${input.component}:${propsHash}`;
25
+ function normalizeChannel(input) {
26
+ if (typeof input === "string") return {
27
+ name: input,
28
+ type: "public"
29
+ };
30
+ if (!Object.hasOwn(CHANNEL_PREFIXES, input.type)) throw new TypeError(`Unknown live channel type [${String(input.type)}]. Use public, private, presence or encrypted-private.`);
31
+ const prefix = CHANNEL_PREFIXES[input.type];
32
+ return {
33
+ name: prefix !== "" && input.name.startsWith(prefix) ? input.name.slice(prefix.length) : input.name,
34
+ type: input.type
35
+ };
44
36
  }
45
- //#endregion
46
- //#region src/core/prop-descriptor.ts
47
37
  /**
48
38
  * A prop wrapped with resolution and merge behaviour, ported from
49
39
  * inertia-laravel's prop classes and their `MergesProps`, `DefersProps`
@@ -72,6 +62,8 @@ var PropDescriptor = class {
72
62
  isScroll = false;
73
63
  wrapper = "data";
74
64
  scrollMetadata = null;
65
+ liveDeclarations = [];
66
+ liveThrottle = null;
75
67
  /**
76
68
  * Create a descriptor around a prop source.
77
69
  *
@@ -97,9 +89,16 @@ var PropDescriptor = class {
97
89
  return !this.appends && this.mergesAtRoot();
98
90
  }
99
91
  /**
100
- * Mark the prop for a shallow client-side merge.
92
+ * `true` once at least one live declaration was recorded.
93
+ */
94
+ get isLive() {
95
+ return this.liveDeclarations.length > 0;
96
+ }
97
+ /**
98
+ * Mark the prop for a shallow client-side merge. Throws when the prop is live.
101
99
  */
102
100
  merge() {
101
+ if (this.isLive) throw new Error(MERGE_LIVE_MESSAGE);
103
102
  this.merges = true;
104
103
  return this;
105
104
  }
@@ -229,6 +228,48 @@ var PropDescriptor = class {
229
228
  return null;
230
229
  }
231
230
  /**
231
+ * Declare events (and the channels they arrive on) that refresh the prop.
232
+ * May be called more than once; a later throttle replaces an earlier one.
233
+ * Throws on a merge prop or an unknown channel type.
234
+ *
235
+ * @param options Events, channels and optional throttle in milliseconds.
236
+ */
237
+ live(options) {
238
+ if (this.merges) throw new Error(MERGE_LIVE_MESSAGE);
239
+ const events = wrapList(options.on);
240
+ const channels = wrapList(options.channel).map(normalizeChannel);
241
+ this.liveDeclarations.push({
242
+ events,
243
+ channels
244
+ });
245
+ if (options.throttle !== void 0) this.liveThrottle = options.throttle;
246
+ return this;
247
+ }
248
+ /**
249
+ * Build the channel listeners, ported from inertia-laravel's
250
+ * `HasLiveUpdates::liveListeners()` without event-class branches. Each
251
+ * channel gets the deduplicated events declared for it, in first-seen order.
252
+ */
253
+ liveListeners() {
254
+ if (!this.liveDeclarations.some((declaration) => declaration.events.length > 0)) throw new Error("A live property must listen for at least one event. Pass the event to the [on] option.");
255
+ const listeners = /* @__PURE__ */ new Map();
256
+ this.liveDeclarations.forEach(({ events, channels }) => {
257
+ events.forEach((event) => {
258
+ if (channels.length === 0) throw new Error(`Unable to resolve the channels for the [${event}] event. Pass the [channel] option.`);
259
+ channels.forEach((channel) => {
260
+ const key = `${channel.type}:${channel.name}`;
261
+ const listener = listeners.get(key) ?? {
262
+ channel: { ...channel },
263
+ events: []
264
+ };
265
+ if (!listener.events.includes(event)) listener.events.push(event);
266
+ listeners.set(key, listener);
267
+ });
268
+ });
269
+ });
270
+ return [...listeners.values()];
271
+ }
272
+ /**
232
273
  * Resolve the source: call it when it is a function, then await the result.
233
274
  */
234
275
  async resolveSource() {
@@ -319,6 +360,16 @@ function always(value) {
319
360
  return prop;
320
361
  }
321
362
  /**
363
+ * A live prop: its value is resolved like an always prop's source and the
364
+ * client refreshes it when one of the given events arrives.
365
+ *
366
+ * @param value Plain value or producer.
367
+ * @param options Events, channels and optional throttle.
368
+ */
369
+ function live(value, options) {
370
+ return new PropDescriptor("live", value).live(options);
371
+ }
372
+ /**
322
373
  * A merge prop: the client appends it to its existing value at the root.
323
374
  * Throws a `TypeError` when given a second argument (the 2.x `matchOn`
324
375
  * form); chain `.matchOn()` instead.
@@ -450,359 +501,84 @@ async function unpackDotProps(props) {
450
501
  }, Promise.resolve({ ...props }));
451
502
  }
452
503
  //#endregion
453
- //#region src/core/errors.ts
504
+ //#region src/core/partial-filter.ts
454
505
  /**
455
- * Apply error-bag scoping to a flat errors object, matching the official
456
- * Inertia behavior. When `bag` is set, errors are namespaced under
457
- * `props.errors[bag]`. When `bag` is null, errors live at `props.errors`.
506
+ * `true` when `path` equals `parent` or lies in its dot-notation subtree.
458
507
  *
459
- * @param errors Flat field-to-message map.
460
- * @param bag Optional error-bag name (e.g. `"createUser"`); null for top-level.
508
+ * @param path Dot-notation prop path to test.
509
+ * @param parent Dot-notation path that may contain `path`.
461
510
  */
462
- function scopeErrors(errors, bag) {
463
- if (bag) return { errors: { [bag]: errors } };
464
- return { errors };
511
+ function pathIsSameOrDescendant(path, parent) {
512
+ return path === parent || path.startsWith(`${parent}.`);
465
513
  }
466
- //#endregion
467
- //#region src/core/headers.ts
468
- /**
469
- * Canonical Inertia v3 protocol header names.
470
- *
471
- * The Inertia client sends these headers on every visit. The server uses
472
- * them to decide between a full HTML response and a JSON page response,
473
- * and to scope partial reloads.
474
- */
475
- const INERTIA_HEADERS = {
476
- inertia: "x-inertia",
477
- version: "x-inertia-version",
478
- partialComponent: "x-inertia-partial-component",
479
- partialData: "x-inertia-partial-data",
480
- partialExcept: "x-inertia-partial-except",
481
- errorBag: "x-inertia-error-bag",
482
- reset: "x-inertia-reset",
483
- location: "x-inertia-location",
484
- exceptOnceProps: "x-inertia-except-once-props",
485
- mergeIntent: "x-inertia-infinite-scroll-merge-intent"
486
- };
487
514
  /**
488
- * Read a header value as a single string, looking up the lowercase variant as
489
- * a fallback. Returns the first entry of an array value and `null` when the
490
- * header is absent.
515
+ * `true` when `path` matches an `only` entry or is a descendant of one.
491
516
  *
492
- * @param headers Raw header record from the HTTP layer.
493
- * @param name Header name to read.
517
+ * @param path Dot-notation prop path to test.
518
+ * @param only Requested partial-reload `only` paths.
494
519
  */
495
- function readHeader(headers, name) {
496
- const value = headers[name] ?? headers[name.toLowerCase()];
497
- if (value === void 0 || value === null) return null;
498
- if (Array.isArray(value)) return value[0] ?? null;
499
- return value;
520
+ function matchesOnly(path, only) {
521
+ return only.some((onlyPath) => pathIsSameOrDescendant(path, onlyPath));
500
522
  }
501
523
  /**
502
- * Split a comma-separated header value into a trimmed, non-empty string list.
503
- * Returns `null` when the input is null or yields no entries.
524
+ * `true` when `path` is a strict ancestor of an `only` entry.
504
525
  *
505
- * @param value Raw header value, or null when absent.
526
+ * @param path Dot-notation prop path to test.
527
+ * @param only Requested partial-reload `only` paths.
506
528
  */
507
- function parseList(value) {
508
- if (value === null) return null;
509
- const parts = value.split(",").map((part) => part.trim()).filter((part) => part.length > 0);
510
- return parts.length > 0 ? parts : null;
529
+ function leadsToOnly(path, only) {
530
+ return only.some((onlyPath) => onlyPath.startsWith(`${path}.`));
511
531
  }
512
532
  /**
513
- * Parse a request's headers into a normalized {@link InertiaRequestInfo}.
533
+ * `true` when `path` equals an `except` entry, is a descendant of one, or is
534
+ * an ancestor of one. Matching ancestors mirrors the reference Node server,
535
+ * whose flat filter drops a top-level key whenever any `except` entry lives
536
+ * under it; inertia-laravel keeps the ancestor and drops only the child.
514
537
  *
515
- * @param input Headers, HTTP method, and request URL. Method is uppercased in
516
- * the returned object; the URL is passed through unchanged.
538
+ * @param path Dot-notation prop path to test.
539
+ * @param except Requested partial-reload `except` paths.
517
540
  */
518
- function parseInertiaRequest(input) {
519
- const { headers } = input;
520
- const inertia = readHeader(headers, INERTIA_HEADERS.inertia);
521
- const version = readHeader(headers, INERTIA_HEADERS.version);
522
- const partialComponent = readHeader(headers, INERTIA_HEADERS.partialComponent);
523
- const partialOnly = parseList(readHeader(headers, INERTIA_HEADERS.partialData));
524
- const partialExcept = parseList(readHeader(headers, INERTIA_HEADERS.partialExcept));
525
- const errorBag = readHeader(headers, INERTIA_HEADERS.errorBag);
526
- const resetKeys = parseList(readHeader(headers, INERTIA_HEADERS.reset));
527
- const exceptOnceProps = parseList(readHeader(headers, INERTIA_HEADERS.exceptOnceProps)) ?? [];
528
- const mergeIntent = readHeader(headers, INERTIA_HEADERS.mergeIntent) === "prepend" ? "prepend" : "append";
529
- return {
530
- isInertia: inertia === "true" || inertia === "1",
531
- version,
532
- partialComponent,
533
- partialOnly,
534
- partialExcept,
535
- errorBag,
536
- resetKeys,
537
- exceptOnceProps,
538
- mergeIntent,
539
- method: input.method.toUpperCase(),
540
- url: input.url
541
- };
541
+ function matchesExcept(path, except) {
542
+ return except.some((exceptPath) => pathIsSameOrDescendant(path, exceptPath) || pathIsSameOrDescendant(exceptPath, path));
542
543
  }
543
544
  //#endregion
544
- //#region src/core/html.ts
545
- const HTML_ESCAPES = {
546
- "&": "&",
547
- "<": "&lt;",
548
- ">": "&gt;",
549
- "\"": "&quot;",
550
- "'": "&#39;"
551
- };
545
+ //#region src/core/props-resolver.ts
552
546
  /**
553
- * Escape characters that would break out of a double-quoted HTML attribute.
547
+ * `true` for arrays and for objects whose prototype is `Object.prototype` or `null`.
554
548
  *
555
- * @param value Raw attribute value.
549
+ * @param value Value to test.
556
550
  */
557
- function escapeHtmlAttribute(value) {
558
- return value.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch);
551
+ function isPlainObjectOrArray(value) {
552
+ if (Array.isArray(value)) return true;
553
+ if (value === null || typeof value !== "object") return false;
554
+ const proto = Object.getPrototypeOf(value);
555
+ return proto === Object.prototype || proto === null;
559
556
  }
560
557
  /**
561
- * Encode an Inertia page object for embedding inside a
562
- * `<script type="application/json" data-page="...">` tag — the Inertia v3
563
- * initial-page transport that the client reads via `getInitialPageFromDOM`.
564
- *
565
- * Every `<` is replaced with its JSON unicode escape so the payload cannot
566
- * terminate the surrounding `<\/script>` element or open an HTML comment. The
567
- * escape is transparent — JSON.parse decodes it back to `<`.
558
+ * Normalize a partial header list: empty entries are dropped and an empty list becomes `null`.
568
559
  *
569
- * @param page Resolved Inertia page object.
560
+ * @param list Parsed header list, or `null` when absent.
570
561
  */
571
- function encodePageScript(page) {
572
- return JSON.stringify(page).replace(/</g, "\\u003C");
562
+ function normalizeList(list) {
563
+ const entries = (list ?? []).filter((entry) => entry !== "");
564
+ return entries.length > 0 ? entries : null;
573
565
  }
574
566
  /**
575
- * Minimal default HTML shell for use when no custom root view is provided.
576
- * Real apps should plug in a template engine (pug, ejs, react-dom/server,
577
- * etc.) and embed `encodePageScript(page)` themselves.
578
- *
579
- * The initial page object is emitted as a
580
- * `<script type="application/json" data-page="...">` tag, the Inertia v3
581
- * transport that the client reads on boot.
567
+ * Default rescue hook: log the rescued error with the prop's path.
582
568
  *
583
- * @param input Page object plus optional title/head/rootId/scriptSrc/SSR markup
584
- * — see {@link DefaultRootViewInput}.
569
+ * @param error The thrown value.
570
+ * @param path Dot path of the rescued prop.
585
571
  */
586
- function renderDefaultHtml(input) {
587
- const rootId = input.rootId ?? "app";
588
- const title = input.title ?? "Inertia App";
589
- const head = input.head ?? "";
590
- const script = input.scriptSrc ? `<script type="module" src="${escapeHtmlAttribute(input.scriptSrc)}"><\/script>` : "";
591
- let rootMarkup;
592
- if (input.ssrFull) rootMarkup = input.ssrFull;
593
- else {
594
- const idAttr = escapeHtmlAttribute(rootId);
595
- rootMarkup = `<div id="${idAttr}">${input.ssrBody ?? ""}</div>\n ${`<script data-page="${idAttr}" type="application/json">${encodePageScript(input.page)}<\/script>`}`;
596
- }
597
- return `<!DOCTYPE html>
598
- <html>
599
- <head>
600
- <meta charset="utf-8" />
601
- <meta name="viewport" content="width=device-width, initial-scale=1" />
602
- <title>${escapeHtmlAttribute(title)}</title>
603
- ${head}
604
- </head>
605
- <body>
606
- ${rootMarkup}
607
- ${script}
608
- </body>
609
- </html>`;
572
+ function reportRescuedError(error, path) {
573
+ console.error(`Inertia prop '${path}' failed to resolve and was rescued.`, error);
610
574
  }
611
- /** Default user-facing messages keyed by HTTP status, used when none is given. */
612
- const ERROR_STATUS_MESSAGES = {
613
- 400: "Bad Request",
614
- 401: "Unauthorized",
615
- 403: "Forbidden",
616
- 404: "Page Not Found",
617
- 409: "Conflict",
618
- 419: "Page Expired",
619
- 422: "Unprocessable Entity",
620
- 429: "Too Many Requests",
621
- 500: "Server Error",
622
- 502: "Bad Gateway",
623
- 503: "Service Unavailable",
624
- 504: "Gateway Timeout"
625
- };
626
575
  /**
627
- * Render a minimal, SPA-less standalone HTML error page. Used as the hard
628
- * fallback when no client `Error` component can be resolved (e.g. a plain
629
- * browser load, or an SSR/render failure on the Inertia path), so a user
630
- * never receives a raw JSON error or a blank page.
576
+ * Report a failure of a custom rescue hook through the default reporter:
577
+ * the original rescued error, then the hook's own error.
631
578
  *
632
- * @param input HTTP status plus an optional override message — see {@link ErrorPageInput}.
633
- */
634
- function renderErrorPage(input) {
635
- const { status } = input;
636
- const message = input.message ?? ERROR_STATUS_MESSAGES[status] ?? "Something went wrong";
637
- const safeStatus = escapeHtmlAttribute(String(status));
638
- const safeMessage = escapeHtmlAttribute(message);
639
- return `<!DOCTYPE html>
640
- <html>
641
- <head>
642
- <meta charset="utf-8" />
643
- <meta name="viewport" content="width=device-width, initial-scale=1" />
644
- <title>${safeStatus} ${safeMessage}</title>
645
- <style>
646
- body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f8f9fa; color: #212529; }
647
- main { text-align: center; padding: 2rem; }
648
- h1 { font-size: 4rem; margin: 0; font-weight: 600; }
649
- p { font-size: 1.25rem; margin: 0.5rem 0 0; color: #6c757d; }
650
- </style>
651
- </head>
652
- <body>
653
- <main>
654
- <h1>${safeStatus}</h1>
655
- <p>${safeMessage}</p>
656
- </main>
657
- </body>
658
- </html>`;
659
- }
660
- //#endregion
661
- //#region src/core/page.ts
662
- /**
663
- * Whether flash data is worth emitting as a top-level page key. Nullish and
664
- * empty-object flash are dropped so the wire payload stays compact and
665
- * absent flash never surfaces a stray `flash: {}` to the client.
666
- *
667
- * @param flash Candidate flash value pulled from the session.
668
- */
669
- function hasFlashData(flash) {
670
- if (flash === null || flash === void 0) return false;
671
- if (typeof flash === "object" && !Array.isArray(flash)) return Object.keys(flash).length > 0;
672
- return true;
673
- }
674
- /**
675
- * Every {@link PropsMetadata} key, in protocol order. `createPage` copies only
676
- * these keys, so a widened metadata object cannot overwrite page fields.
677
- */
678
- const METADATA_KEYS = [
679
- "sharedProps",
680
- "mergeProps",
681
- "prependProps",
682
- "deepMergeProps",
683
- "matchPropsOn",
684
- "deferredProps",
685
- "rescuedProps",
686
- "scrollProps",
687
- "onceProps"
688
- ];
689
- /**
690
- * Whether a metadata value carries anything worth emitting: a non-empty array
691
- * or an object with at least one key.
692
- *
693
- * @param value Candidate metadata value from {@link PropsMetadata}.
694
- */
695
- function hasMetadataEntries(value) {
696
- if (Array.isArray(value)) return value.length > 0;
697
- if (value !== null && typeof value === "object") return Object.keys(value).length > 0;
698
- return false;
699
- }
700
- /**
701
- * Build a normalized Inertia v3 page object. `clearHistory`, `encryptHistory`
702
- * and `preserveFragment` default to `false` and are always present. Each
703
- * `metadata` key is copied onto the page only when non-empty so the JSON over
704
- * the wire stays compact. `flash` is emitted as a top-level key only when it
705
- * carries data (non-nullish, and a non-empty object when object-valued) —
706
- * mirroring inertia-laravel's `$flash ? ['flash' => $flash] : []`.
707
- *
708
- * @param input Required page fields plus the optional v3 extras.
709
- */
710
- function createPage(input) {
711
- const page = {
712
- component: input.component,
713
- props: input.props,
714
- url: input.url,
715
- version: input.version,
716
- clearHistory: input.clearHistory ?? false,
717
- encryptHistory: input.encryptHistory ?? false,
718
- preserveFragment: input.preserveFragment ?? false
719
- };
720
- const { metadata } = input;
721
- if (metadata) METADATA_KEYS.forEach((key) => {
722
- if (hasMetadataEntries(metadata[key])) Object.assign(page, { [key]: metadata[key] });
723
- });
724
- if (hasFlashData(input.flash)) page.flash = input.flash;
725
- return page;
726
- }
727
- //#endregion
728
- //#region src/core/partial-filter.ts
729
- /**
730
- * `true` when `path` equals `parent` or lies in its dot-notation subtree.
731
- *
732
- * @param path Dot-notation prop path to test.
733
- * @param parent Dot-notation path that may contain `path`.
734
- */
735
- function pathIsSameOrDescendant(path, parent) {
736
- return path === parent || path.startsWith(`${parent}.`);
737
- }
738
- /**
739
- * `true` when `path` matches an `only` entry or is a descendant of one.
740
- *
741
- * @param path Dot-notation prop path to test.
742
- * @param only Requested partial-reload `only` paths.
743
- */
744
- function matchesOnly(path, only) {
745
- return only.some((onlyPath) => pathIsSameOrDescendant(path, onlyPath));
746
- }
747
- /**
748
- * `true` when `path` is a strict ancestor of an `only` entry.
749
- *
750
- * @param path Dot-notation prop path to test.
751
- * @param only Requested partial-reload `only` paths.
752
- */
753
- function leadsToOnly(path, only) {
754
- return only.some((onlyPath) => onlyPath.startsWith(`${path}.`));
755
- }
756
- /**
757
- * `true` when `path` equals an `except` entry, is a descendant of one, or is
758
- * an ancestor of one. Matching ancestors mirrors the reference Node server,
759
- * whose flat filter drops a top-level key whenever any `except` entry lives
760
- * under it; inertia-laravel keeps the ancestor and drops only the child.
761
- *
762
- * @param path Dot-notation prop path to test.
763
- * @param except Requested partial-reload `except` paths.
764
- */
765
- function matchesExcept(path, except) {
766
- return except.some((exceptPath) => pathIsSameOrDescendant(path, exceptPath) || pathIsSameOrDescendant(exceptPath, path));
767
- }
768
- //#endregion
769
- //#region src/core/props-resolver.ts
770
- /**
771
- * `true` for arrays and for objects whose prototype is `Object.prototype` or `null`.
772
- *
773
- * @param value Value to test.
774
- */
775
- function isPlainObjectOrArray(value) {
776
- if (Array.isArray(value)) return true;
777
- if (value === null || typeof value !== "object") return false;
778
- const proto = Object.getPrototypeOf(value);
779
- return proto === Object.prototype || proto === null;
780
- }
781
- /**
782
- * Normalize a partial header list: empty entries are dropped and an empty list becomes `null`.
783
- *
784
- * @param list Parsed header list, or `null` when absent.
785
- */
786
- function normalizeList(list) {
787
- const entries = (list ?? []).filter((entry) => entry !== "");
788
- return entries.length > 0 ? entries : null;
789
- }
790
- /**
791
- * Default rescue hook: log the rescued error with the prop's path.
792
- *
793
- * @param error The thrown value.
794
- * @param path Dot path of the rescued prop.
795
- */
796
- function reportRescuedError(error, path) {
797
- console.error(`Inertia prop '${path}' failed to resolve and was rescued.`, error);
798
- }
799
- /**
800
- * Report a failure of a custom rescue hook through the default reporter:
801
- * the original rescued error, then the hook's own error.
802
- *
803
- * @param hookError The value the hook threw or rejected with.
804
- * @param error The rescued error the hook was given.
805
- * @param path Dot path of the rescued prop.
579
+ * @param hookError The value the hook threw or rejected with.
580
+ * @param error The rescued error the hook was given.
581
+ * @param path Dot path of the rescued prop.
806
582
  */
807
583
  function reportHookFailure(hookError, error, path) {
808
584
  reportRescuedError(error, path);
@@ -897,6 +673,7 @@ var PropsResolver = class {
897
673
  rescuedProps = [];
898
674
  scrollProps = Object.create(null);
899
675
  onceProps = Object.create(null);
676
+ liveProps = Object.create(null);
900
677
  used = false;
901
678
  /**
902
679
  * Create a resolver for one request and component.
@@ -921,8 +698,7 @@ var PropsResolver = class {
921
698
  * @param props Page props; these win over shared props on collision.
922
699
  */
923
700
  async resolve(shared, props) {
924
- if (this.used) throw new Error("PropsResolver.resolve() was already called on this instance; create one resolver per response.");
925
- this.used = true;
701
+ this.markUsed();
926
702
  const combined = {
927
703
  ...this.resolveSharedProps(shared),
928
704
  ...props
@@ -933,6 +709,55 @@ var PropsResolver = class {
933
709
  };
934
710
  }
935
711
  /**
712
+ * Resolve props for a socket broadcast payload: every prop is resolved, with
713
+ * no partial filtering, once/defer exclusion, metadata or dotted-key
714
+ * unpacking. Siblings resolve concurrently and keep insertion order; rescued
715
+ * props are reported and omitted; plain objects and arrays are recursed into.
716
+ *
717
+ * @param props Props to resolve.
718
+ */
719
+ async resolveBroadcast(props) {
720
+ this.markUsed();
721
+ return await this.broadcastNode(props, "");
722
+ }
723
+ /**
724
+ * Fail when this resolver has already been used; otherwise mark it used.
725
+ */
726
+ markUsed() {
727
+ if (this.used) throw new Error("PropsResolver.resolve() was already called on this instance; create one resolver per response.");
728
+ this.used = true;
729
+ }
730
+ /**
731
+ * Resolve one level of a broadcast payload.
732
+ *
733
+ * @param node Plain object or array whose entries are resolved.
734
+ * @param prefix Dot path of the node, empty at the root.
735
+ */
736
+ async broadcastNode(node, prefix) {
737
+ const keys = Object.keys(node);
738
+ const values = await Promise.all(keys.map(async (key) => this.broadcastEntry(node[key], prefix === "" ? key : `${prefix}.${key}`)));
739
+ const result = Array.isArray(node) ? [] : {};
740
+ keys.forEach((key, index) => {
741
+ writeEntry(result, key, values[index]);
742
+ });
743
+ return result;
744
+ }
745
+ /**
746
+ * Resolve a single broadcast entry, unwrapping one level of returned descriptor.
747
+ *
748
+ * @param prop The entry's raw value.
749
+ * @param path Dot path of the entry.
750
+ */
751
+ async broadcastEntry(prop, path) {
752
+ let value = await this.resolveValue(prop);
753
+ if (isRescued(value)) return this.rescue(value.error, path);
754
+ if (value !== prop && this.isPropType(value)) {
755
+ value = await this.resolveValue(value);
756
+ if (isRescued(value)) return this.rescue(value.error, path);
757
+ }
758
+ return isPlainObjectOrArray(value) ? this.broadcastNode(value, path) : value;
759
+ }
760
+ /**
936
761
  * Record the top-level keys of shared props (the segment before the first
937
762
  * `.` for dot-notation keys), deduplicated in first-seen order.
938
763
  *
@@ -958,7 +783,8 @@ var PropsResolver = class {
958
783
  ["deferredProps", this.deferredProps],
959
784
  ["rescuedProps", this.rescuedProps],
960
785
  ["scrollProps", this.scrollProps],
961
- ["onceProps", this.onceProps]
786
+ ["onceProps", this.onceProps],
787
+ ["liveProps", this.liveProps]
962
788
  ];
963
789
  return Object.fromEntries(entries.filter(([, value]) => Object.keys(value).length > 0));
964
790
  }
@@ -1112,7 +938,7 @@ var PropsResolver = class {
1112
938
  */
1113
939
  include(prop, value, path, parentWasResolved) {
1114
940
  this.assertScrollShape(prop, path, value);
1115
- this.collectMetadata(prop, path, value);
941
+ this.collectMetadata(prop, path, value, parentWasResolved);
1116
942
  if (isPlainObjectOrArray(value)) return this.walk(value, path, parentWasResolved || !isPlainObjectOrArray(prop));
1117
943
  return value;
1118
944
  }
@@ -1168,6 +994,7 @@ var PropsResolver = class {
1168
994
  * @param path Dot path of the prop.
1169
995
  */
1170
996
  collectExclusionMetadata(prop, path) {
997
+ if (prop.isLive) this.collectLiveMetadata(path, prop, false);
1171
998
  if (prop.ignoreFirstLoad) this.collectIgnoredPropMetadata(prop, path);
1172
999
  else if (prop.defers) this.collectDeferredExclusionMetadata(prop, path);
1173
1000
  else this.collectOnceMetadata(path, prop);
@@ -1279,12 +1106,29 @@ var PropsResolver = class {
1279
1106
  * @param prop The prop being included.
1280
1107
  * @param path Dot path of the prop.
1281
1108
  * @param resolved The prop's resolved value.
1109
+ * @param parentWasResolved Whether an ancestor was produced by resolving a closure or descriptor.
1282
1110
  */
1283
- collectMetadata(prop, path, resolved) {
1111
+ collectMetadata(prop, path, resolved, parentWasResolved) {
1284
1112
  if (!isPropDescriptor(prop)) return;
1285
1113
  if (prop.merges) this.collectMergeableMetadata(path, prop, false);
1286
1114
  if (prop.isScroll) this.collectScrollMetadata(path, prop, resolved);
1287
1115
  if (prop.resolvesOnce) this.collectOnceMetadata(path, prop);
1116
+ if (prop.isLive) this.collectLiveMetadata(path, prop, parentWasResolved);
1117
+ }
1118
+ /**
1119
+ * Collect the listener manifest of a live prop, unless a partial request
1120
+ * filters it out. Listener errors are not rescued.
1121
+ *
1122
+ * @param path Dot path of the prop.
1123
+ * @param prop The live descriptor.
1124
+ * @param parentWasResolved Whether an ancestor was produced by resolving a closure or descriptor.
1125
+ */
1126
+ collectLiveMetadata(path, prop, parentWasResolved) {
1127
+ if (this.isPartial && !this.bypassesPartialFiltering(prop, parentWasResolved) && !this.isIncludedInPartialMetadata(path)) return;
1128
+ this.liveProps[path] = {
1129
+ listeners: prop.liveListeners(),
1130
+ ...prop.liveThrottle !== null ? { throttle: prop.liveThrottle } : {}
1131
+ };
1288
1132
  }
1289
1133
  /**
1290
1134
  * Collect the deferred prop under its group.
@@ -1393,6 +1237,404 @@ async function resolveProps(input) {
1393
1237
  return new PropsResolver(input.request, input.component, input.onRescue).resolve(input.shared, input.props);
1394
1238
  }
1395
1239
  //#endregion
1240
+ //#region src/core/broadcast.ts
1241
+ /**
1242
+ * Request info for a broadcast: not an Inertia request, nothing partial, nothing already loaded.
1243
+ */
1244
+ const EMPTY_REQUEST = Object.freeze({
1245
+ isInertia: false,
1246
+ version: null,
1247
+ partialComponent: null,
1248
+ partialOnly: null,
1249
+ partialExcept: null,
1250
+ errorBag: null,
1251
+ resetKeys: null,
1252
+ exceptOnceProps: [],
1253
+ mergeIntent: "append",
1254
+ isPrefetch: false,
1255
+ socketId: null,
1256
+ method: "GET",
1257
+ url: "/"
1258
+ });
1259
+ /**
1260
+ * Resolve props for a socket broadcast payload. Every prop is resolved (no
1261
+ * partial filtering, no once/defer exclusion) and rescued props are omitted.
1262
+ *
1263
+ * @param props Props to resolve.
1264
+ * @param onRescue Hook called when a rescued prop throws.
1265
+ */
1266
+ async function resolveBroadcastProps(props, onRescue) {
1267
+ return new PropsResolver({
1268
+ ...EMPTY_REQUEST,
1269
+ exceptOnceProps: []
1270
+ }, "", onRescue).resolveBroadcast(props);
1271
+ }
1272
+ /**
1273
+ * Build the `{ __inertia: { props } }` envelope a socket server broadcasts.
1274
+ *
1275
+ * @param props Props to resolve.
1276
+ * @param onRescue Hook called when a rescued prop throws.
1277
+ */
1278
+ async function broadcastProps(props, onRescue) {
1279
+ return { __inertia: { props: await resolveBroadcastProps(props, onRescue) } };
1280
+ }
1281
+ //#endregion
1282
+ //#region src/core/cache.ts
1283
+ /**
1284
+ * Recursively normalize a value so equal-by-value inputs serialize identically:
1285
+ * object keys are sorted, `undefined` properties are dropped, and non-finite
1286
+ * numbers collapse to `null`.
1287
+ *
1288
+ * @param value Arbitrary JSON-shaped input.
1289
+ */
1290
+ function canonicalize(value) {
1291
+ if (value === null) return null;
1292
+ if (typeof value !== "object") {
1293
+ if (typeof value === "number" && !Number.isFinite(value)) return null;
1294
+ return value;
1295
+ }
1296
+ if (Array.isArray(value)) return value.map(canonicalize);
1297
+ const obj = value;
1298
+ return Object.fromEntries(Object.keys(obj).sort().filter((key) => obj[key] !== void 0).map((key) => [key, canonicalize(obj[key])]));
1299
+ }
1300
+ /**
1301
+ * Stable JSON encoding: keys sorted recursively, undefined dropped, NaN/Inf
1302
+ * normalized. Two equal-by-value objects always produce the same string,
1303
+ * so they hash to the same cache key.
1304
+ *
1305
+ * @param value Arbitrary JSON-shaped input.
1306
+ */
1307
+ function canonicalStringify(value) {
1308
+ return JSON.stringify(canonicalize(value));
1309
+ }
1310
+ /**
1311
+ * Build the cache key for an SSR cache entry.
1312
+ *
1313
+ * Shape: `${prefix}:${version}:${component}:${sha256(props[+discriminator])}`
1314
+ *
1315
+ * Including `version` in the key namespace means a deploy that bumps the
1316
+ * asset version atomically retires every old key — no flush, no race.
1317
+ *
1318
+ * @param input Prefix, version, component, resolved props, optional discriminator.
1319
+ */
1320
+ function computeSsrCacheKey(input) {
1321
+ const versionPart = input.version ?? "_";
1322
+ const propsHash = createHash("sha256").update(canonicalStringify(input.props)).update(input.discriminator ?? "").digest("hex").slice(0, 32);
1323
+ return `${input.prefix}:${versionPart}:${input.component}:${propsHash}`;
1324
+ }
1325
+ //#endregion
1326
+ //#region src/core/errors.ts
1327
+ /**
1328
+ * Apply error-bag scoping to a flat errors object, matching the official
1329
+ * Inertia behavior. When `bag` is set, errors are namespaced under
1330
+ * `props.errors[bag]`. When `bag` is null, errors live at `props.errors`.
1331
+ *
1332
+ * @param errors Flat field-to-message map.
1333
+ * @param bag Optional error-bag name (e.g. `"createUser"`); null for top-level.
1334
+ */
1335
+ function scopeErrors(errors, bag) {
1336
+ if (bag) return { errors: { [bag]: errors } };
1337
+ return { errors };
1338
+ }
1339
+ /**
1340
+ * Resolve the `errors` prop from validation error bags at read time. Port of
1341
+ * inertia-laravel `Middleware::resolveValidationErrors` (3.x).
1342
+ *
1343
+ * - No bags, or an empty map: `{}`.
1344
+ * - A `default` bag and an `errorBag` sent by the client: scoped to that name,
1345
+ * `{ [errorBag]: bags.default }`.
1346
+ * - A `default` bag with no `errorBag`: the default bag flat, `bags.default`.
1347
+ * - No `default` bag: the bags map as-is, each key already a bag name.
1348
+ *
1349
+ * @param bags Error bags keyed by name, or null/undefined when none exist.
1350
+ * @param errorBag Bag name from the `X-Inertia-Error-Bag` request header, or null.
1351
+ */
1352
+ function resolveValidationErrors(bags, errorBag) {
1353
+ if (!bags || Object.keys(bags).length === 0) return {};
1354
+ const defaultBag = bags.default;
1355
+ if (defaultBag !== void 0) {
1356
+ if (errorBag) return { [errorBag]: defaultBag };
1357
+ return defaultBag;
1358
+ }
1359
+ return bags;
1360
+ }
1361
+ //#endregion
1362
+ //#region src/core/headers.ts
1363
+ /**
1364
+ * Canonical Inertia v3 protocol header names.
1365
+ *
1366
+ * The Inertia client sends these headers on every visit. The server uses
1367
+ * them to decide between a full HTML response and a JSON page response,
1368
+ * and to scope partial reloads.
1369
+ */
1370
+ const INERTIA_HEADERS = {
1371
+ inertia: "x-inertia",
1372
+ version: "x-inertia-version",
1373
+ partialComponent: "x-inertia-partial-component",
1374
+ partialData: "x-inertia-partial-data",
1375
+ partialExcept: "x-inertia-partial-except",
1376
+ errorBag: "x-inertia-error-bag",
1377
+ reset: "x-inertia-reset",
1378
+ location: "x-inertia-location",
1379
+ exceptOnceProps: "x-inertia-except-once-props",
1380
+ mergeIntent: "x-inertia-infinite-scroll-merge-intent",
1381
+ redirect: "x-inertia-redirect",
1382
+ socketId: "x-socket-id"
1383
+ };
1384
+ /**
1385
+ * Headers browsers set on speculative (prefetch) requests. A request is a
1386
+ * prefetch when any of them equals `prefetch` (case-insensitive). Mirrors
1387
+ * Laravel's `Request::prefetch()`.
1388
+ */
1389
+ const PREFETCH_HEADERS = [
1390
+ "purpose",
1391
+ "sec-purpose",
1392
+ "x-moz"
1393
+ ];
1394
+ /**
1395
+ * Read a header value as a single string, looking up the lowercase variant as
1396
+ * a fallback. Returns the first entry of an array value and `null` when the
1397
+ * header is absent.
1398
+ *
1399
+ * @param headers Raw header record from the HTTP layer.
1400
+ * @param name Header name to read.
1401
+ */
1402
+ function readHeader(headers, name) {
1403
+ const value = headers[name] ?? headers[name.toLowerCase()];
1404
+ if (value === void 0 || value === null) return null;
1405
+ if (Array.isArray(value)) return value[0] ?? null;
1406
+ return value;
1407
+ }
1408
+ /**
1409
+ * Split a comma-separated header value into a trimmed, non-empty string list.
1410
+ * Returns `null` when the input is null or yields no entries.
1411
+ *
1412
+ * @param value Raw header value, or null when absent.
1413
+ */
1414
+ function parseList(value) {
1415
+ if (value === null) return null;
1416
+ const parts = value.split(",").map((part) => part.trim()).filter((part) => part.length > 0);
1417
+ return parts.length > 0 ? parts : null;
1418
+ }
1419
+ /**
1420
+ * Parse a request's headers into a normalized {@link InertiaRequestInfo}.
1421
+ *
1422
+ * @param input Headers, HTTP method, and request URL. Method is uppercased in
1423
+ * the returned object; the URL is passed through unchanged.
1424
+ */
1425
+ function parseInertiaRequest(input) {
1426
+ const { headers } = input;
1427
+ const inertia = readHeader(headers, INERTIA_HEADERS.inertia);
1428
+ const version = readHeader(headers, INERTIA_HEADERS.version);
1429
+ const partialComponent = readHeader(headers, INERTIA_HEADERS.partialComponent);
1430
+ const partialOnly = parseList(readHeader(headers, INERTIA_HEADERS.partialData));
1431
+ const partialExcept = parseList(readHeader(headers, INERTIA_HEADERS.partialExcept));
1432
+ const errorBag = readHeader(headers, INERTIA_HEADERS.errorBag);
1433
+ const resetKeys = parseList(readHeader(headers, INERTIA_HEADERS.reset));
1434
+ const exceptOnceProps = parseList(readHeader(headers, INERTIA_HEADERS.exceptOnceProps)) ?? [];
1435
+ const mergeIntent = readHeader(headers, INERTIA_HEADERS.mergeIntent) === "prepend" ? "prepend" : "append";
1436
+ return {
1437
+ isInertia: inertia === "true" || inertia === "1",
1438
+ version,
1439
+ partialComponent,
1440
+ partialOnly,
1441
+ partialExcept,
1442
+ errorBag,
1443
+ resetKeys,
1444
+ exceptOnceProps,
1445
+ mergeIntent,
1446
+ isPrefetch: PREFETCH_HEADERS.some((name) => readHeader(headers, name)?.toLowerCase() === "prefetch"),
1447
+ socketId: readHeader(headers, INERTIA_HEADERS.socketId),
1448
+ method: input.method.toUpperCase(),
1449
+ url: input.url
1450
+ };
1451
+ }
1452
+ //#endregion
1453
+ //#region src/core/html.ts
1454
+ const HTML_ESCAPES = {
1455
+ "&": "&amp;",
1456
+ "<": "&lt;",
1457
+ ">": "&gt;",
1458
+ "\"": "&quot;",
1459
+ "'": "&#39;"
1460
+ };
1461
+ /**
1462
+ * Escape characters that would break out of a double-quoted HTML attribute.
1463
+ *
1464
+ * @param value Raw attribute value.
1465
+ */
1466
+ function escapeHtmlAttribute(value) {
1467
+ return value.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch);
1468
+ }
1469
+ /**
1470
+ * Encode an Inertia page object for embedding inside a
1471
+ * `<script type="application/json" data-page="...">` tag — the Inertia v3
1472
+ * initial-page transport that the client reads via `getInitialPageFromDOM`.
1473
+ *
1474
+ * Every `<` is replaced with its JSON unicode escape so the payload cannot
1475
+ * terminate the surrounding `<\/script>` element or open an HTML comment. The
1476
+ * escape is transparent — JSON.parse decodes it back to `<`.
1477
+ *
1478
+ * @param page Resolved Inertia page object.
1479
+ */
1480
+ function encodePageScript(page) {
1481
+ return JSON.stringify(page).replace(/</g, "\\u003C");
1482
+ }
1483
+ /**
1484
+ * Minimal default HTML shell for use when no custom root view is provided.
1485
+ * Real apps should plug in a template engine (pug, ejs, react-dom/server,
1486
+ * etc.) and embed `encodePageScript(page)` themselves.
1487
+ *
1488
+ * The initial page object is emitted as a
1489
+ * `<script type="application/json" data-page="...">` tag, the Inertia v3
1490
+ * transport that the client reads on boot.
1491
+ *
1492
+ * @param input Page object plus optional title/head/rootId/scriptSrc/SSR markup
1493
+ * — see {@link DefaultRootViewInput}.
1494
+ */
1495
+ function renderDefaultHtml(input) {
1496
+ const rootId = input.rootId ?? "app";
1497
+ const title = input.title ?? "Inertia App";
1498
+ const head = input.head ?? "";
1499
+ const script = input.scriptSrc ? `<script type="module" src="${escapeHtmlAttribute(input.scriptSrc)}"><\/script>` : "";
1500
+ let rootMarkup;
1501
+ if (input.ssrFull) rootMarkup = input.ssrFull;
1502
+ else {
1503
+ const idAttr = escapeHtmlAttribute(rootId);
1504
+ rootMarkup = `<div id="${idAttr}">${input.ssrBody ?? ""}</div>\n ${`<script data-page="${idAttr}" type="application/json">${encodePageScript(input.page)}<\/script>`}`;
1505
+ }
1506
+ return `<!DOCTYPE html>
1507
+ <html>
1508
+ <head>
1509
+ <meta charset="utf-8" />
1510
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1511
+ <title>${escapeHtmlAttribute(title)}</title>
1512
+ ${head}
1513
+ </head>
1514
+ <body>
1515
+ ${rootMarkup}
1516
+ ${script}
1517
+ </body>
1518
+ </html>`;
1519
+ }
1520
+ /** Default user-facing messages keyed by HTTP status, used when none is given. */
1521
+ const ERROR_STATUS_MESSAGES = {
1522
+ 400: "Bad Request",
1523
+ 401: "Unauthorized",
1524
+ 403: "Forbidden",
1525
+ 404: "Page Not Found",
1526
+ 409: "Conflict",
1527
+ 419: "Page Expired",
1528
+ 422: "Unprocessable Entity",
1529
+ 429: "Too Many Requests",
1530
+ 500: "Server Error",
1531
+ 502: "Bad Gateway",
1532
+ 503: "Service Unavailable",
1533
+ 504: "Gateway Timeout"
1534
+ };
1535
+ /**
1536
+ * Render a minimal, SPA-less standalone HTML error page. Used as the hard
1537
+ * fallback when no client `Error` component can be resolved (e.g. a plain
1538
+ * browser load, or an SSR/render failure on the Inertia path), so a user
1539
+ * never receives a raw JSON error or a blank page.
1540
+ *
1541
+ * @param input HTTP status plus an optional override message — see {@link ErrorPageInput}.
1542
+ */
1543
+ function renderErrorPage(input) {
1544
+ const { status } = input;
1545
+ const message = input.message ?? ERROR_STATUS_MESSAGES[status] ?? "Something went wrong";
1546
+ const safeStatus = escapeHtmlAttribute(String(status));
1547
+ const safeMessage = escapeHtmlAttribute(message);
1548
+ return `<!DOCTYPE html>
1549
+ <html>
1550
+ <head>
1551
+ <meta charset="utf-8" />
1552
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1553
+ <title>${safeStatus} ${safeMessage}</title>
1554
+ <style>
1555
+ body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f8f9fa; color: #212529; }
1556
+ main { text-align: center; padding: 2rem; }
1557
+ h1 { font-size: 4rem; margin: 0; font-weight: 600; }
1558
+ p { font-size: 1.25rem; margin: 0.5rem 0 0; color: #6c757d; }
1559
+ </style>
1560
+ </head>
1561
+ <body>
1562
+ <main>
1563
+ <h1>${safeStatus}</h1>
1564
+ <p>${safeMessage}</p>
1565
+ </main>
1566
+ </body>
1567
+ </html>`;
1568
+ }
1569
+ //#endregion
1570
+ //#region src/core/page.ts
1571
+ /**
1572
+ * Whether flash data is worth emitting as a top-level page key. Nullish and
1573
+ * empty-object flash are dropped so the wire payload stays compact and
1574
+ * absent flash never surfaces a stray `flash: {}` to the client.
1575
+ *
1576
+ * @param flash Candidate flash value pulled from the session.
1577
+ */
1578
+ function hasFlashData(flash) {
1579
+ if (flash === null || flash === void 0) return false;
1580
+ if (typeof flash === "object" && !Array.isArray(flash)) return Object.keys(flash).length > 0;
1581
+ return true;
1582
+ }
1583
+ /**
1584
+ * Every {@link PropsMetadata} key, in protocol order. `createPage` copies only
1585
+ * these keys, so a widened metadata object cannot overwrite page fields.
1586
+ */
1587
+ const METADATA_KEYS = [
1588
+ "sharedProps",
1589
+ "mergeProps",
1590
+ "prependProps",
1591
+ "deepMergeProps",
1592
+ "matchPropsOn",
1593
+ "deferredProps",
1594
+ "rescuedProps",
1595
+ "scrollProps",
1596
+ "onceProps",
1597
+ "liveProps"
1598
+ ];
1599
+ /**
1600
+ * Whether a metadata value carries anything worth emitting: a non-empty array
1601
+ * or an object with at least one key.
1602
+ *
1603
+ * @param value Candidate metadata value from {@link PropsMetadata}.
1604
+ */
1605
+ function hasMetadataEntries(value) {
1606
+ if (Array.isArray(value)) return value.length > 0;
1607
+ if (value !== null && typeof value === "object") return Object.keys(value).length > 0;
1608
+ return false;
1609
+ }
1610
+ /**
1611
+ * Build a normalized Inertia v3 page object. `clearHistory`, `encryptHistory`
1612
+ * and `preserveFragment` default to `false` and are always present. Each
1613
+ * `metadata` key is copied onto the page only when non-empty so the JSON over
1614
+ * the wire stays compact. `flash` is emitted as a top-level key only when it
1615
+ * carries data (non-nullish, and a non-empty object when object-valued) —
1616
+ * mirroring inertia-laravel's `$flash ? ['flash' => $flash] : []`.
1617
+ *
1618
+ * @param input Required page fields plus the optional v3 extras.
1619
+ */
1620
+ function createPage(input) {
1621
+ const page = {
1622
+ component: input.component,
1623
+ props: input.props,
1624
+ url: input.url,
1625
+ version: input.version,
1626
+ clearHistory: input.clearHistory ?? false,
1627
+ encryptHistory: input.encryptHistory ?? false,
1628
+ preserveFragment: input.preserveFragment ?? false
1629
+ };
1630
+ const { metadata } = input;
1631
+ if (metadata) METADATA_KEYS.forEach((key) => {
1632
+ if (hasMetadataEntries(metadata[key])) Object.assign(page, { [key]: metadata[key] });
1633
+ });
1634
+ if (hasFlashData(input.flash)) page.flash = input.flash;
1635
+ return page;
1636
+ }
1637
+ //#endregion
1396
1638
  //#region src/core/redirects.ts
1397
1639
  /**
1398
1640
  * Status code Inertia uses for "soft" redirects after PUT/PATCH/DELETE so the
@@ -1407,7 +1649,7 @@ const INERTIA_REDIRECT_STATUS = 303;
1407
1649
  const INERTIA_LOCATION_STATUS = 409;
1408
1650
  /**
1409
1651
  * Decide which status code to use for a redirect produced by an Inertia
1410
- * request. After a non-GET/HEAD form submission the response must be 303
1652
+ * request. After a non-GET/HEAD form submission (PUT, PATCH, DELETE or QUERY) the response must be 303
1411
1653
  * so the client's follow-up request becomes a GET.
1412
1654
  *
1413
1655
  * @param method HTTP method of the inbound request (case-insensitive).
@@ -1415,7 +1657,7 @@ const INERTIA_LOCATION_STATUS = 409;
1415
1657
  */
1416
1658
  function inertiaRedirectStatus(method, fallback = 302) {
1417
1659
  const m = method.toUpperCase();
1418
- if (m === "PUT" || m === "PATCH" || m === "DELETE") return 303;
1660
+ if (m === "PUT" || m === "PATCH" || m === "DELETE" || m === "QUERY") return 303;
1419
1661
  return fallback;
1420
1662
  }
1421
1663
  //#endregion
@@ -1494,6 +1736,6 @@ function isVersionMismatch(serverVersion, clientVersion) {
1494
1736
  return serverVersion !== (clientVersion ?? "");
1495
1737
  }
1496
1738
  //#endregion
1497
- export { canonicalStringify as A, defer as C, optional as D, once as E, scroll as O, deepMerge as S, merge as T, INERTIA_HEADERS as _, INERTIA_REDIRECT_STATUS as a, unpackDotProps as b, resolveProps as c, matchesOnly as d, pathIsSameOrDescendant as f, renderErrorPage as g, renderDefaultHtml as h, INERTIA_LOCATION_STATUS as i, computeSsrCacheKey as j, PropDescriptor as k, leadsToOnly as l, encodePageScript as m, resolveVersion as n, inertiaRedirectStatus as o, createPage as p, buildPage as r, PropsResolver as s, isVersionMismatch as t, matchesExcept as u, parseInertiaRequest as v, isPropDescriptor as w, always as x, scopeErrors as y };
1739
+ export { isPropDescriptor as A, matchesExcept as C, always as D, unpackDotProps as E, scroll as F, PropDescriptor as I, merge as M, once as N, deepMerge as O, optional as P, leadsToOnly as S, pathIsSameOrDescendant as T, computeSsrCacheKey as _, INERTIA_REDIRECT_STATUS as a, PropsResolver as b, encodePageScript as c, INERTIA_HEADERS as d, PREFETCH_HEADERS as f, canonicalStringify as g, scopeErrors as h, INERTIA_LOCATION_STATUS as i, live as j, defer as k, renderDefaultHtml as l, resolveValidationErrors as m, resolveVersion as n, inertiaRedirectStatus as o, parseInertiaRequest as p, buildPage as r, createPage as s, isVersionMismatch as t, renderErrorPage as u, broadcastProps as v, matchesOnly as w, resolveProps as x, resolveBroadcastProps as y };
1498
1740
 
1499
- //# sourceMappingURL=core-ChQbBw-k.js.map
1741
+ //# sourceMappingURL=core-PN_yVSCS.js.map