@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.
- package/README.md +60 -20
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.js +2 -2
- package/dist/{core-ChQbBw-k.js → core-PN_yVSCS.js} +612 -370
- package/dist/core-PN_yVSCS.js.map +1 -0
- package/dist/express/index.d.ts +57 -12
- package/dist/express/index.d.ts.map +1 -1
- package/dist/express/index.js +127 -35
- package/dist/express/index.js.map +1 -1
- package/dist/{index-PWaujyMo.d.ts → index-C7vBpEDY.d.ts} +201 -8
- package/dist/index-C7vBpEDY.d.ts.map +1 -0
- package/package.json +5 -1
- package/dist/core-ChQbBw-k.js.map +0 -1
- package/dist/index-PWaujyMo.d.ts.map +0 -1
|
@@ -1,49 +1,39 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
//#region src/core/
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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
|
|
28
|
-
|
|
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
|
-
*
|
|
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
|
|
23
|
+
* @param input Bare channel name or explicit channel.
|
|
39
24
|
*/
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
*
|
|
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/
|
|
504
|
+
//#region src/core/partial-filter.ts
|
|
454
505
|
/**
|
|
455
|
-
*
|
|
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
|
|
460
|
-
* @param
|
|
508
|
+
* @param path Dot-notation prop path to test.
|
|
509
|
+
* @param parent Dot-notation path that may contain `path`.
|
|
461
510
|
*/
|
|
462
|
-
function
|
|
463
|
-
|
|
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
|
-
*
|
|
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
|
|
493
|
-
* @param
|
|
517
|
+
* @param path Dot-notation prop path to test.
|
|
518
|
+
* @param only Requested partial-reload `only` paths.
|
|
494
519
|
*/
|
|
495
|
-
function
|
|
496
|
-
|
|
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
|
-
*
|
|
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
|
|
526
|
+
* @param path Dot-notation prop path to test.
|
|
527
|
+
* @param only Requested partial-reload `only` paths.
|
|
506
528
|
*/
|
|
507
|
-
function
|
|
508
|
-
|
|
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
|
-
*
|
|
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
|
|
516
|
-
*
|
|
538
|
+
* @param path Dot-notation prop path to test.
|
|
539
|
+
* @param except Requested partial-reload `except` paths.
|
|
517
540
|
*/
|
|
518
|
-
function
|
|
519
|
-
|
|
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/
|
|
545
|
-
const HTML_ESCAPES = {
|
|
546
|
-
"&": "&",
|
|
547
|
-
"<": "<",
|
|
548
|
-
">": ">",
|
|
549
|
-
"\"": """,
|
|
550
|
-
"'": "'"
|
|
551
|
-
};
|
|
545
|
+
//#region src/core/props-resolver.ts
|
|
552
546
|
/**
|
|
553
|
-
*
|
|
547
|
+
* `true` for arrays and for objects whose prototype is `Object.prototype` or `null`.
|
|
554
548
|
*
|
|
555
|
-
* @param value
|
|
549
|
+
* @param value Value to test.
|
|
556
550
|
*/
|
|
557
|
-
function
|
|
558
|
-
|
|
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
|
-
*
|
|
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
|
|
560
|
+
* @param list Parsed header list, or `null` when absent.
|
|
570
561
|
*/
|
|
571
|
-
function
|
|
572
|
-
|
|
562
|
+
function normalizeList(list) {
|
|
563
|
+
const entries = (list ?? []).filter((entry) => entry !== "");
|
|
564
|
+
return entries.length > 0 ? entries : null;
|
|
573
565
|
}
|
|
574
566
|
/**
|
|
575
|
-
*
|
|
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
|
|
584
|
-
*
|
|
569
|
+
* @param error The thrown value.
|
|
570
|
+
* @param path Dot path of the rescued prop.
|
|
585
571
|
*/
|
|
586
|
-
function
|
|
587
|
-
|
|
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
|
-
*
|
|
628
|
-
*
|
|
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
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
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
|
+
"&": "&",
|
|
1456
|
+
"<": "<",
|
|
1457
|
+
">": ">",
|
|
1458
|
+
"\"": """,
|
|
1459
|
+
"'": "'"
|
|
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 {
|
|
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-
|
|
1741
|
+
//# sourceMappingURL=core-PN_yVSCS.js.map
|