@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.
- package/LICENSE +21 -0
- package/README.md +515 -0
- package/bin/inertia-prerender.mjs +147 -0
- package/dist/core/index.d.ts +520 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +597 -0
- package/dist/core/index.js.map +1 -0
- package/dist/express/index.d.ts +495 -0
- package/dist/express/index.d.ts.map +1 -0
- package/dist/express/index.js +854 -0
- package/dist/express/index.js.map +1 -0
- package/package.json +86 -0
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
import { INERTIA_HEADERS, INERTIA_LOCATION_STATUS, INERTIA_REDIRECT_STATUS, always, buildPage, canonicalStringify, computeSsrCacheKey, deepMerge, defer, encodePageScript, inertiaRedirectStatus, isVersionMismatch, lazy, merge, optional, parseInertiaRequest, renderDefaultHtml, resolveVersion, scopeErrors } from "../core/index.js";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { format } from "node:util";
|
|
4
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
//#region src/express/edge-cache.ts
|
|
7
|
+
/**
|
|
8
|
+
* Apply an edge-cache decision to the response. Called automatically by
|
|
9
|
+
* the middleware when an `edgeCache` policy is configured. Exported so
|
|
10
|
+
* you can call it from custom code paths too.
|
|
11
|
+
*
|
|
12
|
+
* No-op when `decision` is `null`, when `sMaxAge <= 0`, or when the response
|
|
13
|
+
* is 3xx+ (4xx/5xx must not be cached at the edge). The middleware also skips
|
|
14
|
+
* Inertia XHR responses before calling this — that filter lives at the call
|
|
15
|
+
* site, not here, since `applyEdgeCache` only sees the decision + status.
|
|
16
|
+
*
|
|
17
|
+
* `Vary` values from the decision are merged into the existing header,
|
|
18
|
+
* de-duplicated by trimmed string.
|
|
19
|
+
*
|
|
20
|
+
* @param decision Policy output (or `null` to opt out).
|
|
21
|
+
* @param res Outgoing Express response (mutated: sets `Cache-Control`, `Vary`).
|
|
22
|
+
*/
|
|
23
|
+
function applyEdgeCache(decision, res) {
|
|
24
|
+
if (decision === null || decision.sMaxAge <= 0) return;
|
|
25
|
+
if (res.statusCode >= 300) return;
|
|
26
|
+
const parts = [
|
|
27
|
+
"public",
|
|
28
|
+
`s-maxage=${decision.sMaxAge}`,
|
|
29
|
+
`max-age=${decision.maxAge ?? 0}`
|
|
30
|
+
];
|
|
31
|
+
if (decision.staleWhileRevalidate && decision.staleWhileRevalidate > 0) parts.push(`stale-while-revalidate=${decision.staleWhileRevalidate}`);
|
|
32
|
+
res.setHeader("Cache-Control", parts.join(", "));
|
|
33
|
+
if (decision.vary && decision.vary.length > 0) {
|
|
34
|
+
const existing = res.getHeader("Vary");
|
|
35
|
+
const all = /* @__PURE__ */ new Set();
|
|
36
|
+
if (typeof existing === "string") existing.split(",").forEach((v) => all.add(v.trim()));
|
|
37
|
+
decision.vary.forEach((v) => {
|
|
38
|
+
const trimmed = v.trim();
|
|
39
|
+
if (trimmed) all.add(trimmed);
|
|
40
|
+
});
|
|
41
|
+
res.setHeader("Vary", Array.from(all).join(", "));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/express/response.ts
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the shared-props input for a request. When `flashFromSession` is
|
|
48
|
+
* enabled, session-stored validation errors and flash data are merged in
|
|
49
|
+
* automatically (read-once — they are cleared after being read). Explicit
|
|
50
|
+
* `sharedProps` keys win on collision.
|
|
51
|
+
*
|
|
52
|
+
* @param ctx Render context carrying the middleware options.
|
|
53
|
+
* @param res Express response, scanned for `res.locals.inertiaErrors` as a
|
|
54
|
+
* fallback when no session is wired.
|
|
55
|
+
*/
|
|
56
|
+
function composeSharedProps(ctx, res) {
|
|
57
|
+
const base = ctx.options.sharedProps;
|
|
58
|
+
if (!ctx.options.flashFromSession) return base ?? {};
|
|
59
|
+
return async (req) => {
|
|
60
|
+
let baseProps = {};
|
|
61
|
+
if (base) baseProps = typeof base === "function" ? await base(req) ?? {} : base;
|
|
62
|
+
const { session } = req;
|
|
63
|
+
const errors = session?.errors ?? res.locals.inertiaErrors ?? {};
|
|
64
|
+
const flash = session?.flash ?? null;
|
|
65
|
+
if (session) {
|
|
66
|
+
session.errors = void 0;
|
|
67
|
+
session.flash = void 0;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
errors,
|
|
71
|
+
flash,
|
|
72
|
+
...baseProps
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Send the Inertia version-mismatch response: 409 with `X-Inertia-Location`
|
|
78
|
+
* pointing back at the same URL so the client performs a hard reload.
|
|
79
|
+
*
|
|
80
|
+
* @param res Outgoing Express response.
|
|
81
|
+
* @param url Location to send the client to (typically the requested URL).
|
|
82
|
+
*/
|
|
83
|
+
function sendVersionMismatch(res, url) {
|
|
84
|
+
res.status(409).setHeader(INERTIA_HEADERS.location, url).end();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Send an external Inertia redirect. For Inertia (XHR) requests this returns
|
|
88
|
+
* 409 + `X-Inertia-Location` so the client performs a window.location nav.
|
|
89
|
+
* For plain browser requests it falls back to a normal 302 redirect.
|
|
90
|
+
*
|
|
91
|
+
* @param req Incoming Express request; `req.inertia` is reused when present.
|
|
92
|
+
* @param res Outgoing Express response.
|
|
93
|
+
* @param target Destination URL.
|
|
94
|
+
*/
|
|
95
|
+
function sendInertiaLocation(req, res, target) {
|
|
96
|
+
if ((req.inertia ?? parseInertiaRequest({
|
|
97
|
+
headers: req.headers,
|
|
98
|
+
method: req.method,
|
|
99
|
+
url: req.originalUrl ?? req.url
|
|
100
|
+
})).isInertia) {
|
|
101
|
+
res.status(409).setHeader(INERTIA_HEADERS.location, target).end();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
res.redirect(302, target);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Merge `Accept` and `X-Inertia` into an existing `Vary` header value,
|
|
108
|
+
* deduplicating case-insensitively so the header never accumulates repeats
|
|
109
|
+
* when the middleware and the response both contribute tokens.
|
|
110
|
+
*
|
|
111
|
+
* @param existing Current header value as Node returns it (string, number,
|
|
112
|
+
* string[], or undefined).
|
|
113
|
+
*/
|
|
114
|
+
function mergeVary(existing) {
|
|
115
|
+
const tokens = [];
|
|
116
|
+
const seen = /* @__PURE__ */ new Set();
|
|
117
|
+
const add = (token) => {
|
|
118
|
+
const trimmed = token.trim();
|
|
119
|
+
const key = trimmed.toLowerCase();
|
|
120
|
+
if (trimmed && !seen.has(key)) {
|
|
121
|
+
seen.add(key);
|
|
122
|
+
tokens.push(trimmed);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
if (existing !== void 0) String(existing).split(",").forEach((part) => add(part));
|
|
126
|
+
add("Accept");
|
|
127
|
+
add("X-Inertia");
|
|
128
|
+
return tokens.join(", ");
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Coerce SSR head output (string, string[], or absent) to a single string
|
|
132
|
+
* suitable for injection into the document `<head>`.
|
|
133
|
+
*
|
|
134
|
+
* @param head Head fragment(s) from the SSR renderer.
|
|
135
|
+
*/
|
|
136
|
+
function flattenSsrHead(head) {
|
|
137
|
+
if (head === void 0) return;
|
|
138
|
+
if (Array.isArray(head)) return head.join("");
|
|
139
|
+
return head;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Default rule deciding whether a rendered page is safe to cache. Cacheable
|
|
143
|
+
* iff the request is a non-Inertia GET and the page carries no flash
|
|
144
|
+
* messages or validation errors (those are per-visit state).
|
|
145
|
+
*
|
|
146
|
+
* @param input Express request and the resolved page object.
|
|
147
|
+
*/
|
|
148
|
+
function defaultCacheVary(input) {
|
|
149
|
+
const { req, page } = input;
|
|
150
|
+
if (req.method !== "GET") return false;
|
|
151
|
+
if (req.inertia?.isInertia) return false;
|
|
152
|
+
const props = page.props;
|
|
153
|
+
if (props && typeof props === "object") {
|
|
154
|
+
const flash = props.flash;
|
|
155
|
+
if (flash && Object.keys(flash).length > 0) return false;
|
|
156
|
+
const errors = props.errors;
|
|
157
|
+
if (errors && Object.keys(errors).length > 0) return false;
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Wrap `cache.store.get` so a thrown store error is reported via `onError`
|
|
163
|
+
* and reported as a miss instead of bubbling out to the request handler.
|
|
164
|
+
*
|
|
165
|
+
* @param cache Resolved cache options.
|
|
166
|
+
* @param key Cache key.
|
|
167
|
+
*/
|
|
168
|
+
async function safeCacheGet(cache, key) {
|
|
169
|
+
try {
|
|
170
|
+
return await cache.store.get(key);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
cache.onError?.("get", err);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Wrap `cache.store.set` so a thrown store error is reported via `onError`
|
|
178
|
+
* but never propagates — cache writes are fire-and-forget.
|
|
179
|
+
*
|
|
180
|
+
* @param cache Resolved cache options.
|
|
181
|
+
* @param key Cache key.
|
|
182
|
+
* @param entry Value to store.
|
|
183
|
+
* @param ttl Time-to-live in seconds.
|
|
184
|
+
*/
|
|
185
|
+
async function safeCacheSet(cache, key, entry, ttl) {
|
|
186
|
+
try {
|
|
187
|
+
await cache.store.set(key, entry, ttl);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
cache.onError?.("set", err);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Render the final HTML body. When an SSR result is available, expose it via
|
|
194
|
+
* `res.locals.ssr` so a custom `rootView` can read it, then either call
|
|
195
|
+
* `rootView` or fall back to {@link renderDefaultHtml}.
|
|
196
|
+
*
|
|
197
|
+
* @param ctx Render context carrying the middleware options.
|
|
198
|
+
* @param page Resolved page object.
|
|
199
|
+
* @param ssrResult SSR renderer output, or `null` when SSR is skipped.
|
|
200
|
+
* @param req Incoming request.
|
|
201
|
+
* @param res Outgoing response.
|
|
202
|
+
*/
|
|
203
|
+
async function renderRootViewWithSsr(ctx, page, ssrResult, req, res) {
|
|
204
|
+
if (ssrResult) {
|
|
205
|
+
res.locals.ssr = ssrResult;
|
|
206
|
+
if (ctx.options.rootView) return ctx.options.rootView({
|
|
207
|
+
page,
|
|
208
|
+
req,
|
|
209
|
+
res
|
|
210
|
+
});
|
|
211
|
+
return renderDefaultHtml({
|
|
212
|
+
page,
|
|
213
|
+
head: flattenSsrHead(ssrResult.head),
|
|
214
|
+
ssrBody: ssrResult.bodyIsFullRoot ? void 0 : ssrResult.body,
|
|
215
|
+
ssrFull: ssrResult.bodyIsFullRoot ? ssrResult.body : void 0
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (ctx.options.rootView) return ctx.options.rootView({
|
|
219
|
+
page,
|
|
220
|
+
req,
|
|
221
|
+
res
|
|
222
|
+
});
|
|
223
|
+
return renderDefaultHtml({ page });
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Apply the configured `edgeCache` policy unless this is an Inertia XHR
|
|
227
|
+
* response (those vary per visit and must not be cached at the edge).
|
|
228
|
+
*
|
|
229
|
+
* @param ctx Render context carrying the middleware options.
|
|
230
|
+
* @param page Resolved page object.
|
|
231
|
+
* @param req Incoming request.
|
|
232
|
+
* @param res Outgoing response.
|
|
233
|
+
*/
|
|
234
|
+
function maybeApplyEdgeCache(ctx, page, req, res) {
|
|
235
|
+
if (!ctx.options.edgeCache) return;
|
|
236
|
+
if (req.inertia?.isInertia) return;
|
|
237
|
+
applyEdgeCache(ctx.options.edgeCache({
|
|
238
|
+
req,
|
|
239
|
+
res,
|
|
240
|
+
page
|
|
241
|
+
}), res);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Build and send an Inertia response. Handles version-mismatch detection,
|
|
245
|
+
* partial-reload filtering, JSON vs HTML, SSR, view caching, and edge
|
|
246
|
+
* cache headers. The status code already on `res` (e.g. `res.status(404)`)
|
|
247
|
+
* is preserved on both the JSON and HTML branches.
|
|
248
|
+
*
|
|
249
|
+
* @param req Incoming Express request.
|
|
250
|
+
* @param res Outgoing Express response.
|
|
251
|
+
* @param ctx Render context carrying the middleware options.
|
|
252
|
+
* @param component Inertia component name to render.
|
|
253
|
+
* @param props Page-specific props (merged with shared props).
|
|
254
|
+
* @param options Optional per-response overrides (url, clearHistory, encryptHistory).
|
|
255
|
+
*/
|
|
256
|
+
async function sendInertiaResponse(req, res, ctx, component, props, options = {}) {
|
|
257
|
+
const info = req.inertia ?? parseInertiaRequest({
|
|
258
|
+
headers: req.headers,
|
|
259
|
+
method: req.method,
|
|
260
|
+
url: options.url ?? req.originalUrl ?? req.url
|
|
261
|
+
});
|
|
262
|
+
const url = options.url ?? info.url;
|
|
263
|
+
const version = await resolveVersion(ctx.options.version, req);
|
|
264
|
+
if (info.isInertia && info.method === "GET" && isVersionMismatch(version, info.version)) {
|
|
265
|
+
sendVersionMismatch(res, url);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const page = await buildPage({
|
|
269
|
+
component,
|
|
270
|
+
props,
|
|
271
|
+
request: {
|
|
272
|
+
...info,
|
|
273
|
+
url
|
|
274
|
+
},
|
|
275
|
+
req,
|
|
276
|
+
shared: composeSharedProps(ctx, res),
|
|
277
|
+
version,
|
|
278
|
+
options: {
|
|
279
|
+
clearHistory: options.clearHistory,
|
|
280
|
+
encryptHistory: options.encryptHistory
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
res.setHeader("Vary", mergeVary(res.getHeader("Vary")));
|
|
284
|
+
if (info.isInertia) {
|
|
285
|
+
res.status(res.statusCode).setHeader("Content-Type", "application/json; charset=utf-8").setHeader(INERTIA_HEADERS.inertia, "true");
|
|
286
|
+
res.send(JSON.stringify(page));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const { cache } = ctx.options;
|
|
290
|
+
const cacheVary = cache?.vary ?? defaultCacheVary;
|
|
291
|
+
const cacheable = cache !== void 0 && cacheVary({
|
|
292
|
+
req,
|
|
293
|
+
page
|
|
294
|
+
});
|
|
295
|
+
let cacheKey = null;
|
|
296
|
+
if (cacheable && cache) {
|
|
297
|
+
cacheKey = computeSsrCacheKey({
|
|
298
|
+
prefix: cache.keyPrefix ?? "inertia:ssr",
|
|
299
|
+
version,
|
|
300
|
+
component,
|
|
301
|
+
props: page.props,
|
|
302
|
+
discriminator: cache.discriminator?.(req)
|
|
303
|
+
});
|
|
304
|
+
const hit = await safeCacheGet(cache, cacheKey);
|
|
305
|
+
if (hit) {
|
|
306
|
+
res.setHeader("X-Inertia-Cache", "HIT");
|
|
307
|
+
const html = await renderRootViewWithSsr(ctx, hit.page, {
|
|
308
|
+
head: hit.head,
|
|
309
|
+
body: hit.body,
|
|
310
|
+
bodyIsFullRoot: hit.bodyIsFullRoot
|
|
311
|
+
}, req, res);
|
|
312
|
+
maybeApplyEdgeCache(ctx, hit.page, req, res);
|
|
313
|
+
res.status(res.statusCode).setHeader("Content-Type", "text/html; charset=utf-8").send(html);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
res.setHeader("X-Inertia-Cache", "MISS");
|
|
317
|
+
}
|
|
318
|
+
let ssrResult = null;
|
|
319
|
+
if (ctx.options.ssr) ssrResult = await ctx.options.ssr({
|
|
320
|
+
page,
|
|
321
|
+
req,
|
|
322
|
+
res
|
|
323
|
+
});
|
|
324
|
+
const html = await renderRootViewWithSsr(ctx, page, ssrResult, req, res);
|
|
325
|
+
if (cacheable && cache && cacheKey && ssrResult) {
|
|
326
|
+
const entry = {
|
|
327
|
+
head: ssrResult.head ?? "",
|
|
328
|
+
body: ssrResult.body,
|
|
329
|
+
bodyIsFullRoot: ssrResult.bodyIsFullRoot,
|
|
330
|
+
storedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
331
|
+
page
|
|
332
|
+
};
|
|
333
|
+
safeCacheSet(cache, cacheKey, entry, cache.ttlSeconds ?? 300).catch(() => {});
|
|
334
|
+
}
|
|
335
|
+
maybeApplyEdgeCache(ctx, page, req, res);
|
|
336
|
+
res.status(res.statusCode).setHeader("Content-Type", "text/html; charset=utf-8").send(html);
|
|
337
|
+
}
|
|
338
|
+
//#endregion
|
|
339
|
+
//#region src/express/middleware.ts
|
|
340
|
+
/**
|
|
341
|
+
* Build the `connect-flash`-compatible `req.flash()` method for a request.
|
|
342
|
+
* Provided so this package is a drop-in replacement for `connect-flash`: the
|
|
343
|
+
* returned function matches its semantics exactly — a setter call appends to
|
|
344
|
+
* a per-type bucket in `req.session.flash` and returns the new bucket length,
|
|
345
|
+
* a getter call reads and clears. Throws when no session is wired, just like
|
|
346
|
+
* `connect-flash`.
|
|
347
|
+
*
|
|
348
|
+
* @param req Express request whose `session` holds the flash buckets.
|
|
349
|
+
*/
|
|
350
|
+
function createFlash(req) {
|
|
351
|
+
return ((type, msg, ...rest) => {
|
|
352
|
+
const { session } = req;
|
|
353
|
+
if (session === void 0) throw new Error("req.flash() requires sessions");
|
|
354
|
+
const msgs = session.flash ?? {};
|
|
355
|
+
session.flash = msgs;
|
|
356
|
+
if (type && msg) {
|
|
357
|
+
let bucket = msgs[type];
|
|
358
|
+
if (bucket === void 0) {
|
|
359
|
+
bucket = [];
|
|
360
|
+
msgs[type] = bucket;
|
|
361
|
+
}
|
|
362
|
+
if (rest.length > 0) return bucket.push(format(msg, ...rest));
|
|
363
|
+
if (Array.isArray(msg)) {
|
|
364
|
+
msg.forEach((val) => bucket.push(val));
|
|
365
|
+
return bucket.length;
|
|
366
|
+
}
|
|
367
|
+
return bucket.push(msg);
|
|
368
|
+
}
|
|
369
|
+
if (type) {
|
|
370
|
+
const arr = msgs[type] ?? [];
|
|
371
|
+
delete msgs[type];
|
|
372
|
+
return arr;
|
|
373
|
+
}
|
|
374
|
+
session.flash = {};
|
|
375
|
+
return msgs;
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Create the Inertia.js Express middleware.
|
|
380
|
+
*
|
|
381
|
+
* The middleware:
|
|
382
|
+
* - Parses Inertia headers into `req.inertia`.
|
|
383
|
+
* - Adds `req.flash(type?, msg?)` — a `connect-flash`-compatible API.
|
|
384
|
+
* - Adds `res.inertia(component, props, options)`.
|
|
385
|
+
* - Adds `res.inertiaLocation(url)` for external redirects.
|
|
386
|
+
* - Adds `res.inertiaErrors(errors, bag?)` to flash validation errors.
|
|
387
|
+
* - Adds `res.inertiaFlash(data)` to flash arbitrary data.
|
|
388
|
+
* - Auto-promotes redirects after PUT/PATCH/DELETE to 303 for Inertia visits.
|
|
389
|
+
*
|
|
390
|
+
* Mount before your route handlers:
|
|
391
|
+
*
|
|
392
|
+
* ```ts
|
|
393
|
+
* app.use(inertia({ rootView: ..., sharedProps: ... }));
|
|
394
|
+
* ```
|
|
395
|
+
*
|
|
396
|
+
* @param options Middleware configuration; defaults to an empty object.
|
|
397
|
+
*/
|
|
398
|
+
function inertia(options = {}) {
|
|
399
|
+
const ctx = { options };
|
|
400
|
+
return function inertiaMiddleware(req, res, next) {
|
|
401
|
+
const info = parseInertiaRequest({
|
|
402
|
+
headers: req.headers,
|
|
403
|
+
method: req.method,
|
|
404
|
+
url: req.originalUrl ?? req.url
|
|
405
|
+
});
|
|
406
|
+
req.inertia = info;
|
|
407
|
+
req.flash = createFlash(req);
|
|
408
|
+
res.inertia = async (component, props = {}, opts = {}) => {
|
|
409
|
+
try {
|
|
410
|
+
await sendInertiaResponse(req, res, ctx, component, props, opts);
|
|
411
|
+
} catch (err) {
|
|
412
|
+
next(err);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
res.inertiaLocation = (url) => {
|
|
416
|
+
sendInertiaLocation(req, res, url);
|
|
417
|
+
};
|
|
418
|
+
res.inertiaErrors = (errors, bag) => {
|
|
419
|
+
const { session } = req;
|
|
420
|
+
const scoped = scopeErrors(errors, bag ?? null);
|
|
421
|
+
if (session) session.errors = {
|
|
422
|
+
...session.errors ?? {},
|
|
423
|
+
...scoped.errors
|
|
424
|
+
};
|
|
425
|
+
else res.locals.inertiaErrors = scoped.errors;
|
|
426
|
+
};
|
|
427
|
+
res.inertiaFlash = (data) => {
|
|
428
|
+
const { session } = req;
|
|
429
|
+
if (session) session.flash = data;
|
|
430
|
+
};
|
|
431
|
+
if (info.isInertia) {
|
|
432
|
+
const originalRedirect = res.redirect.bind(res);
|
|
433
|
+
res.redirect = ((arg1, arg2) => {
|
|
434
|
+
let status;
|
|
435
|
+
let url;
|
|
436
|
+
if (typeof arg1 === "number") {
|
|
437
|
+
status = arg1;
|
|
438
|
+
url = String(arg2);
|
|
439
|
+
} else {
|
|
440
|
+
status = inertiaRedirectStatus(req.method, 302);
|
|
441
|
+
url = arg1;
|
|
442
|
+
}
|
|
443
|
+
if (status === 302 && (req.method === "PUT" || req.method === "PATCH" || req.method === "DELETE")) status = 303;
|
|
444
|
+
originalRedirect(status, url);
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
if (!res.getHeader("Vary")) res.setHeader("Vary", "Accept, X-Inertia");
|
|
448
|
+
next();
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
//#endregion
|
|
452
|
+
//#region src/express/memory-store.ts
|
|
453
|
+
/**
|
|
454
|
+
* Build an in-process LRU + TTL {@link SsrCacheStore}. See
|
|
455
|
+
* {@link MemoryCacheStoreOptions} for the full contract.
|
|
456
|
+
*
|
|
457
|
+
* @param options Store configuration; defaults applied when omitted.
|
|
458
|
+
*/
|
|
459
|
+
function createMemoryCacheStore(options = {}) {
|
|
460
|
+
const max = options.max ?? 500;
|
|
461
|
+
const map = /* @__PURE__ */ new Map();
|
|
462
|
+
/**
|
|
463
|
+
* Drop oldest-inserted entries until `map.size <= max`. Relies on `Map`'s
|
|
464
|
+
* insertion-order iteration to identify the LRU entry.
|
|
465
|
+
*/
|
|
466
|
+
function evict() {
|
|
467
|
+
while (map.size > max) {
|
|
468
|
+
const oldest = map.keys().next().value;
|
|
469
|
+
if (oldest === void 0) return;
|
|
470
|
+
map.delete(oldest);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
/**
|
|
475
|
+
* Return the cached entry or `null` on miss / expired. Touched entries
|
|
476
|
+
* are reinserted so they become the most-recently-used.
|
|
477
|
+
*
|
|
478
|
+
* @param key Cache key.
|
|
479
|
+
*/
|
|
480
|
+
async get(key) {
|
|
481
|
+
const slot = map.get(key);
|
|
482
|
+
if (!slot) return null;
|
|
483
|
+
if (slot.expiresAt < Date.now()) {
|
|
484
|
+
map.delete(key);
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
map.delete(key);
|
|
488
|
+
map.set(key, slot);
|
|
489
|
+
return slot.entry;
|
|
490
|
+
},
|
|
491
|
+
/**
|
|
492
|
+
* Write an entry with the given TTL, replacing any prior value. Runs LRU
|
|
493
|
+
* eviction afterward so `max` is never exceeded.
|
|
494
|
+
*
|
|
495
|
+
* @param key Cache key.
|
|
496
|
+
* @param entry Value to store.
|
|
497
|
+
* @param ttlSeconds Time-to-live in seconds.
|
|
498
|
+
*/
|
|
499
|
+
async set(key, entry, ttlSeconds) {
|
|
500
|
+
const expiresAt = Date.now() + ttlSeconds * 1e3;
|
|
501
|
+
map.delete(key);
|
|
502
|
+
map.set(key, {
|
|
503
|
+
entry,
|
|
504
|
+
expiresAt
|
|
505
|
+
});
|
|
506
|
+
evict();
|
|
507
|
+
},
|
|
508
|
+
/**
|
|
509
|
+
* Remove an entry. Idempotent — missing keys are silently ignored.
|
|
510
|
+
*
|
|
511
|
+
* @param key Cache key.
|
|
512
|
+
*/
|
|
513
|
+
async delete(key) {
|
|
514
|
+
map.delete(key);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
//#endregion
|
|
519
|
+
//#region src/express/prerender.ts
|
|
520
|
+
/**
|
|
521
|
+
* Map an HTTP route to an on-disk static-output path. `/` maps to
|
|
522
|
+
* `<outDir>/index.html`; `/foo/bar` to `<outDir>/foo/bar/index.html`.
|
|
523
|
+
* Query strings are stripped before computing the path — reverse proxies
|
|
524
|
+
* (nginx, Caddy, CloudFront) split the URL at `?` when looking up static
|
|
525
|
+
* files, so embedding the query string in a directory name produces a path
|
|
526
|
+
* that can never be served.
|
|
527
|
+
*
|
|
528
|
+
* @param outDir Static-output root.
|
|
529
|
+
* @param route HTTP path (with optional query string) being prerendered.
|
|
530
|
+
*/
|
|
531
|
+
function routeToFile(outDir, route) {
|
|
532
|
+
const { pathname } = new URL(route, "http://x");
|
|
533
|
+
const trimmed = pathname.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
534
|
+
if (!trimmed) return join(outDir, "index.html");
|
|
535
|
+
return join(outDir, trimmed, "index.html");
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Write the rendered HTML to disk when the mode includes static output.
|
|
539
|
+
* Returns the file path written, or `undefined` for warmup-only modes.
|
|
540
|
+
* Throws when static output is requested without an `outDir`.
|
|
541
|
+
*
|
|
542
|
+
* @param options Shared prerender options — `outDir` lives here.
|
|
543
|
+
* @param route HTTP path being rendered; mapped to the on-disk path.
|
|
544
|
+
* @param html Rendered response body to write.
|
|
545
|
+
* @param mode Resolved output mode for this run.
|
|
546
|
+
*/
|
|
547
|
+
async function maybeWriteStatic(options, route, html, mode) {
|
|
548
|
+
if (mode !== "static" && mode !== "both") return;
|
|
549
|
+
if (!options.outDir) throw new Error(`outDir is required when mode is "${mode}"`);
|
|
550
|
+
const outputPath = routeToFile(options.outDir, route);
|
|
551
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
552
|
+
await writeFile(outputPath, html, "utf8");
|
|
553
|
+
return outputPath;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Fetch a single route, optionally write its body to disk, and return a
|
|
557
|
+
* {@link PrerenderRouteResult}. Never throws — failures are returned with
|
|
558
|
+
* `status: null` and an `error` message so the calling loop keeps going.
|
|
559
|
+
*
|
|
560
|
+
* @param options Shared prerender options (baseUrl, mode, outDir, etc.).
|
|
561
|
+
* @param route HTTP path to render.
|
|
562
|
+
*/
|
|
563
|
+
async function runOne(options, route) {
|
|
564
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
565
|
+
const mode = options.mode ?? "warmup";
|
|
566
|
+
const start = Date.now();
|
|
567
|
+
const ctrl = new AbortController();
|
|
568
|
+
const timer = setTimeout(() => ctrl.abort(), options.timeoutMs ?? 3e4);
|
|
569
|
+
try {
|
|
570
|
+
const res = await fetchImpl(new URL(route, options.baseUrl).toString(), {
|
|
571
|
+
method: "GET",
|
|
572
|
+
headers: {
|
|
573
|
+
Accept: "text/html",
|
|
574
|
+
...options.headers
|
|
575
|
+
},
|
|
576
|
+
signal: ctrl.signal
|
|
577
|
+
});
|
|
578
|
+
const html = await res.text();
|
|
579
|
+
const outputPath = await maybeWriteStatic(options, route, html, mode);
|
|
580
|
+
return {
|
|
581
|
+
route,
|
|
582
|
+
status: res.status,
|
|
583
|
+
bytes: Buffer.byteLength(html),
|
|
584
|
+
outputPath,
|
|
585
|
+
durationMs: Date.now() - start
|
|
586
|
+
};
|
|
587
|
+
} catch (err) {
|
|
588
|
+
return {
|
|
589
|
+
route,
|
|
590
|
+
status: null,
|
|
591
|
+
bytes: 0,
|
|
592
|
+
error: err.message,
|
|
593
|
+
durationMs: Date.now() - start
|
|
594
|
+
};
|
|
595
|
+
} finally {
|
|
596
|
+
clearTimeout(timer);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Run the prerender pipeline. Programmatic entry-point for the
|
|
601
|
+
* `bin/inertia-prerender` CLI.
|
|
602
|
+
*
|
|
603
|
+
* Two output modes (which can be combined via `"both"`):
|
|
604
|
+
*
|
|
605
|
+
* - **`warmup`** (default) — fetches each route, discards the body. Use to
|
|
606
|
+
* populate the SSR view cache after a deploy so the first real visitor
|
|
607
|
+
* doesn't pay the SSR latency cost.
|
|
608
|
+
* - **`static`** — fetches each route and writes the response body to
|
|
609
|
+
* `<outDir>/<route>/index.html`. Serve those files directly via
|
|
610
|
+
* nginx/CDN, bypassing Express entirely.
|
|
611
|
+
*
|
|
612
|
+
* Bounded-concurrency: `concurrency` workers consume routes from a shared
|
|
613
|
+
* counter; the returned `results` preserve input order. Failures on
|
|
614
|
+
* individual routes never halt the run — the per-route summary lets a CI
|
|
615
|
+
* step choose to fail-loud.
|
|
616
|
+
*
|
|
617
|
+
* @param options Pipeline configuration — see {@link PrerenderOptions}.
|
|
618
|
+
*/
|
|
619
|
+
async function prerender(options) {
|
|
620
|
+
const concurrency = Math.max(1, options.concurrency ?? 4);
|
|
621
|
+
const results = new Array(options.routes.length);
|
|
622
|
+
let next = 0;
|
|
623
|
+
/**
|
|
624
|
+
* Claim the next route index and process it, then recurse. Recursion
|
|
625
|
+
* (instead of a loop) avoids both `while`-spin and `no-await-in-loop`.
|
|
626
|
+
*/
|
|
627
|
+
async function worker() {
|
|
628
|
+
const i = next;
|
|
629
|
+
next += 1;
|
|
630
|
+
if (i >= options.routes.length) return;
|
|
631
|
+
results[i] = await runOne(options, options.routes[i] ?? "");
|
|
632
|
+
await worker();
|
|
633
|
+
}
|
|
634
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, options.routes.length) }, async () => worker()));
|
|
635
|
+
const ok = results.filter((r) => r && r.status !== null && r.status < 400 && !r.error).length;
|
|
636
|
+
const failed = results.length - ok;
|
|
637
|
+
return {
|
|
638
|
+
total: results.length,
|
|
639
|
+
ok,
|
|
640
|
+
failed,
|
|
641
|
+
results
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
//#endregion
|
|
645
|
+
//#region src/express/ssr-fetcher.ts
|
|
646
|
+
const EMPTY_RESULT = {
|
|
647
|
+
body: "",
|
|
648
|
+
head: ""
|
|
649
|
+
};
|
|
650
|
+
/**
|
|
651
|
+
* Resolve after the given number of milliseconds.
|
|
652
|
+
*
|
|
653
|
+
* @param ms Delay in milliseconds.
|
|
654
|
+
*/
|
|
655
|
+
async function sleep(ms) {
|
|
656
|
+
return new Promise((resolve) => {
|
|
657
|
+
setTimeout(resolve, ms);
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Build an {@link SsrRenderer} that POSTs the page object to a separate Node
|
|
662
|
+
* SSR process (or load-balanced pool) and returns the rendered
|
|
663
|
+
* `{ head, body }`.
|
|
664
|
+
*
|
|
665
|
+
* Matches the wire protocol used by `@inertiajs/{vue3,react,svelte}/server`
|
|
666
|
+
* `createServer()`: `POST <url>` with `application/json` body containing the
|
|
667
|
+
* page object, response is `{ head: string[], body: string }`.
|
|
668
|
+
*
|
|
669
|
+
* Adds production hardening on top of the bare protocol:
|
|
670
|
+
* - Per-attempt timeout (default 5000ms) via `AbortController`.
|
|
671
|
+
* - Recursive retry with exponential backoff (`retryBaseMs * 2^attempt`).
|
|
672
|
+
* - Circuit breaker — opens after `breakerThreshold` consecutive failed
|
|
673
|
+
* *requests* (not attempts); half-opens after `breakerCooldownMs` so a
|
|
674
|
+
* probe request can re-close it on success.
|
|
675
|
+
* - `fallback`: `"client"` returns an empty SSR result so the client
|
|
676
|
+
* renders alone; `"throw"` rethrows so Express's error handler can react.
|
|
677
|
+
*
|
|
678
|
+
* @param options Fetcher configuration — see {@link InertiaSsrFetcherOptions}.
|
|
679
|
+
*/
|
|
680
|
+
function createInertiaSsrFetcher(options) {
|
|
681
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
682
|
+
const timeoutMs = options.timeoutMs ?? 5e3;
|
|
683
|
+
const retries = options.retries ?? 2;
|
|
684
|
+
const retryBaseMs = options.retryBaseMs ?? 100;
|
|
685
|
+
const breakerThreshold = options.breakerThreshold ?? 5;
|
|
686
|
+
const breakerCooldownMs = options.breakerCooldownMs ?? 3e4;
|
|
687
|
+
const fallback = options.fallback ?? "client";
|
|
688
|
+
const breakerEnabled = breakerThreshold > 0 && Number.isFinite(breakerThreshold);
|
|
689
|
+
const state = {
|
|
690
|
+
failures: 0,
|
|
691
|
+
openedAt: null
|
|
692
|
+
};
|
|
693
|
+
/**
|
|
694
|
+
* Returns `true` while the breaker is open and within its cooldown window;
|
|
695
|
+
* a return of `false` after a previous failure means we are half-open and
|
|
696
|
+
* the caller may attempt a probe request.
|
|
697
|
+
*/
|
|
698
|
+
function breakerOpen() {
|
|
699
|
+
if (!breakerEnabled || state.openedAt === null) return false;
|
|
700
|
+
if (Date.now() - state.openedAt >= breakerCooldownMs) return false;
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Reset the breaker after a successful render.
|
|
705
|
+
*/
|
|
706
|
+
function recordSuccess() {
|
|
707
|
+
state.failures = 0;
|
|
708
|
+
state.openedAt = null;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Increment the failure counter; open the breaker when the threshold is hit.
|
|
712
|
+
*/
|
|
713
|
+
function recordFailure() {
|
|
714
|
+
state.failures += 1;
|
|
715
|
+
if (breakerEnabled && state.failures >= breakerThreshold) state.openedAt = Date.now();
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Fire one SSR request — the unit the retry loop wraps. Throws on
|
|
719
|
+
* timeout, network error, or non-2xx response.
|
|
720
|
+
*
|
|
721
|
+
* @param page Page object to send to the SSR server.
|
|
722
|
+
*/
|
|
723
|
+
async function attempt(page) {
|
|
724
|
+
const controller = new AbortController();
|
|
725
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
726
|
+
try {
|
|
727
|
+
const res = await fetchImpl(options.url, {
|
|
728
|
+
method: "POST",
|
|
729
|
+
headers: {
|
|
730
|
+
"Content-Type": "application/json",
|
|
731
|
+
...options.headers
|
|
732
|
+
},
|
|
733
|
+
body: JSON.stringify(page),
|
|
734
|
+
signal: controller.signal
|
|
735
|
+
});
|
|
736
|
+
if (!res.ok) throw new Error(`Inertia SSR request failed: ${res.status} ${res.statusText}`);
|
|
737
|
+
const json = await res.json();
|
|
738
|
+
return {
|
|
739
|
+
body: json.body ?? "",
|
|
740
|
+
head: json.head,
|
|
741
|
+
bodyIsFullRoot: json.bodyIsFullRoot
|
|
742
|
+
};
|
|
743
|
+
} finally {
|
|
744
|
+
clearTimeout(timer);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return async ({ page }) => {
|
|
748
|
+
if (breakerOpen()) {
|
|
749
|
+
if (fallback === "throw") throw new Error("Inertia SSR circuit breaker is open");
|
|
750
|
+
return EMPTY_RESULT;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Run one render attempt, recursing with exponential backoff until the
|
|
754
|
+
* retry budget is spent. Records breaker success on the first win.
|
|
755
|
+
*
|
|
756
|
+
* @param remaining Retry attempts left before the error propagates.
|
|
757
|
+
*/
|
|
758
|
+
const attemptWithRetry = async (remaining) => {
|
|
759
|
+
try {
|
|
760
|
+
const result = await attempt(page);
|
|
761
|
+
recordSuccess();
|
|
762
|
+
return result;
|
|
763
|
+
} catch (err) {
|
|
764
|
+
if (remaining <= 0) throw err;
|
|
765
|
+
await sleep(retryBaseMs * 2 ** (retries - remaining));
|
|
766
|
+
return attemptWithRetry(remaining - 1);
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
try {
|
|
770
|
+
return await attemptWithRetry(retries);
|
|
771
|
+
} catch (lastError) {
|
|
772
|
+
recordFailure();
|
|
773
|
+
if (fallback === "throw") throw lastError;
|
|
774
|
+
return EMPTY_RESULT;
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
//#endregion
|
|
779
|
+
//#region src/express/vite-manifest.ts
|
|
780
|
+
/**
|
|
781
|
+
* Build a stable asset version from a Vite-generated `manifest.json`.
|
|
782
|
+
*
|
|
783
|
+
* Compatible with both `@inertiajs/vite-plugin` output and the standard
|
|
784
|
+
* `--manifest` option from `vite build`. The returned function is suitable
|
|
785
|
+
* to pass directly as the `version` option of the Inertia middleware.
|
|
786
|
+
*
|
|
787
|
+
* Strategy:
|
|
788
|
+
* - Read the manifest file.
|
|
789
|
+
* - Hash a canonicalized list of `(key -> file)` entries (only the `file`
|
|
790
|
+
* field per entry, so unrelated manifest metadata never perturbs the hash).
|
|
791
|
+
* - Cache the result; re-read only after the file's mtime changes (when
|
|
792
|
+
* `watchMtime` is true) or never re-read (when false).
|
|
793
|
+
* - Returns `null` when the manifest file is absent or unparseable.
|
|
794
|
+
*
|
|
795
|
+
* A new manifest (different hashed output paths) yields a new version, which
|
|
796
|
+
* the middleware embeds in the SSR cache key — so a deploy atomically
|
|
797
|
+
* invalidates every existing cache entry.
|
|
798
|
+
*
|
|
799
|
+
* @param options Path to the manifest plus the mtime-watch flag.
|
|
800
|
+
*/
|
|
801
|
+
function viteManifestVersion(options) {
|
|
802
|
+
const watchMtime = options.watchMtime ?? true;
|
|
803
|
+
let cached = null;
|
|
804
|
+
/**
|
|
805
|
+
* Resolve to the current version string, or `null` when the manifest can't
|
|
806
|
+
* be read or parsed. Hits the in-memory cache when the mtime is unchanged.
|
|
807
|
+
*/
|
|
808
|
+
async function read() {
|
|
809
|
+
let mtimeMs;
|
|
810
|
+
try {
|
|
811
|
+
mtimeMs = (await stat(options.manifestPath)).mtimeMs;
|
|
812
|
+
} catch {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
if (cached && (!watchMtime || cached.mtimeMs === mtimeMs)) return cached.version;
|
|
816
|
+
let raw;
|
|
817
|
+
try {
|
|
818
|
+
raw = await readFile(options.manifestPath, "utf8");
|
|
819
|
+
} catch {
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
let parsed;
|
|
823
|
+
try {
|
|
824
|
+
parsed = JSON.parse(raw);
|
|
825
|
+
} catch {
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
const lines = Object.keys(parsed).sort().map((key) => {
|
|
829
|
+
const entry = parsed[key];
|
|
830
|
+
if (entry && typeof entry === "object" && "file" in entry) return `${key}=${entry.file}`;
|
|
831
|
+
return `${key}=${JSON.stringify(entry)}`;
|
|
832
|
+
});
|
|
833
|
+
const version = createHash("sha256").update(lines.join("\n")).digest("hex").slice(0, 16);
|
|
834
|
+
cached = {
|
|
835
|
+
mtimeMs,
|
|
836
|
+
version
|
|
837
|
+
};
|
|
838
|
+
return version;
|
|
839
|
+
}
|
|
840
|
+
return read;
|
|
841
|
+
}
|
|
842
|
+
//#endregion
|
|
843
|
+
//#region src/express/index.ts
|
|
844
|
+
/**
|
|
845
|
+
* Express adapter for Inertia.js v3.
|
|
846
|
+
*
|
|
847
|
+
* Default entry point of `@blacklizard/inertia-express`. The framework-agnostic
|
|
848
|
+
* core is available at `@blacklizard/inertia-express/core`.
|
|
849
|
+
*/
|
|
850
|
+
var express_default = inertia;
|
|
851
|
+
//#endregion
|
|
852
|
+
export { INERTIA_HEADERS, INERTIA_LOCATION_STATUS, INERTIA_REDIRECT_STATUS, always, applyEdgeCache, canonicalStringify, computeSsrCacheKey, createInertiaSsrFetcher, createMemoryCacheStore, deepMerge, express_default as default, defer, encodePageScript, inertia, lazy, merge, optional, prerender, sendInertiaLocation, sendInertiaResponse, sendVersionMismatch, viteManifestVersion };
|
|
853
|
+
|
|
854
|
+
//# sourceMappingURL=index.js.map
|