@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 blacklizard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
# @blacklizard/inertia-express
|
|
2
|
+
|
|
3
|
+
[](https://github.com/blacklizard/inertia-express/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/blacklizard/inertia-express/actions/workflows/conformance.yml)
|
|
5
|
+
[](https://www.npmjs.com/package/@blacklizard/inertia-express)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://nodejs.org)
|
|
8
|
+
|
|
9
|
+
A production-ready, TypeScript-first server adapter for [Inertia.js](https://inertiajs.com) v3, with first-class support for Express.js.
|
|
10
|
+
|
|
11
|
+
Inertia.js is a glue layer that lets you build modern, single-page-app-style frontends (Vue, React, Svelte) on top of classic server-side routing and controllers. This package implements the Inertia v3 server protocol for Node.js, mirroring what the official Laravel, Rails, and Phoenix adapters do.
|
|
12
|
+
|
|
13
|
+
The internal core is framework-agnostic, so future adapters for Fastify, Hono, NestJS, or others can be built on the same primitives.
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- Full implementation of the Inertia.js v3 protocol
|
|
18
|
+
- `res.inertia(component, props, options)` Express helper
|
|
19
|
+
- Shared props (static or per-request)
|
|
20
|
+
- Lazy, optional, deferred, always, merge, and deep-merge props
|
|
21
|
+
- Partial reloads (`X-Inertia-Partial-Data` / `X-Inertia-Partial-Except`)
|
|
22
|
+
- Asset version handling and automatic 409 + `X-Inertia-Location` on mismatch
|
|
23
|
+
- Inertia-aware redirects (auto 303 after PUT/PATCH/DELETE)
|
|
24
|
+
- External redirects via `res.inertiaLocation()`
|
|
25
|
+
- Validation error helpers
|
|
26
|
+
- Pluggable root view
|
|
27
|
+
- Pluggable SSR hook + production-grade out-of-process SSR client (timeout / retry / circuit breaker)
|
|
28
|
+
- View cache with pluggable store (memory built-in, Redis via `@blacklizard/inertia-cache-redis`)
|
|
29
|
+
- CDN edge-cache policy (`Cache-Control` / `Vary` for non-Inertia responses)
|
|
30
|
+
- Vite manifest → version helper (auto-invalidates SSR cache on deploy)
|
|
31
|
+
- Prerender CLI + programmatic API (warmup or static export)
|
|
32
|
+
- TypeScript-first API with declaration merging on `express`
|
|
33
|
+
- ESM-only, Node.js 20+
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pnpm add @blacklizard/inertia-express
|
|
39
|
+
# or
|
|
40
|
+
npm install @blacklizard/inertia-express
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`express` is a peer dependency:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pnpm add express
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick start
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import express from "express";
|
|
53
|
+
import { inertia } from "@blacklizard/inertia-express";
|
|
54
|
+
|
|
55
|
+
const app = express();
|
|
56
|
+
|
|
57
|
+
app.use(express.urlencoded({ extended: true }));
|
|
58
|
+
app.use(express.json());
|
|
59
|
+
|
|
60
|
+
app.use(
|
|
61
|
+
inertia({
|
|
62
|
+
version: () => process.env.ASSET_VERSION ?? null,
|
|
63
|
+
sharedProps: (req) => ({
|
|
64
|
+
auth: { user: req.user ?? null },
|
|
65
|
+
flash: req.session?.flash ?? {},
|
|
66
|
+
}),
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
app.get("/users", async (req, res) => {
|
|
71
|
+
await res.inertia("Users/Index", {
|
|
72
|
+
users: await db.users.findMany(),
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
app.listen(3000);
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
Pass options to `inertia({ ... })`:
|
|
82
|
+
|
|
83
|
+
| Option | Type | Description |
|
|
84
|
+
| ------------------ | ------------------------------------- | ---------------------------------------------------------------------------- |
|
|
85
|
+
| `version` | `string \| (req) => string \| null` | Asset version. Triggers a forced reload on mismatch. |
|
|
86
|
+
| `sharedProps` | `object \| (req) => object` | Props merged into every page response. |
|
|
87
|
+
| `rootView` | `({ page, req, res }) => string` | Custom HTML shell for non-Inertia (first-load) responses. |
|
|
88
|
+
| `ssr` | `SsrRenderer` | SSR hook returning `{ head, body }`. See [SSR](#server-side-rendering-ssr). |
|
|
89
|
+
| `cache` | `InertiaCacheOptions` | View cache for SSR HTML. See [View cache](#view-cache). |
|
|
90
|
+
| `edgeCache` | `EdgeCachePolicy` | CDN `Cache-Control` policy. See [Edge caching](#edge-caching-cdn). |
|
|
91
|
+
| `flashFromSession` | `boolean` | Auto-promote `req.session.errors` / `flash` into shared props. Default off. |
|
|
92
|
+
|
|
93
|
+
If no `rootView` is given, the middleware emits a minimal default HTML shell with an empty `<div id="app">` plus the v3 `<script type="application/json" data-page="app">` page tag, so you can wire up a Vite client manually.
|
|
94
|
+
|
|
95
|
+
## Rendering pages
|
|
96
|
+
|
|
97
|
+
Inside any handler:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
app.get("/dashboard", async (req, res) => {
|
|
101
|
+
await res.inertia("Dashboard", {
|
|
102
|
+
stats: await dashboard.getStats(),
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The middleware automatically:
|
|
108
|
+
|
|
109
|
+
- Detects `X-Inertia: true` and returns the page object as JSON.
|
|
110
|
+
- Otherwise renders the configured root view with the page object embedded.
|
|
111
|
+
- Applies version mismatch detection on Inertia GETs.
|
|
112
|
+
- Filters props for partial reloads.
|
|
113
|
+
|
|
114
|
+
## Shared props
|
|
115
|
+
|
|
116
|
+
Shared props are evaluated on every render and merged into the page's props. Per-page props win on key collisions.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
inertia({
|
|
120
|
+
sharedProps: async (req) => ({
|
|
121
|
+
auth: { user: req.user ?? null },
|
|
122
|
+
csrf: req.csrfToken?.(),
|
|
123
|
+
flash: req.session?.flash ?? {},
|
|
124
|
+
errors: req.session?.errors ?? {},
|
|
125
|
+
}),
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Lazy, optional, deferred, always, and merge props
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { lazy, optional, defer, always, merge, deepMerge } from "@blacklizard/inertia-express";
|
|
133
|
+
|
|
134
|
+
await res.inertia("Dashboard", {
|
|
135
|
+
// Always evaluated.
|
|
136
|
+
user: req.user,
|
|
137
|
+
|
|
138
|
+
// Evaluated on every visit, but the function form lets you defer the work.
|
|
139
|
+
recent: lazy(() => userService.recent()),
|
|
140
|
+
|
|
141
|
+
// Omitted on full visits; only sent if requested via partial reload.
|
|
142
|
+
heavy: optional(() => analytics.expensiveQuery()),
|
|
143
|
+
|
|
144
|
+
// Omitted on initial visit; the client automatically requests it via a
|
|
145
|
+
// follow-up partial reload after mount, grouped by `"dashboard"`.
|
|
146
|
+
stats: defer(() => dashboard.getStats(), "dashboard"),
|
|
147
|
+
|
|
148
|
+
// Included on every response, even partial reloads that filter it out.
|
|
149
|
+
flash: always(() => req.session.flash),
|
|
150
|
+
|
|
151
|
+
// Merge with the existing client-side prop on partial reloads instead of
|
|
152
|
+
// replacing it. Useful for paginated lists / infinite-scroll feeds. Pass a
|
|
153
|
+
// match-on field to dedupe array items instead of blindly appending.
|
|
154
|
+
notifications: merge(await db.notifications.page(req.query.page), "id"),
|
|
155
|
+
|
|
156
|
+
// Recursive deep-merge into the existing client-side prop.
|
|
157
|
+
filters: deepMerge({ sort: { field: "name" } }),
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
A client can send `X-Inertia-Reset: <key>` to have a merge / deep-merge prop
|
|
162
|
+
replaced instead of merged on that visit.
|
|
163
|
+
|
|
164
|
+
## Partial reloads
|
|
165
|
+
|
|
166
|
+
When the client requests a partial reload, it sends:
|
|
167
|
+
|
|
168
|
+
- `X-Inertia-Partial-Component`: the component currently mounted.
|
|
169
|
+
- `X-Inertia-Partial-Data`: comma-separated prop keys to include.
|
|
170
|
+
- `X-Inertia-Partial-Except`: comma-separated prop keys to exclude (takes precedence over `Partial-Data`).
|
|
171
|
+
|
|
172
|
+
The middleware applies this filtering automatically. Partial reload filtering is only applied when `X-Inertia-Partial-Component` matches the rendered component, so cross-page navigations don't accidentally drop props.
|
|
173
|
+
|
|
174
|
+
## Validation errors
|
|
175
|
+
|
|
176
|
+
The simplest pattern is to share `req.session.errors` from `sharedProps`:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
inertia({
|
|
180
|
+
sharedProps: (req) => ({ errors: req.session?.errors ?? {} }),
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The middleware also exposes `res.inertiaErrors(errors, bag?)`, which:
|
|
185
|
+
|
|
186
|
+
- Writes errors to `req.session.errors` if a session is present.
|
|
187
|
+
- Otherwise stores them on `res.locals.inertiaErrors` for the same response.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
app.post("/users", (req, res) => {
|
|
191
|
+
const errors = validate(req.body);
|
|
192
|
+
if (Object.keys(errors).length) {
|
|
193
|
+
res.inertiaErrors(errors);
|
|
194
|
+
return res.redirect("/users/new");
|
|
195
|
+
}
|
|
196
|
+
// ...success path
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
You may pass an error bag name to namespace errors under `errors[bag]`.
|
|
201
|
+
|
|
202
|
+
## Redirects
|
|
203
|
+
|
|
204
|
+
For Inertia requests, `res.redirect(url)` after a `PUT`, `PATCH`, or `DELETE` is automatically promoted to status `303`. This is required by the Inertia protocol to ensure the follow-up request is a `GET`.
|
|
205
|
+
|
|
206
|
+
For external redirects (different host or full reload), use `res.inertiaLocation()`:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
res.inertiaLocation("https://billing.example.com/checkout");
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
For Inertia (XHR) visits this returns `409 X-Inertia-Location: <url>` which the client handles by performing `window.location = url`. For plain browser visits it falls back to `302 Location: <url>`.
|
|
213
|
+
|
|
214
|
+
## Asset versioning
|
|
215
|
+
|
|
216
|
+
Pass a `version` resolver. When a client's `X-Inertia-Version` header doesn't match the current value on a `GET`, the middleware responds with `409` + `X-Inertia-Location: <same-url>`, and the Inertia client performs a full reload.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
inertia({
|
|
220
|
+
version: () => readFileSync("public/build/manifest.json", "utf8"),
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
A version of `null` (the default) disables mismatch detection.
|
|
225
|
+
|
|
226
|
+
## Custom root view
|
|
227
|
+
|
|
228
|
+
Use any template engine, JSX, or string template you like:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
inertia({
|
|
232
|
+
rootView: ({ page }) => myPugTemplate({ page }),
|
|
233
|
+
});
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Inside the root view, embed the page object in the Inertia v3 `<script type="application/json" data-page="app">` tag. The helper `encodePageScript(page)` encodes the JSON safely for you:
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
import { encodePageScript } from "@blacklizard/inertia-express";
|
|
240
|
+
|
|
241
|
+
inertia({
|
|
242
|
+
rootView: ({ page }) => `
|
|
243
|
+
<!doctype html>
|
|
244
|
+
<html>
|
|
245
|
+
<body>
|
|
246
|
+
<div id="app"></div>
|
|
247
|
+
<script data-page="app" type="application/json">${encodePageScript(page)}</script>
|
|
248
|
+
<script type="module" src="/build/app.js"></script>
|
|
249
|
+
</body>
|
|
250
|
+
</html>
|
|
251
|
+
`,
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Server-side rendering (SSR)
|
|
256
|
+
|
|
257
|
+
Pass an `ssr` hook to render pages on the server before the first byte goes to the browser. The hook returns `{ head, body }` (string or string[] for `head`); the middleware injects it into the default root view, or exposes it as `res.locals.ssr` for a custom `rootView` to use.
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
inertia({
|
|
261
|
+
ssr: async ({ page }) => {
|
|
262
|
+
const { default: render } = await import("./dist/ssr/entry-server.js");
|
|
263
|
+
return render(page);
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Returning `null` from the hook skips SSR for that request (useful for client-only fallback when SSR is unavailable).
|
|
269
|
+
|
|
270
|
+
### Out-of-process SSR via `createInertiaSsrFetcher`
|
|
271
|
+
|
|
272
|
+
For production, run SSR in a separate process pool (e.g. `@blacklizard/inertia-ssr-worker`) and use `createInertiaSsrFetcher` to call it over HTTP:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
import { inertia, createInertiaSsrFetcher } from "@blacklizard/inertia-express";
|
|
276
|
+
|
|
277
|
+
inertia({
|
|
278
|
+
ssr: createInertiaSsrFetcher({
|
|
279
|
+
url: "http://ssr.internal/render",
|
|
280
|
+
timeoutMs: 5000,
|
|
281
|
+
retries: 2,
|
|
282
|
+
breakerThreshold: 5,
|
|
283
|
+
breakerCooldownMs: 30_000,
|
|
284
|
+
fallback: "client",
|
|
285
|
+
}),
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
| Option | Default | Description |
|
|
290
|
+
| ------------------- | ------- | ---------------------------------------------------------------------- |
|
|
291
|
+
| `url` | — | SSR endpoint URL (POSTs the page object as JSON). |
|
|
292
|
+
| `timeoutMs` | `5000` | Per-attempt timeout. |
|
|
293
|
+
| `retries` | `2` | Retries on failure (up to 3 total tries). Exponential backoff. |
|
|
294
|
+
| `retryBaseMs` | `100` | Initial backoff, doubles per retry. |
|
|
295
|
+
| `breakerThreshold` | `5` | Open circuit after N consecutive failures. `0`/`Infinity` disables. |
|
|
296
|
+
| `breakerCooldownMs` | `30000` | How long the breaker stays open before allowing one probe. |
|
|
297
|
+
| `fallback` | `"client"` | `"client"` returns empty SSR (client renders); `"throw"` rethrows. |
|
|
298
|
+
| `headers` | — | Extra headers per request. |
|
|
299
|
+
|
|
300
|
+
The wire protocol matches `@inertiajs/{vue3,react,svelte}/server` `createServer()`: `POST /` with the page object as JSON, response `{ head: string[], body: string }`.
|
|
301
|
+
|
|
302
|
+
## View cache
|
|
303
|
+
|
|
304
|
+
Layer a cache between the request and the SSR call so cached HTML can be served without invoking the renderer. The cache key incorporates the page component, props, URL, and `version`, so deploys atomically retire all entries.
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
import { inertia, createMemoryCacheStore } from "@blacklizard/inertia-express";
|
|
308
|
+
|
|
309
|
+
inertia({
|
|
310
|
+
cache: {
|
|
311
|
+
store: createMemoryCacheStore({ max: 500 }),
|
|
312
|
+
ttlSeconds: 300,
|
|
313
|
+
keyPrefix: "inertia:ssr",
|
|
314
|
+
vary: ({ req }) => !req.session?.user, // skip auth'd traffic
|
|
315
|
+
discriminator: (req) => req.headers["accept-language"]?.toString(),
|
|
316
|
+
onError: (op, err) => log.warn({ op, err }, "ssr cache error"),
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
| Option | Default | Description |
|
|
322
|
+
| --------------- | -------------- | ---------------------------------------------------------------------------- |
|
|
323
|
+
| `store` | — | `SsrCacheStore` impl. See `createMemoryCacheStore` or `inertia-cache-redis`. |
|
|
324
|
+
| `ttlSeconds` | `300` | Entry TTL. |
|
|
325
|
+
| `keyPrefix` | `"inertia:ssr"` | Namespace for keys. |
|
|
326
|
+
| `vary` | GET + 200 + non-Inertia | Per-request bypass — return `false` to skip read+write. |
|
|
327
|
+
| `discriminator` | — | Extra cache-key segment (locale, A/B bucket, theme). |
|
|
328
|
+
| `onError` | — | Called on `get`/`set`/`delete` failures. Cache failures are non-fatal. |
|
|
329
|
+
|
|
330
|
+
For multi-process deployments, use the Redis store:
|
|
331
|
+
|
|
332
|
+
```bash
|
|
333
|
+
pnpm add @blacklizard/inertia-cache-redis
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
import { createRedisCacheStore } from "@blacklizard/inertia-cache-redis";
|
|
338
|
+
|
|
339
|
+
inertia({
|
|
340
|
+
cache: { store: createRedisCacheStore({ client: redis, keyPrefix: "inertia:ssr" }) },
|
|
341
|
+
});
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
You can also stack `createMemoryCacheStore` as L1 in front of Redis L2 (see `inertia-cache-redis` README).
|
|
345
|
+
|
|
346
|
+
## Asset versioning from a Vite manifest
|
|
347
|
+
|
|
348
|
+
`viteManifestVersion` reads `manifest.json` and returns a hash that changes whenever any built asset changes. Pair it with the `version` option so deploys force clients to reload AND atomically retire SSR cache entries.
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
import { viteManifestVersion } from "@blacklizard/inertia-express";
|
|
352
|
+
|
|
353
|
+
inertia({
|
|
354
|
+
version: viteManifestVersion({
|
|
355
|
+
manifestPath: "public/build/.vite/manifest.json",
|
|
356
|
+
watchMtime: true,
|
|
357
|
+
}),
|
|
358
|
+
});
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
`watchMtime: false` reads once on boot — slightly faster but requires a process restart to pick up new builds.
|
|
362
|
+
|
|
363
|
+
## Prerender / static export
|
|
364
|
+
|
|
365
|
+
Two output modes (combinable):
|
|
366
|
+
|
|
367
|
+
- **`warmup`** — fetches each route, discards the body. Populates the SSR view cache after a deploy so the first real visitor doesn't pay the SSR latency cost.
|
|
368
|
+
- **`static`** — fetches each route, writes the response body to `<outDir>/<route>/index.html`. Serve those files directly via nginx/CDN, bypassing Express.
|
|
369
|
+
|
|
370
|
+
CLI (installed via `bin`):
|
|
371
|
+
|
|
372
|
+
```bash
|
|
373
|
+
inertia-prerender \
|
|
374
|
+
--base-url http://127.0.0.1:3000 \
|
|
375
|
+
--route / --route /about --route /pricing \
|
|
376
|
+
--mode both --out-dir dist/static \
|
|
377
|
+
--concurrency 8 --timeout-ms 30000 \
|
|
378
|
+
--header 'X-Prerender: 1' \
|
|
379
|
+
--fail-on-error
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
`--routes <file.json>` reads an array of routes from JSON. `--quiet` suppresses per-route lines.
|
|
383
|
+
|
|
384
|
+
Programmatic:
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
import { prerender } from "@blacklizard/inertia-express";
|
|
388
|
+
|
|
389
|
+
const summary = await prerender({
|
|
390
|
+
baseUrl: "http://127.0.0.1:3000",
|
|
391
|
+
routes: ["/", "/about", "/pricing"],
|
|
392
|
+
mode: "warmup",
|
|
393
|
+
concurrency: 8,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
console.log(`${summary.ok}/${summary.total} ok, ${summary.failed} failed`);
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Failures on individual routes never halt the run — inspect `summary.results` to fail-loud in CI.
|
|
400
|
+
|
|
401
|
+
## Flash from session
|
|
402
|
+
|
|
403
|
+
Set `flashFromSession: true` to auto-promote `req.session.errors` and `req.session.flash` into shared props (and clear them after read), so you don't have to wire them into `sharedProps` manually:
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
inertia({ flashFromSession: true });
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Off by default — most apps already wire flash/errors via `sharedProps`. Requires a session middleware that exposes `req.session`.
|
|
410
|
+
|
|
411
|
+
## Edge caching (CDN)
|
|
412
|
+
|
|
413
|
+
Pass an `edgeCache` policy to apply `Cache-Control` headers to non-Inertia (first-load HTML) responses. The middleware always emits `Vary: Accept, X-Inertia` so a CDN can safely cache the HTML shell without ever serving it to an Inertia (XHR) request.
|
|
414
|
+
|
|
415
|
+
The policy receives `{ req, res, page }` and returns a decision object — or `null` to opt out for that request:
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
inertia({
|
|
419
|
+
edgeCache: ({ req, page }) => {
|
|
420
|
+
// Authed traffic: never cache.
|
|
421
|
+
if (req.session?.user) return null;
|
|
422
|
+
|
|
423
|
+
// Marketing Pages: cache aggressively.
|
|
424
|
+
if (page.component.startsWith("Marketing/")) {
|
|
425
|
+
return { sMaxAge: 3600, maxAge: 0, staleWhileRevalidate: 86400 };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Everything else: short edge cache, browser revalidates.
|
|
429
|
+
return { sMaxAge: 60, maxAge: 0, staleWhileRevalidate: 30 };
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
The decision shape:
|
|
435
|
+
|
|
436
|
+
| Field | Type | Description |
|
|
437
|
+
| ---------------------- | ---------- | -------------------------------------------------------------------- |
|
|
438
|
+
| `sMaxAge` | `number` | `s-maxage` for shared/CDN caches, in seconds. `<= 0` opts out. |
|
|
439
|
+
| `maxAge` | `number?` | Browser `max-age`. Defaults to `0` (revalidate immediately). |
|
|
440
|
+
| `staleWhileRevalidate` | `number?` | `stale-while-revalidate` window in seconds. |
|
|
441
|
+
| `vary` | `string[]?`| Extra `Vary` values appended to the default `Accept, X-Inertia`. |
|
|
442
|
+
|
|
443
|
+
The middleware skips header application when:
|
|
444
|
+
|
|
445
|
+
- The request is an Inertia (XHR) visit — those vary per session.
|
|
446
|
+
- The policy returns `null` or `sMaxAge <= 0`.
|
|
447
|
+
- The response is not 2xx.
|
|
448
|
+
|
|
449
|
+
For varying caches by cookie or locale, append to `vary`:
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
edgeCache: () => ({
|
|
453
|
+
sMaxAge: 60,
|
|
454
|
+
vary: ["Cookie", "Accept-Language"],
|
|
455
|
+
});
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Common pattern — pair with the in-process `cache` view cache so the SSR renderer is skipped entirely on cache hits, then let the CDN absorb the rest:
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
inertia({
|
|
462
|
+
cache: { ttlMs: 60_000 },
|
|
463
|
+
edgeCache: ({ req }) =>
|
|
464
|
+
req.session?.user ? null : { sMaxAge: 60, staleWhileRevalidate: 300 },
|
|
465
|
+
});
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
If you need to apply a decision from a custom code path (e.g. a non-Inertia route that you still want to share the same caching rules), `applyEdgeCache` is exported:
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
import { applyEdgeCache } from "@blacklizard/inertia-express";
|
|
472
|
+
|
|
473
|
+
applyEdgeCache({ sMaxAge: 60, staleWhileRevalidate: 300 }, res);
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
### Invalidation
|
|
477
|
+
|
|
478
|
+
Edge caches are time-based. To invalidate on deploy, change `version` — the Inertia client treats this as a version mismatch and forces a full reload, but the CDN cache itself ages out via `s-maxage`. For instant invalidation, use your CDN's purge API (Cloudflare `purge_cache`, Fastly `purge`, CloudFront invalidation) keyed on the URL or a surrogate tag.
|
|
479
|
+
|
|
480
|
+
## Subpath imports
|
|
481
|
+
|
|
482
|
+
The framework-agnostic core (no Express dependency) is exported separately:
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
import {
|
|
486
|
+
buildPage,
|
|
487
|
+
parseInertiaRequest,
|
|
488
|
+
resolveProps,
|
|
489
|
+
} from "@blacklizard/inertia-express/core";
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
Use this to build adapters for other frameworks.
|
|
493
|
+
|
|
494
|
+
## TypeScript
|
|
495
|
+
|
|
496
|
+
The package augments Express types so `req.inertia`, `res.inertia()`, `res.inertiaLocation()`, and `res.inertiaErrors()` are typed without extra setup, as long as `@blacklizard/inertia-express` is imported somewhere in your project.
|
|
497
|
+
|
|
498
|
+
For typed page props on the client side, generic `res.inertia<TProps>("Component", props)` is supported.
|
|
499
|
+
|
|
500
|
+
## Compatibility
|
|
501
|
+
|
|
502
|
+
- Inertia.js v3 protocol
|
|
503
|
+
- Node.js >= 24
|
|
504
|
+
- Express 4 or 5 (peer dep; tested against Express 5)
|
|
505
|
+
- ESM-only
|
|
506
|
+
|
|
507
|
+
## Known limitations
|
|
508
|
+
|
|
509
|
+
- The default HTML shell is intentionally minimal. Real apps should provide a `rootView`.
|
|
510
|
+
- This adapter does not bundle a session implementation — bring your own (`express-session`, `cookie-session`, etc.).
|
|
511
|
+
- Optimistic-update / `useHttp` server-side helpers are intentionally minimal here; the protocol primitives (`mergeProps`, `deepMergeProps`, `matchPropsOn`, `deferredProps`, `clearHistory`, `encryptHistory`) are exposed.
|
|
512
|
+
|
|
513
|
+
## License
|
|
514
|
+
|
|
515
|
+
MIT
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { argv, exit, stderr, stdout } from "node:process";
|
|
4
|
+
import { prerender } from "../dist/express/index.js";
|
|
5
|
+
|
|
6
|
+
function usage() {
|
|
7
|
+
return `\
|
|
8
|
+
Usage: inertia-prerender [options]
|
|
9
|
+
|
|
10
|
+
Options:
|
|
11
|
+
--base-url <url> Origin to fetch from (e.g. http://127.0.0.1:3000) [required]
|
|
12
|
+
--routes <file> Path to JSON file containing an array of routes, OR
|
|
13
|
+
--route <path> Repeatable. Single route, e.g. --route /
|
|
14
|
+
--mode <static|warmup|both> Default "warmup"
|
|
15
|
+
--out-dir <path> Required when --mode includes static
|
|
16
|
+
--concurrency <n> Default 4
|
|
17
|
+
--timeout-ms <n> Per-request timeout. Default 30000
|
|
18
|
+
--header <K: V> Repeatable. Extra request header
|
|
19
|
+
--fail-on-error Exit non-zero if any route fails
|
|
20
|
+
--quiet Suppress per-route lines
|
|
21
|
+
--help Show this help
|
|
22
|
+
`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv) {
|
|
26
|
+
const args = {
|
|
27
|
+
routes: [],
|
|
28
|
+
headers: {},
|
|
29
|
+
mode: "warmup",
|
|
30
|
+
concurrency: 4,
|
|
31
|
+
timeoutMs: 30000,
|
|
32
|
+
failOnError: false,
|
|
33
|
+
quiet: false,
|
|
34
|
+
};
|
|
35
|
+
const tokens = argv.slice(2);
|
|
36
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
37
|
+
const token = tokens[i];
|
|
38
|
+
const next = () => tokens[++i];
|
|
39
|
+
switch (token) {
|
|
40
|
+
case "--help":
|
|
41
|
+
case "-h":
|
|
42
|
+
args.help = true;
|
|
43
|
+
break;
|
|
44
|
+
case "--base-url":
|
|
45
|
+
args.baseUrl = next();
|
|
46
|
+
break;
|
|
47
|
+
case "--routes":
|
|
48
|
+
args.routesFile = next();
|
|
49
|
+
break;
|
|
50
|
+
case "--route":
|
|
51
|
+
args.routes.push(next());
|
|
52
|
+
break;
|
|
53
|
+
case "--mode":
|
|
54
|
+
args.mode = next();
|
|
55
|
+
break;
|
|
56
|
+
case "--out-dir":
|
|
57
|
+
args.outDir = next();
|
|
58
|
+
break;
|
|
59
|
+
case "--concurrency":
|
|
60
|
+
args.concurrency = Number(next());
|
|
61
|
+
break;
|
|
62
|
+
case "--timeout-ms":
|
|
63
|
+
args.timeoutMs = Number(next());
|
|
64
|
+
break;
|
|
65
|
+
case "--header": {
|
|
66
|
+
const raw = next();
|
|
67
|
+
const idx = raw.indexOf(":");
|
|
68
|
+
if (idx === -1) {
|
|
69
|
+
throw new Error(`bad --header: ${raw}`);
|
|
70
|
+
}
|
|
71
|
+
args.headers[raw.slice(0, idx).trim()] = raw.slice(idx + 1).trim();
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case "--fail-on-error":
|
|
75
|
+
args.failOnError = true;
|
|
76
|
+
break;
|
|
77
|
+
case "--quiet":
|
|
78
|
+
args.quiet = true;
|
|
79
|
+
break;
|
|
80
|
+
default:
|
|
81
|
+
throw new Error(`unknown argument: ${token}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return args;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function main() {
|
|
88
|
+
let args;
|
|
89
|
+
try {
|
|
90
|
+
args = parseArgs(argv);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
stderr.write(`error: ${err.message}\n${usage()}`);
|
|
93
|
+
exit(2);
|
|
94
|
+
}
|
|
95
|
+
if (args.help) {
|
|
96
|
+
stdout.write(usage());
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!args.baseUrl) {
|
|
100
|
+
stderr.write(`error: --base-url required\n${usage()}`);
|
|
101
|
+
exit(2);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let routes = args.routes;
|
|
105
|
+
if (args.routesFile) {
|
|
106
|
+
const raw = await readFile(args.routesFile, "utf8");
|
|
107
|
+
const parsed = JSON.parse(raw);
|
|
108
|
+
if (!Array.isArray(parsed)) {
|
|
109
|
+
stderr.write(`error: ${args.routesFile} must contain an array\n`);
|
|
110
|
+
exit(2);
|
|
111
|
+
}
|
|
112
|
+
routes = [...parsed, ...routes];
|
|
113
|
+
}
|
|
114
|
+
if (routes.length === 0) {
|
|
115
|
+
stderr.write(`error: no routes provided (use --route or --routes)\n`);
|
|
116
|
+
exit(2);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const summary = await prerender({
|
|
120
|
+
baseUrl: args.baseUrl,
|
|
121
|
+
routes,
|
|
122
|
+
mode: args.mode,
|
|
123
|
+
outDir: args.outDir,
|
|
124
|
+
concurrency: args.concurrency,
|
|
125
|
+
timeoutMs: args.timeoutMs,
|
|
126
|
+
headers: args.headers,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (!args.quiet) {
|
|
130
|
+
for (const r of summary.results) {
|
|
131
|
+
const tag = r.error ? "FAIL" : `${r.status}`;
|
|
132
|
+
stdout.write(
|
|
133
|
+
`[${tag}] ${r.route} (${r.durationMs}ms, ${r.bytes}B)${r.outputPath ? ` -> ${r.outputPath}` : ""}${r.error ? ` -- ${r.error}` : ""}\n`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
stdout.write(`done: ${summary.ok}/${summary.total} ok, ${summary.failed} failed\n`);
|
|
138
|
+
|
|
139
|
+
if (args.failOnError && summary.failed > 0) {
|
|
140
|
+
exit(1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
main().catch((err) => {
|
|
145
|
+
stderr.write(`fatal: ${err.stack ?? err.message}\n`);
|
|
146
|
+
exit(1);
|
|
147
|
+
});
|