@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 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
+ [![CI](https://github.com/blacklizard/inertia-express/actions/workflows/ci.yml/badge.svg)](https://github.com/blacklizard/inertia-express/actions/workflows/ci.yml)
4
+ [![Conformance](https://github.com/blacklizard/inertia-express/actions/workflows/conformance.yml/badge.svg)](https://github.com/blacklizard/inertia-express/actions/workflows/conformance.yml)
5
+ [![npm version](https://img.shields.io/npm/v/@blacklizard/inertia-express.svg)](https://www.npmjs.com/package/@blacklizard/inertia-express)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
+ [![Node.js >= 24](https://img.shields.io/badge/node-%3E%3D24-brightgreen)](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
+ });