@blacklizard/inertia-express 0.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.
@@ -0,0 +1,597 @@
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
+ }
20
+ /**
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.
26
+ */
27
+ function canonicalStringify(value) {
28
+ return JSON.stringify(canonicalize(value));
29
+ }
30
+ /**
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.
37
+ *
38
+ * @param input Prefix, version, component, resolved props, optional discriminator.
39
+ */
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}`;
44
+ }
45
+ //#endregion
46
+ //#region src/core/errors.ts
47
+ /**
48
+ * Apply error-bag scoping to a flat errors object, matching the official
49
+ * Inertia behavior. When `bag` is set, errors are namespaced under
50
+ * `props.errors[bag]`. When `bag` is null, errors live at `props.errors`.
51
+ *
52
+ * @param errors Flat field-to-message map.
53
+ * @param bag Optional error-bag name (e.g. `"createUser"`); null for top-level.
54
+ */
55
+ function scopeErrors(errors, bag) {
56
+ if (bag) return { errors: { [bag]: errors } };
57
+ return { errors };
58
+ }
59
+ //#endregion
60
+ //#region src/core/headers.ts
61
+ /**
62
+ * Canonical Inertia v3 protocol header names.
63
+ *
64
+ * The Inertia client sends these headers on every visit. The server uses
65
+ * them to decide between a full HTML response and a JSON page response,
66
+ * and to scope partial reloads.
67
+ */
68
+ const INERTIA_HEADERS = {
69
+ inertia: "x-inertia",
70
+ version: "x-inertia-version",
71
+ partialComponent: "x-inertia-partial-component",
72
+ partialData: "x-inertia-partial-data",
73
+ partialExcept: "x-inertia-partial-except",
74
+ errorBag: "x-inertia-error-bag",
75
+ reset: "x-inertia-reset",
76
+ location: "x-inertia-location"
77
+ };
78
+ /**
79
+ * Read a header value as a single string, looking up the lowercase variant as
80
+ * a fallback. Returns the first entry of an array value and `null` when the
81
+ * header is absent.
82
+ *
83
+ * @param headers Raw header record from the HTTP layer.
84
+ * @param name Header name to read.
85
+ */
86
+ function readHeader(headers, name) {
87
+ const value = headers[name] ?? headers[name.toLowerCase()];
88
+ if (value === void 0 || value === null) return null;
89
+ if (Array.isArray(value)) return value[0] ?? null;
90
+ return value;
91
+ }
92
+ /**
93
+ * Split a comma-separated header value into a trimmed, non-empty string list.
94
+ * Returns `null` when the input is null or yields no entries.
95
+ *
96
+ * @param value Raw header value, or null when absent.
97
+ */
98
+ function parseList(value) {
99
+ if (value === null) return null;
100
+ const parts = value.split(",").map((part) => part.trim()).filter((part) => part.length > 0);
101
+ return parts.length > 0 ? parts : null;
102
+ }
103
+ /**
104
+ * Parse a request's headers into a normalized {@link InertiaRequestInfo}.
105
+ *
106
+ * @param input Headers, HTTP method, and request URL. Method is uppercased in
107
+ * the returned object; the URL is passed through unchanged.
108
+ */
109
+ function parseInertiaRequest(input) {
110
+ const { headers } = input;
111
+ const inertia = readHeader(headers, INERTIA_HEADERS.inertia);
112
+ const version = readHeader(headers, INERTIA_HEADERS.version);
113
+ const partialComponent = readHeader(headers, INERTIA_HEADERS.partialComponent);
114
+ const partialOnly = parseList(readHeader(headers, INERTIA_HEADERS.partialData));
115
+ const partialExcept = parseList(readHeader(headers, INERTIA_HEADERS.partialExcept));
116
+ const errorBag = readHeader(headers, INERTIA_HEADERS.errorBag);
117
+ const resetKeys = parseList(readHeader(headers, INERTIA_HEADERS.reset));
118
+ return {
119
+ isInertia: inertia === "true" || inertia === "1",
120
+ version,
121
+ partialComponent,
122
+ partialOnly,
123
+ partialExcept,
124
+ errorBag,
125
+ resetKeys,
126
+ method: input.method.toUpperCase(),
127
+ url: input.url
128
+ };
129
+ }
130
+ //#endregion
131
+ //#region src/core/html.ts
132
+ const HTML_ESCAPES = {
133
+ "&": "&",
134
+ "<": "&lt;",
135
+ ">": "&gt;",
136
+ "\"": "&quot;",
137
+ "'": "&#39;"
138
+ };
139
+ /**
140
+ * Escape characters that would break out of a double-quoted HTML attribute.
141
+ *
142
+ * @param value Raw attribute value.
143
+ */
144
+ function escapeHtmlAttribute(value) {
145
+ return value.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch);
146
+ }
147
+ /**
148
+ * Encode an Inertia page object for embedding inside a
149
+ * `<script type="application/json" data-page="...">` tag — the Inertia v3
150
+ * initial-page transport that the client reads via `getInitialPageFromDOM`.
151
+ *
152
+ * Every `<` is replaced with its JSON unicode escape so the payload cannot
153
+ * terminate the surrounding `<\/script>` element or open an HTML comment. The
154
+ * escape is transparent — JSON.parse decodes it back to `<`.
155
+ *
156
+ * @param page Resolved Inertia page object.
157
+ */
158
+ function encodePageScript(page) {
159
+ return JSON.stringify(page).replace(/</g, "\\u003C");
160
+ }
161
+ /**
162
+ * Minimal default HTML shell for use when no custom root view is provided.
163
+ * Real apps should plug in a template engine (pug, ejs, react-dom/server,
164
+ * etc.) and embed `encodePageScript(page)` themselves.
165
+ *
166
+ * The initial page object is emitted as a
167
+ * `<script type="application/json" data-page="...">` tag, the Inertia v3
168
+ * transport that the client reads on boot.
169
+ *
170
+ * @param input Page object plus optional title/head/rootId/scriptSrc/SSR markup
171
+ * — see {@link DefaultRootViewInput}.
172
+ */
173
+ function renderDefaultHtml(input) {
174
+ const rootId = input.rootId ?? "app";
175
+ const title = input.title ?? "Inertia App";
176
+ const head = input.head ?? "";
177
+ const script = input.scriptSrc ? `<script type="module" src="${escapeHtmlAttribute(input.scriptSrc)}"><\/script>` : "";
178
+ let rootMarkup;
179
+ if (input.ssrFull) rootMarkup = input.ssrFull;
180
+ else {
181
+ const idAttr = escapeHtmlAttribute(rootId);
182
+ rootMarkup = `<div id="${idAttr}">${input.ssrBody ?? ""}</div>\n ${`<script data-page="${idAttr}" type="application/json">${encodePageScript(input.page)}<\/script>`}`;
183
+ }
184
+ return `<!DOCTYPE html>
185
+ <html>
186
+ <head>
187
+ <meta charset="utf-8" />
188
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
189
+ <title>${escapeHtmlAttribute(title)}</title>
190
+ ${head}
191
+ </head>
192
+ <body>
193
+ ${rootMarkup}
194
+ ${script}
195
+ </body>
196
+ </html>`;
197
+ }
198
+ //#endregion
199
+ //#region src/core/page.ts
200
+ /**
201
+ * Build a normalized Inertia v3 page object. `clearHistory` and `encryptHistory`
202
+ * default to `false`. Empty/absent `deferredProps` / `mergeProps` /
203
+ * `deepMergeProps` / `matchPropsOn` are dropped so the JSON over the wire stays
204
+ * compact.
205
+ *
206
+ * @param input Required page fields plus the optional v3 extras.
207
+ */
208
+ function createPage(input) {
209
+ const page = {
210
+ component: input.component,
211
+ props: input.props,
212
+ url: input.url,
213
+ version: input.version,
214
+ clearHistory: input.clearHistory ?? false,
215
+ encryptHistory: input.encryptHistory ?? false
216
+ };
217
+ if (input.deferredProps && Object.keys(input.deferredProps).length > 0) page.deferredProps = input.deferredProps;
218
+ if (input.mergeProps && input.mergeProps.length > 0) page.mergeProps = input.mergeProps;
219
+ if (input.deepMergeProps && input.deepMergeProps.length > 0) page.deepMergeProps = input.deepMergeProps;
220
+ if (input.matchPropsOn && input.matchPropsOn.length > 0) page.matchPropsOn = input.matchPropsOn;
221
+ return page;
222
+ }
223
+ //#endregion
224
+ //#region src/core/props.ts
225
+ const LAZY = "__inertia_lazy";
226
+ const OPTIONAL = "__inertia_optional";
227
+ const DEFERRED = "__inertia_deferred";
228
+ const ALWAYS = "__inertia_always";
229
+ const MERGE = "__inertia_merge";
230
+ const DEEP_MERGE = "__inertia_deep_merge";
231
+ const MATCH_ON = "__inertia_match_on";
232
+ /**
233
+ * Attach the (non-enumerable) `MATCH_ON` symbol-key to a merge/deep-merge
234
+ * prop value so `resolveProps` can emit the dotted `prop.field` paths.
235
+ *
236
+ * @param value Object being tagged (mutated in place).
237
+ * @param matchOn Single field name or list; no-op when undefined.
238
+ */
239
+ function tagMatchOn(value, matchOn) {
240
+ if (matchOn === void 0) return;
241
+ Object.defineProperty(value, MATCH_ON, {
242
+ value: Array.isArray(matchOn) ? matchOn : [matchOn],
243
+ enumerable: false
244
+ });
245
+ }
246
+ /**
247
+ * Wrap a value-producing function as a "lazy" prop.
248
+ *
249
+ * Lazy props are evaluated on every visit (full or partial) when included.
250
+ * They are NOT skipped on the initial visit — use {@link optional} for that.
251
+ *
252
+ * @param fn Producer called (and awaited) when the prop is included.
253
+ */
254
+ function lazy(fn) {
255
+ const wrapped = (async () => fn());
256
+ Object.defineProperty(wrapped, LAZY, {
257
+ value: true,
258
+ enumerable: false
259
+ });
260
+ return wrapped;
261
+ }
262
+ /**
263
+ * Wrap a value-producing function as an "optional" prop.
264
+ *
265
+ * Optional props are omitted from the initial response and only included
266
+ * when explicitly requested via `X-Inertia-Partial-Data`.
267
+ *
268
+ * @param fn Producer called (and awaited) on partial reloads that request the key.
269
+ */
270
+ function optional(fn) {
271
+ const wrapped = (async () => fn());
272
+ Object.defineProperty(wrapped, OPTIONAL, {
273
+ value: true,
274
+ enumerable: false
275
+ });
276
+ return wrapped;
277
+ }
278
+ /**
279
+ * Wrap a value-producing function as a "deferred" prop.
280
+ *
281
+ * Deferred props are omitted from the initial visit. The client receives
282
+ * the list of deferred prop keys (grouped by `group`) in the page object
283
+ * and triggers an automatic partial reload to fetch them.
284
+ *
285
+ * @param fn Producer called (and awaited) on the deferred follow-up reload.
286
+ * @param group Load-group name; keys in the same group are fetched in one round-trip.
287
+ */
288
+ function defer(fn, group = "default") {
289
+ const wrapped = (async () => fn());
290
+ Object.defineProperty(wrapped, DEFERRED, {
291
+ value: true,
292
+ enumerable: false
293
+ });
294
+ Object.defineProperty(wrapped, "group", {
295
+ value: group,
296
+ enumerable: false
297
+ });
298
+ return wrapped;
299
+ }
300
+ /**
301
+ * Wrap a value-producing function as an "always" prop.
302
+ *
303
+ * Always props are included on every response — full visits and partial
304
+ * reloads alike — regardless of `X-Inertia-Partial-Data` /
305
+ * `X-Inertia-Partial-Except` filtering.
306
+ *
307
+ * @param fn Producer called (and awaited) on every visit.
308
+ */
309
+ function always(fn) {
310
+ const wrapped = (async () => fn());
311
+ Object.defineProperty(wrapped, ALWAYS, {
312
+ value: true,
313
+ enumerable: false
314
+ });
315
+ return wrapped;
316
+ }
317
+ /**
318
+ * Mark a value as a "merge" prop. The Inertia client will append the new
319
+ * value to the existing prop (typically used for paginated arrays).
320
+ *
321
+ * Pass `matchOn` (a field name or list of field names) to have the client
322
+ * dedupe array items on that field instead of blindly appending.
323
+ *
324
+ * @param value Object or array to tag (mutated in place via non-enumerable properties).
325
+ * @param matchOn Optional field name(s) to dedupe on.
326
+ */
327
+ function merge(value, matchOn) {
328
+ const tagged = value;
329
+ Object.defineProperty(tagged, MERGE, {
330
+ value: true,
331
+ enumerable: false
332
+ });
333
+ tagMatchOn(tagged, matchOn);
334
+ return tagged;
335
+ }
336
+ /**
337
+ * Mark a value as a "deep merge" prop. The Inertia client recursively merges
338
+ * the new value into the existing prop rather than shallow-appending.
339
+ *
340
+ * Pass `matchOn` (a field name or list of field names) to have the client
341
+ * dedupe array items on that field.
342
+ *
343
+ * @param value Object or array to tag (mutated in place via non-enumerable properties).
344
+ * @param matchOn Optional field name(s) to dedupe on.
345
+ */
346
+ function deepMerge(value, matchOn) {
347
+ const tagged = value;
348
+ Object.defineProperty(tagged, DEEP_MERGE, {
349
+ value: true,
350
+ enumerable: false
351
+ });
352
+ tagMatchOn(tagged, matchOn);
353
+ return tagged;
354
+ }
355
+ /**
356
+ * Type guard: `true` when `v` is a {@link lazy} prop wrapper.
357
+ *
358
+ * @param v Value to test.
359
+ */
360
+ function isLazy(v) {
361
+ return typeof v === "function" && v[LAZY] === true;
362
+ }
363
+ /**
364
+ * Type guard: `true` when `v` is an {@link optional} prop wrapper.
365
+ *
366
+ * @param v Value to test.
367
+ */
368
+ function isOptional(v) {
369
+ return typeof v === "function" && v[OPTIONAL] === true;
370
+ }
371
+ /**
372
+ * Type guard: `true` when `v` is a {@link defer} prop wrapper.
373
+ *
374
+ * @param v Value to test.
375
+ */
376
+ function isDeferred(v) {
377
+ return typeof v === "function" && v[DEFERRED] === true;
378
+ }
379
+ /**
380
+ * Type guard: `true` when `v` is an {@link always} prop wrapper.
381
+ *
382
+ * @param v Value to test.
383
+ */
384
+ function isAlways(v) {
385
+ return typeof v === "function" && v[ALWAYS] === true;
386
+ }
387
+ /**
388
+ * Type guard: `true` when `v` is a {@link merge} prop wrapper.
389
+ *
390
+ * @param v Value to test.
391
+ */
392
+ function isMerge(v) {
393
+ return v !== null && typeof v === "object" && v[MERGE] === true;
394
+ }
395
+ /**
396
+ * Type guard: `true` when `v` is a {@link deepMerge} prop wrapper.
397
+ *
398
+ * @param v Value to test.
399
+ */
400
+ function isDeepMerge(v) {
401
+ return v !== null && typeof v === "object" && v[DEEP_MERGE] === true;
402
+ }
403
+ /**
404
+ * Match a prop key against a partial-reload key list. A pattern matches when
405
+ * it equals the key, or when it is a dotted path into the key (`users.data`
406
+ * matches the `users` prop). Mirrors the official adapter's partial filtering.
407
+ *
408
+ * @param patterns Key patterns from `X-Inertia-Partial-Data` / `-Partial-Except`.
409
+ * @param key Prop key being tested.
410
+ */
411
+ function partialKeyMatches(patterns, key) {
412
+ return patterns.some((pattern) => pattern === key || pattern.startsWith(`${key}.`));
413
+ }
414
+ /**
415
+ * Read the non-enumerable `MATCH_ON` field-list off a tagged merge value,
416
+ * returning an empty array when absent.
417
+ *
418
+ * @param v Possibly-tagged merge / deep-merge prop value.
419
+ */
420
+ function readMatchOn(v) {
421
+ if (v === null || typeof v !== "object") return [];
422
+ const fields = v[MATCH_ON];
423
+ return Array.isArray(fields) ? fields : [];
424
+ }
425
+ /**
426
+ * Combine shared + page props, then apply partial-reload filtering and
427
+ * lazy/optional/deferred evaluation per the Inertia v3 protocol.
428
+ *
429
+ * Resolution order matches the official adapters:
430
+ * 1. Merge shared and page props (page wins on collision).
431
+ * 2. Determine if this is a partial reload for the same component.
432
+ * 3. For non-partial visits: drop optional and deferred props; keep everything
433
+ * else (lazy props are evaluated).
434
+ * 4. For partial visits: keep only keys in `partialOnly` (if set), then drop
435
+ * keys in `partialExcept`. Optional props are now eligible.
436
+ * 5. Evaluate every remaining function-shaped prop
437
+ * (lazy/optional/deferred/always) by calling it and awaiting the result.
438
+ * Independent props are evaluated in parallel via `Promise.all`.
439
+ * 6. Collect merge / deep-merge / match-on keys, excluding any key the client
440
+ * listed in `X-Inertia-Reset` — those props are reset (replaced) rather
441
+ * than appended.
442
+ *
443
+ * @param input Page + shared props, the component name, and parsed request info.
444
+ */
445
+ async function resolveProps(input) {
446
+ const { props, shared, component, request } = input;
447
+ const combined = {
448
+ ...shared,
449
+ ...props
450
+ };
451
+ const isPartial = request.isInertia && request.partialComponent !== null && request.partialComponent === component;
452
+ const deferred = {};
453
+ const mergeKeys = [];
454
+ const deepMergeKeys = [];
455
+ const matchOnKeys = [];
456
+ const filtered = {};
457
+ const isExcludedFromPartial = (key, value) => {
458
+ if (!isPartial || isAlways(value)) return false;
459
+ if (request.partialOnly !== null && !partialKeyMatches(request.partialOnly, key)) return true;
460
+ return request.partialExcept !== null && partialKeyMatches(request.partialExcept, key);
461
+ };
462
+ Object.entries(combined).forEach(([key, value]) => {
463
+ if (isDeferred(value) && !isPartial) {
464
+ (deferred[value.group] ??= []).push(key);
465
+ return;
466
+ }
467
+ if (isOptional(value) && !isPartial) return;
468
+ if (isExcludedFromPartial(key, value)) return;
469
+ filtered[key] = value;
470
+ });
471
+ const { resetKeys } = request;
472
+ const evaluated = {};
473
+ (await Promise.all(Object.entries(filtered).map(async ([key, value]) => {
474
+ return [key, isLazy(value) || isOptional(value) || isDeferred(value) || isAlways(value) ? await value() : value];
475
+ }))).forEach(([key, resolved]) => {
476
+ evaluated[key] = resolved;
477
+ if (resetKeys?.includes(key) ?? false) return;
478
+ const merging = isMerge(resolved);
479
+ const deepMerging = isDeepMerge(resolved);
480
+ if (merging) mergeKeys.push(key);
481
+ else if (deepMerging) deepMergeKeys.push(key);
482
+ if (merging || deepMerging) readMatchOn(resolved).forEach((field) => {
483
+ matchOnKeys.push(`${key}.${field}`);
484
+ });
485
+ });
486
+ return {
487
+ props: evaluated,
488
+ deferred,
489
+ merge: mergeKeys,
490
+ deepMerge: deepMergeKeys,
491
+ matchOn: matchOnKeys
492
+ };
493
+ }
494
+ //#endregion
495
+ //#region src/core/redirects.ts
496
+ /**
497
+ * Status code Inertia uses for "soft" redirects after PUT/PATCH/DELETE so the
498
+ * follow-up GET is forced to also be a GET on every browser.
499
+ */
500
+ const INERTIA_REDIRECT_STATUS = 303;
501
+ /**
502
+ * Status code used for the `X-Inertia-Location` external redirect
503
+ * mechanism. The client intercepts the 409 and performs a window.location
504
+ * navigation rather than a fetch.
505
+ */
506
+ const INERTIA_LOCATION_STATUS = 409;
507
+ /**
508
+ * Decide which status code to use for a redirect produced by an Inertia
509
+ * request. After a non-GET/HEAD form submission the response must be 303
510
+ * so the client's follow-up request becomes a GET.
511
+ *
512
+ * @param method HTTP method of the inbound request (case-insensitive).
513
+ * @param fallback Status code to use for GET/HEAD/POST. Defaults to 302.
514
+ */
515
+ function inertiaRedirectStatus(method, fallback = 302) {
516
+ const m = method.toUpperCase();
517
+ if (m === "PUT" || m === "PATCH" || m === "DELETE") return 303;
518
+ return fallback;
519
+ }
520
+ //#endregion
521
+ //#region src/core/render.ts
522
+ /**
523
+ * Resolve a `SharedPropsInput` to a plain props object. Returns `{}` for
524
+ * undefined/null inputs or when the resolver returns nullish.
525
+ *
526
+ * @param shared Static props object, a (possibly async) function, or undefined.
527
+ * @param req Request passed to the resolver when `shared` is a function.
528
+ */
529
+ async function evaluateShared(shared, req) {
530
+ if (shared === void 0 || shared === null) return {};
531
+ if (typeof shared === "function") return await shared(req) ?? {};
532
+ return shared;
533
+ }
534
+ /**
535
+ * Build the final {@link InertiaPage} object given a controller's component
536
+ * + props, the request info, and configured shared props / version.
537
+ *
538
+ * Returned page is suitable for either:
539
+ * - JSON serialization on `X-Inertia` requests, or
540
+ * - embedding into the root HTML view via a `<script>` page tag.
541
+ *
542
+ * @param input Component, props, parsed request, shared resolver, version, and
543
+ * optional `clearHistory` / `encryptHistory` flags.
544
+ */
545
+ async function buildPage(input) {
546
+ const sharedProps = await evaluateShared(input.shared, input.req);
547
+ const resolved = await resolveProps({
548
+ props: input.props,
549
+ shared: sharedProps,
550
+ component: input.component,
551
+ request: input.request
552
+ });
553
+ return createPage({
554
+ component: input.component,
555
+ props: resolved.props,
556
+ url: input.request.url,
557
+ version: input.version,
558
+ clearHistory: input.options?.clearHistory,
559
+ encryptHistory: input.options?.encryptHistory,
560
+ deferredProps: resolved.deferred,
561
+ mergeProps: resolved.merge,
562
+ deepMergeProps: resolved.deepMerge,
563
+ matchPropsOn: resolved.matchOn
564
+ });
565
+ }
566
+ //#endregion
567
+ //#region src/core/versioning.ts
568
+ /**
569
+ * Resolve a version value (string, async function, or null) against a request.
570
+ *
571
+ * Returns `null` when no version is configured — in which case asset version
572
+ * mismatch detection is disabled.
573
+ *
574
+ * @param resolver Static version string, a (possibly async) function, or undefined.
575
+ * @param req Request passed to the resolver when it is a function.
576
+ */
577
+ async function resolveVersion(resolver, req) {
578
+ if (resolver === void 0 || resolver === null) return null;
579
+ if (typeof resolver === "string") return resolver;
580
+ return await resolver(req) ?? null;
581
+ }
582
+ /**
583
+ * Returns true if the client's version (`X-Inertia-Version`) does not match
584
+ * the server's current version. Mismatches on GET requests should trigger a
585
+ * full reload via the `X-Inertia-Location` mechanism.
586
+ *
587
+ * @param serverVersion Currently configured server version; `null` disables checks.
588
+ * @param clientVersion Value of the `X-Inertia-Version` header; `null` is treated as empty.
589
+ */
590
+ function isVersionMismatch(serverVersion, clientVersion) {
591
+ if (serverVersion === null) return false;
592
+ return serverVersion !== (clientVersion ?? "");
593
+ }
594
+ //#endregion
595
+ export { INERTIA_HEADERS, INERTIA_LOCATION_STATUS, INERTIA_REDIRECT_STATUS, always, buildPage, canonicalStringify, computeSsrCacheKey, createPage, deepMerge, defer, encodePageScript, inertiaRedirectStatus, isVersionMismatch, lazy, merge, optional, parseInertiaRequest, renderDefaultHtml, resolveProps, resolveVersion, scopeErrors };
596
+
597
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/core/cache.ts","../../src/core/errors.ts","../../src/core/headers.ts","../../src/core/html.ts","../../src/core/page.ts","../../src/core/props.ts","../../src/core/redirects.ts","../../src/core/render.ts","../../src/core/versioning.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\n\nimport type { InertiaPage, PageProps } from './types.js';\n\n/**\n * Pluggable storage interface for the SSR view cache.\n *\n * Implementations live outside the core package — see\n * `createMemoryCacheStore` in `@blacklizard/inertia-express` and\n * `createRedisCacheStore` in `@blacklizard/inertia-cache-redis`.\n *\n * Values are arbitrary JSON — usually `SsrCacheEntry` (`{ head, body, ... }`).\n * The store should not interpret them.\n */\nexport interface SsrCacheStore {\n get: (key: string) => Promise<SsrCacheEntry | null>;\n set: (key: string, value: SsrCacheEntry, ttlSeconds: number) => Promise<void>;\n delete: (key: string) => Promise<void>;\n}\n\n/**\n * What we actually store per cache hit. Includes everything needed to\n * reconstruct the HTML response without re-running the SSR renderer.\n */\nexport interface SsrCacheEntry {\n head: string | string[];\n body: string;\n bodyIsFullRoot?: boolean;\n /** ISO timestamp when this entry was stored. Useful for debugging. */\n storedAt: string;\n /**\n * The page object the entry corresponds to. Lets a custom rootView see\n * the same structure on a cache hit as on a fresh render.\n */\n page: InertiaPage;\n}\n\n/**\n * Recursively normalize a value so equal-by-value inputs serialize identically:\n * object keys are sorted, `undefined` properties are dropped, and non-finite\n * numbers collapse to `null`.\n *\n * @param value Arbitrary JSON-shaped input.\n */\nfunction canonicalize(value: unknown): unknown {\n if (value === null) {\n return null;\n }\n\n if (typeof value !== 'object') {\n if (typeof value === 'number' && !Number.isFinite(value)) {\n return null;\n }\n\n return value;\n }\n\n if (Array.isArray(value)) {\n return value.map(canonicalize);\n }\n\n const obj = value as Record<string, unknown>;\n\n return Object.fromEntries(\n Object.keys(obj)\n .sort()\n .filter((key) => obj[key] !== undefined)\n .map((key) => [key, canonicalize(obj[key])]),\n );\n}\n\n/**\n * Stable JSON encoding: keys sorted recursively, undefined dropped, NaN/Inf\n * normalized. Two equal-by-value objects always produce the same string,\n * so they hash to the same cache key.\n *\n * @param value Arbitrary JSON-shaped input.\n */\nexport function canonicalStringify(value: unknown): string {\n return JSON.stringify(canonicalize(value));\n}\n\n/**\n * Inputs to {@link computeSsrCacheKey}.\n */\nexport interface ComputeCacheKeyInput {\n /** Cache namespace, e.g. `\"inertia:ssr\"`. */\n prefix: string;\n /** Asset version. Becomes part of the key so deploys auto-invalidate. */\n version: string | null;\n /** Component name being rendered. */\n component: string;\n /** Final, fully-resolved props (post shared-merge, post-lazy evaluation). */\n props: PageProps;\n /** Optional extra discriminator (locale, theme, A/B bucket, etc.). */\n discriminator?: string;\n}\n\n/**\n * Build the cache key for an SSR cache entry.\n *\n * Shape: `${prefix}:${version}:${component}:${sha256(props[+discriminator])}`\n *\n * Including `version` in the key namespace means a deploy that bumps the\n * asset version atomically retires every old key — no flush, no race.\n *\n * @param input Prefix, version, component, resolved props, optional discriminator.\n */\nexport function computeSsrCacheKey(input: ComputeCacheKeyInput): string {\n const versionPart = input.version ?? '_';\n const propsHash = createHash('sha256')\n .update(canonicalStringify(input.props))\n .update(input.discriminator ?? '')\n .digest('hex')\n .slice(0, 32);\n\n return `${input.prefix}:${versionPart}:${input.component}:${propsHash}`;\n}\n","import type { PageProps } from './types.js';\n\n/**\n * Flat map of `field → first error message`, the shape the Inertia client\n * expects under `props.errors`.\n */\nexport type ValidationErrors = Record<string, string>;\n\n/**\n * Apply error-bag scoping to a flat errors object, matching the official\n * Inertia behavior. When `bag` is set, errors are namespaced under\n * `props.errors[bag]`. When `bag` is null, errors live at `props.errors`.\n *\n * @param errors Flat field-to-message map.\n * @param bag Optional error-bag name (e.g. `\"createUser\"`); null for top-level.\n */\nexport function scopeErrors(errors: ValidationErrors, bag: string | null): PageProps {\n if (bag) {\n return { errors: { [bag]: errors } };\n }\n\n return { errors };\n}\n","import type { InertiaRequestInfo } from './types.js';\n\n/**\n * Canonical Inertia v3 protocol header names.\n *\n * The Inertia client sends these headers on every visit. The server uses\n * them to decide between a full HTML response and a JSON page response,\n * and to scope partial reloads.\n */\nexport const INERTIA_HEADERS = {\n inertia: 'x-inertia',\n version: 'x-inertia-version',\n partialComponent: 'x-inertia-partial-component',\n partialData: 'x-inertia-partial-data',\n partialExcept: 'x-inertia-partial-except',\n errorBag: 'x-inertia-error-bag',\n reset: 'x-inertia-reset',\n location: 'x-inertia-location',\n} as const;\n\ntype HeaderRecord = Record<string, string | string[] | undefined>;\n\n/**\n * Read a header value as a single string, looking up the lowercase variant as\n * a fallback. Returns the first entry of an array value and `null` when the\n * header is absent.\n *\n * @param headers Raw header record from the HTTP layer.\n * @param name Header name to read.\n */\nfunction readHeader(headers: HeaderRecord, name: string): string | null {\n const value = headers[name] ?? headers[name.toLowerCase()];\n\n if (value === undefined || value === null) {\n return null;\n }\n\n if (Array.isArray(value)) {\n return value[0] ?? null;\n }\n\n return value;\n}\n\n/**\n * Split a comma-separated header value into a trimmed, non-empty string list.\n * Returns `null` when the input is null or yields no entries.\n *\n * @param value Raw header value, or null when absent.\n */\nfunction parseList(value: string | null): string[] | null {\n if (value === null) {\n return null;\n }\n\n const parts = value\n .split(',')\n .map((part) => part.trim())\n .filter((part) => part.length > 0);\n\n return parts.length > 0 ? parts : null;\n}\n\n/**\n * Parse a request's headers into a normalized {@link InertiaRequestInfo}.\n *\n * @param input Headers, HTTP method, and request URL. Method is uppercased in\n * the returned object; the URL is passed through unchanged.\n */\nexport function parseInertiaRequest(input: { headers: HeaderRecord; method: string; url: string }): InertiaRequestInfo {\n const { headers } = input;\n const inertia = readHeader(headers, INERTIA_HEADERS.inertia);\n const version = readHeader(headers, INERTIA_HEADERS.version);\n const partialComponent = readHeader(headers, INERTIA_HEADERS.partialComponent);\n const partialOnly = parseList(readHeader(headers, INERTIA_HEADERS.partialData));\n const partialExcept = parseList(readHeader(headers, INERTIA_HEADERS.partialExcept));\n const errorBag = readHeader(headers, INERTIA_HEADERS.errorBag);\n const resetKeys = parseList(readHeader(headers, INERTIA_HEADERS.reset));\n\n return {\n isInertia: inertia === 'true' || inertia === '1',\n version,\n partialComponent,\n partialOnly,\n partialExcept,\n errorBag,\n resetKeys,\n method: input.method.toUpperCase(),\n url: input.url,\n };\n}\n","import type { InertiaPage } from './types.js';\n\nconst HTML_ESCAPES: Record<string, string> = {\n '&': '&amp;',\n '<': '&lt;',\n '>': '&gt;',\n '\"': '&quot;',\n \"'\": '&#39;',\n};\n\n/**\n * Escape characters that would break out of a double-quoted HTML attribute.\n *\n * @param value Raw attribute value.\n */\nfunction escapeHtmlAttribute(value: string): string {\n return value.replace(/[&<>\"']/g, (ch) => HTML_ESCAPES[ch] ?? ch);\n}\n\n/**\n * Encode an Inertia page object for embedding inside a\n * `<script type=\"application/json\" data-page=\"...\">` tag — the Inertia v3\n * initial-page transport that the client reads via `getInitialPageFromDOM`.\n *\n * Every `<` is replaced with its JSON unicode escape so the payload cannot\n * terminate the surrounding `</script>` element or open an HTML comment. The\n * escape is transparent — JSON.parse decodes it back to `<`.\n *\n * @param page Resolved Inertia page object.\n */\nexport function encodePageScript(page: InertiaPage): string {\n return JSON.stringify(page).replace(/</g, '\\\\u003C');\n}\n\n/**\n * Inputs to {@link renderDefaultHtml}.\n */\nexport interface DefaultRootViewInput {\n page: InertiaPage;\n /** Optional `<title>` for the document. */\n title?: string;\n /** Optional <head> content (meta tags, links, etc.). */\n head?: string;\n /** ID of the SPA root element. Defaults to `app`. */\n rootId?: string;\n /** Path to the client entry script, e.g. `/assets/app.js`. */\n scriptSrc?: string;\n /**\n * Pre-rendered SSR HTML for the root element's *inner* content. When set,\n * the inner HTML of the root div is replaced with this string so the\n * client hydrates over server-rendered markup.\n */\n ssrBody?: string;\n /**\n * Pre-rendered SSR HTML representing the entire root element. When set,\n * `ssrBody`, `rootId`, and the page `<script>` tag are ignored — the\n * provided string is inserted into `<body>` as-is. Use this when your\n * SSR renderer already returns the full root element + page script (the\n * Inertia v3 SSR servers do).\n */\n ssrFull?: string;\n}\n\n/**\n * Minimal default HTML shell for use when no custom root view is provided.\n * Real apps should plug in a template engine (pug, ejs, react-dom/server,\n * etc.) and embed `encodePageScript(page)` themselves.\n *\n * The initial page object is emitted as a\n * `<script type=\"application/json\" data-page=\"...\">` tag, the Inertia v3\n * transport that the client reads on boot.\n *\n * @param input Page object plus optional title/head/rootId/scriptSrc/SSR markup\n * — see {@link DefaultRootViewInput}.\n */\nexport function renderDefaultHtml(input: DefaultRootViewInput): string {\n const rootId = input.rootId ?? 'app';\n const title = input.title ?? 'Inertia App';\n const head = input.head ?? '';\n const script = input.scriptSrc ? `<script type=\"module\" src=\"${escapeHtmlAttribute(input.scriptSrc)}\"></script>` : '';\n\n let rootMarkup: string;\n\n if (input.ssrFull) {\n rootMarkup = input.ssrFull;\n } else {\n const idAttr = escapeHtmlAttribute(rootId);\n const inner = input.ssrBody ?? '';\n const pageScript = `<script data-page=\"${idAttr}\" type=\"application/json\">${encodePageScript(input.page)}</script>`;\n rootMarkup = `<div id=\"${idAttr}\">${inner}</div>\\n ${pageScript}`;\n }\n\n return `<!DOCTYPE html>\n<html>\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>${escapeHtmlAttribute(title)}</title>\n ${head}\n </head>\n <body>\n ${rootMarkup}\n ${script}\n </body>\n</html>`;\n}\n","import type { InertiaPage, PageProps } from './types.js';\n\n/**\n * Inputs to {@link createPage} — the fields of an {@link InertiaPage} plus\n * optional history flags and v3 prop-key metadata.\n */\nexport interface CreatePageInput<TProps extends PageProps = PageProps> {\n component: string;\n props: TProps;\n url: string;\n version: string | null;\n clearHistory?: boolean;\n encryptHistory?: boolean;\n deferredProps?: Record<string, string[]>;\n mergeProps?: string[];\n deepMergeProps?: string[];\n matchPropsOn?: string[];\n}\n\n/**\n * Build a normalized Inertia v3 page object. `clearHistory` and `encryptHistory`\n * default to `false`. Empty/absent `deferredProps` / `mergeProps` /\n * `deepMergeProps` / `matchPropsOn` are dropped so the JSON over the wire stays\n * compact.\n *\n * @param input Required page fields plus the optional v3 extras.\n */\nexport function createPage<TProps extends PageProps>(input: CreatePageInput<TProps>): InertiaPage<TProps> {\n const page: InertiaPage<TProps> = {\n component: input.component,\n props: input.props,\n url: input.url,\n version: input.version,\n clearHistory: input.clearHistory ?? false,\n encryptHistory: input.encryptHistory ?? false,\n };\n\n if (input.deferredProps && Object.keys(input.deferredProps).length > 0) {\n page.deferredProps = input.deferredProps;\n }\n\n if (input.mergeProps && input.mergeProps.length > 0) {\n page.mergeProps = input.mergeProps;\n }\n\n if (input.deepMergeProps && input.deepMergeProps.length > 0) {\n page.deepMergeProps = input.deepMergeProps;\n }\n\n if (input.matchPropsOn && input.matchPropsOn.length > 0) {\n page.matchPropsOn = input.matchPropsOn;\n }\n\n return page;\n}\n","import type {\n AlwaysProp,\n DeepMergeProp,\n DeferredProp,\n InertiaRequestInfo,\n LazyProp,\n MergeProp,\n OptionalProp,\n PageProps,\n} from './types.js';\n\nconst LAZY = '__inertia_lazy' as const;\nconst OPTIONAL = '__inertia_optional' as const;\nconst DEFERRED = '__inertia_deferred' as const;\nconst ALWAYS = '__inertia_always' as const;\nconst MERGE = '__inertia_merge' as const;\nconst DEEP_MERGE = '__inertia_deep_merge' as const;\nconst MATCH_ON = '__inertia_match_on' as const;\n\n/**\n * Attach the (non-enumerable) `MATCH_ON` symbol-key to a merge/deep-merge\n * prop value so `resolveProps` can emit the dotted `prop.field` paths.\n *\n * @param value Object being tagged (mutated in place).\n * @param matchOn Single field name or list; no-op when undefined.\n */\nfunction tagMatchOn(value: object, matchOn: string | string[] | undefined): void {\n if (matchOn === undefined) {\n return;\n }\n\n const fields = Array.isArray(matchOn) ? matchOn : [matchOn];\n Object.defineProperty(value, MATCH_ON, { value: fields, enumerable: false });\n}\n\n/**\n * Wrap a value-producing function as a \"lazy\" prop.\n *\n * Lazy props are evaluated on every visit (full or partial) when included.\n * They are NOT skipped on the initial visit — use {@link optional} for that.\n *\n * @param fn Producer called (and awaited) when the prop is included.\n */\nexport function lazy<T>(fn: () => T | Promise<T>): LazyProp<T> {\n const wrapped = (async () => fn()) as LazyProp<T>;\n Object.defineProperty(wrapped, LAZY, { value: true, enumerable: false });\n\n return wrapped;\n}\n\n/**\n * Wrap a value-producing function as an \"optional\" prop.\n *\n * Optional props are omitted from the initial response and only included\n * when explicitly requested via `X-Inertia-Partial-Data`.\n *\n * @param fn Producer called (and awaited) on partial reloads that request the key.\n */\nexport function optional<T>(fn: () => T | Promise<T>): OptionalProp<T> {\n const wrapped = (async () => fn()) as OptionalProp<T>;\n Object.defineProperty(wrapped, OPTIONAL, { value: true, enumerable: false });\n\n return wrapped;\n}\n\n/**\n * Wrap a value-producing function as a \"deferred\" prop.\n *\n * Deferred props are omitted from the initial visit. The client receives\n * the list of deferred prop keys (grouped by `group`) in the page object\n * and triggers an automatic partial reload to fetch them.\n *\n * @param fn Producer called (and awaited) on the deferred follow-up reload.\n * @param group Load-group name; keys in the same group are fetched in one round-trip.\n */\nexport function defer<T>(fn: () => T | Promise<T>, group = 'default'): DeferredProp<T> {\n const wrapped = (async () => fn()) as DeferredProp<T>;\n Object.defineProperty(wrapped, DEFERRED, { value: true, enumerable: false });\n Object.defineProperty(wrapped, 'group', { value: group, enumerable: false });\n\n return wrapped;\n}\n\n/**\n * Wrap a value-producing function as an \"always\" prop.\n *\n * Always props are included on every response — full visits and partial\n * reloads alike — regardless of `X-Inertia-Partial-Data` /\n * `X-Inertia-Partial-Except` filtering.\n *\n * @param fn Producer called (and awaited) on every visit.\n */\nexport function always<T>(fn: () => T | Promise<T>): AlwaysProp<T> {\n const wrapped = (async () => fn()) as AlwaysProp<T>;\n Object.defineProperty(wrapped, ALWAYS, { value: true, enumerable: false });\n\n return wrapped;\n}\n\n/**\n * Mark a value as a \"merge\" prop. The Inertia client will append the new\n * value to the existing prop (typically used for paginated arrays).\n *\n * Pass `matchOn` (a field name or list of field names) to have the client\n * dedupe array items on that field instead of blindly appending.\n *\n * @param value Object or array to tag (mutated in place via non-enumerable properties).\n * @param matchOn Optional field name(s) to dedupe on.\n */\nexport function merge<T extends object>(value: T, matchOn?: string | string[]): MergeProp<T> {\n const tagged = value as MergeProp<T>;\n Object.defineProperty(tagged, MERGE, { value: true, enumerable: false });\n tagMatchOn(tagged, matchOn);\n\n return tagged;\n}\n\n/**\n * Mark a value as a \"deep merge\" prop. The Inertia client recursively merges\n * the new value into the existing prop rather than shallow-appending.\n *\n * Pass `matchOn` (a field name or list of field names) to have the client\n * dedupe array items on that field.\n *\n * @param value Object or array to tag (mutated in place via non-enumerable properties).\n * @param matchOn Optional field name(s) to dedupe on.\n */\nexport function deepMerge<T extends object>(value: T, matchOn?: string | string[]): DeepMergeProp<T> {\n const tagged = value as DeepMergeProp<T>;\n Object.defineProperty(tagged, DEEP_MERGE, { value: true, enumerable: false });\n tagMatchOn(tagged, matchOn);\n\n return tagged;\n}\n\n/**\n * Type guard: `true` when `v` is a {@link lazy} prop wrapper.\n *\n * @param v Value to test.\n */\nfunction isLazy(v: unknown): v is LazyProp {\n return typeof v === 'function' && (v as { [LAZY]?: boolean })[LAZY] === true;\n}\n\n/**\n * Type guard: `true` when `v` is an {@link optional} prop wrapper.\n *\n * @param v Value to test.\n */\nfunction isOptional(v: unknown): v is OptionalProp {\n return typeof v === 'function' && (v as { [OPTIONAL]?: boolean })[OPTIONAL] === true;\n}\n\n/**\n * Type guard: `true` when `v` is a {@link defer} prop wrapper.\n *\n * @param v Value to test.\n */\nfunction isDeferred(v: unknown): v is DeferredProp {\n return typeof v === 'function' && (v as { [DEFERRED]?: boolean })[DEFERRED] === true;\n}\n\n/**\n * Type guard: `true` when `v` is an {@link always} prop wrapper.\n *\n * @param v Value to test.\n */\nfunction isAlways(v: unknown): v is AlwaysProp {\n return typeof v === 'function' && (v as { [ALWAYS]?: boolean })[ALWAYS] === true;\n}\n\n/**\n * Type guard: `true` when `v` is a {@link merge} prop wrapper.\n *\n * @param v Value to test.\n */\nfunction isMerge(v: unknown): v is MergeProp {\n return v !== null && typeof v === 'object' && (v as { [MERGE]?: boolean })[MERGE] === true;\n}\n\n/**\n * Type guard: `true` when `v` is a {@link deepMerge} prop wrapper.\n *\n * @param v Value to test.\n */\nfunction isDeepMerge(v: unknown): v is DeepMergeProp {\n return v !== null && typeof v === 'object' && (v as { [DEEP_MERGE]?: boolean })[DEEP_MERGE] === true;\n}\n\n/**\n * Match a prop key against a partial-reload key list. A pattern matches when\n * it equals the key, or when it is a dotted path into the key (`users.data`\n * matches the `users` prop). Mirrors the official adapter's partial filtering.\n *\n * @param patterns Key patterns from `X-Inertia-Partial-Data` / `-Partial-Except`.\n * @param key Prop key being tested.\n */\nfunction partialKeyMatches(patterns: string[], key: string): boolean {\n return patterns.some((pattern) => pattern === key || pattern.startsWith(`${key}.`));\n}\n\n/**\n * Read the non-enumerable `MATCH_ON` field-list off a tagged merge value,\n * returning an empty array when absent.\n *\n * @param v Possibly-tagged merge / deep-merge prop value.\n */\nfunction readMatchOn(v: unknown): string[] {\n if (v === null || typeof v !== 'object') {\n return [];\n }\n\n const fields = (v as { [MATCH_ON]?: string[] })[MATCH_ON];\n\n return Array.isArray(fields) ? fields : [];\n}\n\n/**\n * Inputs to {@link resolveProps}.\n */\nexport interface ResolvePropsInput {\n /** Props passed to `res.inertia(component, props)`. */\n props: PageProps;\n /** Globally shared props after evaluation. */\n shared: PageProps;\n /** The component name being rendered. */\n component: string;\n /** Parsed Inertia request info. */\n request: InertiaRequestInfo;\n}\n\n/**\n * Result of {@link resolveProps}: the evaluated props plus the per-key\n * metadata the caller needs to populate the page object.\n */\nexport interface ResolvePropsResult {\n props: PageProps;\n /** Deferred prop key map by group, for the initial visit. */\n deferred: Record<string, string[]>;\n /** Shallow-merge prop key list. */\n merge: string[];\n /** Deep-merge prop key list. */\n deepMerge: string[];\n /** Dotted `prop.field` match-on paths for merge/deep-merge props. */\n matchOn: string[];\n}\n\n/**\n * Combine shared + page props, then apply partial-reload filtering and\n * lazy/optional/deferred evaluation per the Inertia v3 protocol.\n *\n * Resolution order matches the official adapters:\n * 1. Merge shared and page props (page wins on collision).\n * 2. Determine if this is a partial reload for the same component.\n * 3. For non-partial visits: drop optional and deferred props; keep everything\n * else (lazy props are evaluated).\n * 4. For partial visits: keep only keys in `partialOnly` (if set), then drop\n * keys in `partialExcept`. Optional props are now eligible.\n * 5. Evaluate every remaining function-shaped prop\n * (lazy/optional/deferred/always) by calling it and awaiting the result.\n * Independent props are evaluated in parallel via `Promise.all`.\n * 6. Collect merge / deep-merge / match-on keys, excluding any key the client\n * listed in `X-Inertia-Reset` — those props are reset (replaced) rather\n * than appended.\n *\n * @param input Page + shared props, the component name, and parsed request info.\n */\nexport async function resolveProps(input: ResolvePropsInput): Promise<ResolvePropsResult> {\n const {\n props, shared, component, request,\n } = input;\n\n const combined: PageProps = { ...shared, ...props };\n\n const isPartial = request.isInertia && request.partialComponent !== null && request.partialComponent === component;\n\n const deferred: Record<string, string[]> = {};\n const mergeKeys: string[] = [];\n const deepMergeKeys: string[] = [];\n const matchOnKeys: string[] = [];\n const filtered: PageProps = {};\n\n const isExcludedFromPartial = (key: string, value: unknown): boolean => {\n if (!isPartial || isAlways(value)) {\n return false;\n }\n\n if (request.partialOnly !== null && !partialKeyMatches(request.partialOnly, key)) {\n return true;\n }\n\n return request.partialExcept !== null && partialKeyMatches(request.partialExcept, key);\n };\n\n Object.entries(combined).forEach(([key, value]) => {\n // Track deferred prop keys for the page object (only on initial visits).\n // On a partial reload that requests the key, it is evaluated like a lazy prop.\n if (isDeferred(value) && !isPartial) {\n (deferred[value.group] ??= []).push(key);\n\n return;\n }\n\n // Optional props never appear on full visits.\n if (isOptional(value) && !isPartial) {\n return;\n }\n\n if (isExcludedFromPartial(key, value)) {\n return;\n }\n\n filtered[key] = value;\n });\n\n const { resetKeys } = request;\n const evaluated: PageProps = {};\n\n // Evaluate every function-shaped prop; independent props resolve in parallel.\n const resolvedEntries = await Promise.all(\n Object.entries(filtered).map(async ([key, value]): Promise<readonly [string, unknown]> => {\n const needsEval = isLazy(value) || isOptional(value) || isDeferred(value) || isAlways(value);\n const resolved: unknown = needsEval ? await (value as () => unknown)() : value;\n\n return [key, resolved] as const;\n }),\n );\n\n resolvedEntries.forEach(([key, resolved]) => {\n evaluated[key] = resolved;\n\n const isReset = resetKeys?.includes(key) ?? false;\n\n if (isReset) {\n return;\n }\n\n const merging = isMerge(resolved);\n const deepMerging = isDeepMerge(resolved);\n\n if (merging) {\n mergeKeys.push(key);\n } else if (deepMerging) {\n deepMergeKeys.push(key);\n }\n\n if (merging || deepMerging) {\n readMatchOn(resolved).forEach((field) => {\n matchOnKeys.push(`${key}.${field}`);\n });\n }\n });\n\n return {\n props: evaluated,\n deferred,\n merge: mergeKeys,\n deepMerge: deepMergeKeys,\n matchOn: matchOnKeys,\n };\n}\n","/**\n * Status code Inertia uses for \"soft\" redirects after PUT/PATCH/DELETE so the\n * follow-up GET is forced to also be a GET on every browser.\n */\nexport const INERTIA_REDIRECT_STATUS = 303;\n\n/**\n * Status code used for the `X-Inertia-Location` external redirect\n * mechanism. The client intercepts the 409 and performs a window.location\n * navigation rather than a fetch.\n */\nexport const INERTIA_LOCATION_STATUS = 409;\n\n/**\n * Decide which status code to use for a redirect produced by an Inertia\n * request. After a non-GET/HEAD form submission the response must be 303\n * so the client's follow-up request becomes a GET.\n *\n * @param method HTTP method of the inbound request (case-insensitive).\n * @param fallback Status code to use for GET/HEAD/POST. Defaults to 302.\n */\nexport function inertiaRedirectStatus(method: string, fallback = 302): number {\n const m = method.toUpperCase();\n\n if (m === 'PUT' || m === 'PATCH' || m === 'DELETE') {\n return INERTIA_REDIRECT_STATUS;\n }\n\n return fallback;\n}\n","import { createPage } from './page.js';\nimport { resolveProps } from './props.js';\n\nimport type {\n InertiaPage, InertiaRequestInfo, PageProps, RenderOptions, SharedPropsInput,\n} from './types.js';\n\n/**\n * Inputs to {@link buildPage}. `TReq` is the framework's request type so\n * `shared` (when a function) receives the correct argument shape.\n */\nexport interface BuildPageInput<TReq = unknown> {\n component: string;\n props: PageProps;\n request: InertiaRequestInfo;\n req: TReq;\n shared: SharedPropsInput<TReq> | undefined;\n version: string | null;\n options?: RenderOptions;\n}\n\n/**\n * Resolve a `SharedPropsInput` to a plain props object. Returns `{}` for\n * undefined/null inputs or when the resolver returns nullish.\n *\n * @param shared Static props object, a (possibly async) function, or undefined.\n * @param req Request passed to the resolver when `shared` is a function.\n */\nasync function evaluateShared<TReq>(shared: SharedPropsInput<TReq> | undefined, req: TReq): Promise<PageProps> {\n if (shared === undefined || shared === null) {\n return {};\n }\n\n if (typeof shared === 'function') {\n const result = await shared(req);\n\n return result ?? {};\n }\n\n return shared;\n}\n\n/**\n * Build the final {@link InertiaPage} object given a controller's component\n * + props, the request info, and configured shared props / version.\n *\n * Returned page is suitable for either:\n * - JSON serialization on `X-Inertia` requests, or\n * - embedding into the root HTML view via a `<script>` page tag.\n *\n * @param input Component, props, parsed request, shared resolver, version, and\n * optional `clearHistory` / `encryptHistory` flags.\n */\nexport async function buildPage<TReq>(input: BuildPageInput<TReq>): Promise<InertiaPage> {\n const sharedProps = await evaluateShared(input.shared, input.req);\n\n const resolved = await resolveProps({\n props: input.props,\n shared: sharedProps,\n component: input.component,\n request: input.request,\n });\n\n return createPage({\n component: input.component,\n props: resolved.props,\n url: input.request.url,\n version: input.version,\n clearHistory: input.options?.clearHistory,\n encryptHistory: input.options?.encryptHistory,\n deferredProps: resolved.deferred,\n mergeProps: resolved.merge,\n deepMergeProps: resolved.deepMerge,\n matchPropsOn: resolved.matchOn,\n });\n}\n","import type { VersionResolver } from './types.js';\n\n/**\n * Resolve a version value (string, async function, or null) against a request.\n *\n * Returns `null` when no version is configured — in which case asset version\n * mismatch detection is disabled.\n *\n * @param resolver Static version string, a (possibly async) function, or undefined.\n * @param req Request passed to the resolver when it is a function.\n */\nexport async function resolveVersion<TReq>(\n resolver: VersionResolver<TReq> | undefined,\n req: TReq,\n): Promise<string | null> {\n if (resolver === undefined || resolver === null) {\n return null;\n }\n\n if (typeof resolver === 'string') {\n return resolver;\n }\n\n const result = await (resolver)(req);\n\n return result ?? null;\n}\n\n/**\n * Returns true if the client's version (`X-Inertia-Version`) does not match\n * the server's current version. Mismatches on GET requests should trigger a\n * full reload via the `X-Inertia-Location` mechanism.\n *\n * @param serverVersion Currently configured server version; `null` disables checks.\n * @param clientVersion Value of the `X-Inertia-Version` header; `null` is treated as empty.\n */\nexport function isVersionMismatch(serverVersion: string | null, clientVersion: string | null): boolean {\n if (serverVersion === null) {\n return false;\n }\n\n // A missing client header is treated as an empty version, matching the\n // official adapters: an empty client version still mismatches a non-empty\n // server version and must trigger a full reload.\n return serverVersion !== (clientVersion ?? '');\n}\n"],"mappings":"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"}