@blacklizard/inertia-express 2.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,10 +17,11 @@ The internal core is framework-agnostic, so future adapters for Fastify, Hono, N
17
17
  - Full implementation of the Inertia.js v3 protocol
18
18
  - `res.inertia(component, props, options)` Express helper
19
19
  - Shared props (static or per-request)
20
- - Lazy, optional, deferred, always, merge, and deep-merge props
20
+ - Optional, deferred (with rescue), always, merge, deep-merge, once, and infinite-scroll props
21
+ - Live props (`liveProps` channel listeners) and `broadcastProps` payloads for your realtime server
21
22
  - Partial reloads (`X-Inertia-Partial-Data` / `X-Inertia-Partial-Except`)
22
23
  - Asset version handling and automatic 409 + `X-Inertia-Location` on mismatch
23
- - Inertia-aware redirects (auto 303 after PUT/PATCH/DELETE)
24
+ - Inertia-aware redirects (auto 303 after PUT/PATCH/DELETE/QUERY, 409 + `X-Inertia-Redirect` for `#fragment` targets, empty 200 → redirect back)
24
25
  - External redirects via `res.inertiaLocation()`
25
26
  - Validation error helpers
26
27
  - Pluggable root view
@@ -88,7 +89,7 @@ Pass options to `inertia({ ... })`:
88
89
  | `ssr` | `SsrRenderer` | SSR hook returning `{ head, body }`. See [SSR](#server-side-rendering-ssr). |
89
90
  | `cache` | `InertiaCacheOptions` | View cache for SSR HTML. See [View cache](#view-cache). |
90
91
  | `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
+ | `flashFromSession` | `boolean` | Surface `req.session.flash` as the top-level `flash` page key. Default off. |
92
93
 
93
94
  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
 
@@ -121,45 +122,210 @@ inertia({
121
122
  auth: { user: req.user ?? null },
122
123
  csrf: req.csrfToken?.(),
123
124
  flash: req.session?.flash ?? {},
124
- errors: req.session?.errors ?? {},
125
125
  }),
126
126
  });
127
127
  ```
128
128
 
129
- ## Lazy, optional, deferred, always, and merge props
129
+ Validation errors do not need wiring here: `errors` is shared on every page automatically (see [Validation errors](#validation-errors)).
130
+
131
+ ## Prop helpers
132
+
133
+ Every prop value is either a plain value, a bare closure (evaluated on each
134
+ response that includes it, awaited if it returns a promise), or a descriptor
135
+ built by one of the constructors below. Descriptors are chainable and mirror
136
+ inertia-laravel's prop classes; the resolver is a port of its `PropsResolver`.
137
+
138
+ ```ts
139
+ import { always, deepMerge, defer, live, merge, once, optional, scroll } from "@blacklizard/inertia-express";
140
+ ```
141
+
142
+ A bare closure replaces 2.x's `lazy()`:
143
+
144
+ ```ts
145
+ app.get("/dashboard", async (req, res) => {
146
+ await res.inertia("Dashboard", {
147
+ user: req.user,
148
+ recent: () => userService.recent(),
149
+ });
150
+ });
151
+ ```
152
+
153
+ ### `optional`
154
+
155
+ Omitted from the first load; resolved only when a partial reload asks for it.
156
+
157
+ ```ts
158
+ app.get("/reports", async (req, res) => {
159
+ await res.inertia("Reports", {
160
+ heavy: optional(() => analytics.expensiveQuery()),
161
+ });
162
+ });
163
+ ```
164
+
165
+ Laravel: `Inertia::optional()`
166
+
167
+ ### `defer`
168
+
169
+ Omitted from the first load; the client fetches it in a follow-up request with
170
+ the rest of its group (default `"default"`). `.rescue()` opts in to error
171
+ rescue: if the producer throws, the prop is dropped from the response, its path
172
+ is reported in `rescuedProps`, and the middleware's `onRescue` hook is called.
173
+
174
+ ```ts
175
+ app.get("/dashboard", async (req, res) => {
176
+ await res.inertia("Dashboard", {
177
+ stats: defer(() => dashboard.getStats(), "dashboard"),
178
+ feed: defer(() => feedService.load()).rescue(),
179
+ });
180
+ });
181
+ ```
182
+
183
+ Laravel: `Inertia::defer()`
184
+
185
+ ### `always`
186
+
187
+ Included on every response, even partial reloads that do not request it.
188
+ Takes a plain value or a closure.
189
+
190
+ ```ts
191
+ app.get("/inbox", async (req, res) => {
192
+ await res.inertia("Inbox", {
193
+ flash: always(() => req.session.flash),
194
+ });
195
+ });
196
+ ```
197
+
198
+ Laravel: `Inertia::always()`
199
+
200
+ ### `merge` / `deepMerge`
201
+
202
+ `merge(value)` tells the client to append the prop to its existing value at the
203
+ root; `deepMerge(value)` merges recursively. Chain `.append(path?, matchOn?)`
204
+ or `.prepend(path?, matchOn?)` to target nested paths, and `.matchOn(path)` to
205
+ dedupe items by a key instead of blindly appending. A path argument may be
206
+ `true` (the root, the default), a dotted path string, a list of paths, or a
207
+ `{ path: matchOnKey }` record. A client can send `X-Inertia-Reset: <key>` to
208
+ have a merge prop replaced instead of merged on that visit.
130
209
 
131
210
  ```ts
132
- import { lazy, optional, defer, always, merge, deepMerge } from "@blacklizard/inertia-express";
211
+ app.get("/notifications", async (req, res) => {
212
+ await res.inertia("Notifications", {
213
+ notifications: merge(() => db.notifications.page(req.query.page)).matchOn("id"),
214
+ feed: merge(() => feedService.page(req.query.page)).append("data", "id").prepend("pinned"),
215
+ filters: deepMerge({ sort: { field: "name" } }),
216
+ });
217
+ });
218
+ ```
133
219
 
134
- await res.inertia("Dashboard", {
135
- // Always evaluated.
136
- user: req.user,
220
+ Laravel: `Inertia::merge()` / `Inertia::deepMerge()`
137
221
 
138
- // Evaluated on every visit, but the function form lets you defer the work.
139
- recent: lazy(() => userService.recent()),
222
+ ### `once`
140
223
 
141
- // Omitted on full visits; only sent if requested via partial reload.
142
- heavy: optional(() => analytics.expensiveQuery()),
224
+ Resolved once and cached by the client across visits; later visits skip the
225
+ producer while the client still holds the value. `.as(key)` sets a custom cache
226
+ key (shareable across pages), `.until(delay)` sets an expiry (a number of
227
+ seconds, or a `Date`), and `.fresh()` forces re-resolution on this response.
228
+ `optional`, `defer` and `merge` descriptors also accept `.once()`.
229
+
230
+ ```ts
231
+ app.get("/settings", async (req, res) => {
232
+ await res.inertia("Settings", {
233
+ countries: once(() => geo.countries()).as("countries").until(3600),
234
+ plans: once(() => billing.plans()).until(new Date("2027-01-01")),
235
+ banner: once(() => cms.banner()).fresh(req.query.refresh === "1"),
236
+ });
237
+ });
238
+ ```
143
239
 
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"),
240
+ Laravel: `Inertia::once()`
147
241
 
148
- // Included on every response, even partial reloads that filter it out.
149
- flash: always(() => req.session.flash),
242
+ ### `scroll`
150
243
 
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"),
244
+ An infinite-scroll prop: merged at its `wrapper` key (default `"data"`) and
245
+ emitted with pagination metadata. `metadata` is required — either an object
246
+ `{ pageName, previousPage, nextPage, currentPage }` or a function deriving it
247
+ from the resolved value. The merge direction follows the client's
248
+ `X-Inertia-Infinite-Scroll-Merge-Intent` header (`prepend`, otherwise
249
+ `append`). Chain `.defer(group?)` to load the first page in a follow-up
250
+ request, and `.matchOn(path)` to dedupe.
155
251
 
156
- // Recursive deep-merge into the existing client-side prop.
157
- filters: deepMerge({ sort: { field: "name" } }),
252
+ ```ts
253
+ app.get("/posts", async (req, res) => {
254
+ await res.inertia("Posts", {
255
+ posts: scroll(() => postService.paginate(req.query.page), {
256
+ metadata: (page) => ({ pageName: "page", previousPage: page.prev, nextPage: page.next, currentPage: page.current }),
257
+ }).matchOn("id"),
258
+ comments: scroll(() => commentService.page(1), { wrapper: "items", metadata: { pageName: "page", previousPage: null, nextPage: 2, currentPage: 1 } }).defer(),
259
+ });
158
260
  });
159
261
  ```
160
262
 
161
- A client can send `X-Inertia-Reset: <key>` to have a merge / deep-merge prop
162
- replaced instead of merged on that visit.
263
+ Laravel: `Inertia::scroll()`
264
+
265
+ ### `live`
266
+
267
+ A prop the client refreshes when a realtime event arrives. `live(value, { on,
268
+ channel, throttle? })` resolves like a plain prop and lists its channel
269
+ listeners in the page object's `liveProps`; `always`, `optional`, `defer` and
270
+ `once` descriptors accept the same options through `.live(options)`. Merge and
271
+ scroll props cannot be live. Events are strings and channels are explicit: a
272
+ bare string channel is public, and `{ name, type }` sets `private`,
273
+ `presence` or `encrypted-private`. `throttle` is in milliseconds.
274
+
275
+ `broadcastProps(props, onRescue?)` resolves every prop (no partial or once
276
+ filtering, rescued props omitted) into the `{ __inertia: { props } }` payload
277
+ to send with the event; `resolveBroadcastProps` returns the bare props map. The
278
+ adapter does not open sockets: send the payload with your own realtime server.
279
+ `req.inertia.socketId` carries the `X-Socket-Id` header (`null` when absent) so
280
+ that server can skip the sender.
281
+
282
+ ```ts
283
+ import { broadcastProps, live } from "@blacklizard/inertia-express";
284
+
285
+ app.get("/leads", async (req, res) => {
286
+ await res.inertia("Leads/Index", {
287
+ leads: live(() => loadLeads(), { on: "LeadUpdated", channel: { name: "leads", type: "private" } }),
288
+ });
289
+ });
290
+
291
+ // After a lead changes (Socket.IO shown)
292
+ // The client subscribes to the declared event on the declared channel.
293
+ io.to("leads")
294
+ .except(req.inertia?.socketId ?? [])
295
+ .emit("LeadUpdated", await broadcastProps({ leads: () => loadLeads() }));
296
+ ```
297
+
298
+ Laravel: `Inertia::live()` / `->live()`
299
+
300
+ ### Rescue hook and fragment preservation
301
+
302
+ Pass `onRescue: (error, path, req) => …` to `inertia({ … })` to report errors
303
+ from rescued deferred props (the default logs them). Pass
304
+ `{ preserveFragment: true }` as the third argument to `res.inertia()` to have
305
+ the client keep the current URL `#fragment` across that visit.
306
+
307
+ ### Migrating from 2.x
308
+
309
+ | 2.x | 3.0 |
310
+ |---|---|
311
+ | `lazy(fn)` | `fn` (a bare closure) |
312
+ | `merge(value, matchOn)` | `merge(value).matchOn(matchOn)` |
313
+ | `deepMerge(value, matchOn)` | `deepMerge(value).matchOn(matchOn)` |
314
+ | `defer(fn, group)` | unchanged |
315
+ | `optional(fn)`, `always(fn)` | unchanged |
316
+ | `resolveProps()` → `{ props, deferred, merge, deepMerge, matchOn }` | `{ props, metadata }` |
317
+ | Types `MergeProp<T> = T & {...}` | descriptor types; the page `props` type is unchanged |
318
+
319
+ Other changes to check when upgrading:
320
+
321
+ - `merge(value, "id")` and `deepMerge(value, "id")` now throw a `TypeError` instead of silently ignoring the second argument. Use `.matchOn("id")`.
322
+ - Partial reloads follow dotted paths. `only: users.data` on a plain-object `users` now returns just `{ users: { data } }`; children of a closure or descriptor still bypass filtering and come back whole. `except: users.data` drops the whole top-level `users`, as in 2.x and in the upstream reference server; inertia-laravel keeps `users` and drops only the child, and the adapter deliberately differs from it here.
323
+ - Types: `DeferredProp` is renamed to `DeferProp`. `LazyProp`, `DeepMergeProp` and `PropValue` are removed. `MergeProp<T>` is now the descriptor interface returned by `merge()` / `deepMerge()`, not `T & {...}`.
324
+ - Core-entry adapter authors who build these objects by hand: `InertiaPage.preserveFragment` is now required, and `InertiaRequestInfo` gains the required fields `exceptOnceProps` and `mergeIntent`.
325
+ - `sharedProps` (the list of shared top-level keys) is now emitted on every page that has shared props.
326
+ - Descriptors are builders. Create them inside the handler for each request rather than mutating one hoisted to module level; the resolver itself only reads them, so a hoisted descriptor that is never changed after creation is safe to reuse.
327
+ - Sibling props still resolve concurrently, as in 2.x, and the output key order and metadata order are deterministic (insertion order).
328
+ - Errors thrown by the `onRescue` hook (or a rejected promise it returns) are logged with `console.error` and never propagated.
163
329
 
164
330
  ## Partial reloads
165
331
 
@@ -173,18 +339,10 @@ The middleware applies this filtering automatically. Partial reload filtering is
173
339
 
174
340
  ## Validation errors
175
341
 
176
- The simplest pattern is to share `req.session.errors` from `sharedProps`:
342
+ Record validation errors with `res.inertiaErrors(errors, bag?)`. Errors are stored per bag (`default` when no bag is given); a repeated bag replaces the earlier one and other bags are kept.
177
343
 
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.
344
+ - With a session, it writes `req.session.inertia.errors[bag] = errors`; the next render reads them and clears them (read-once).
345
+ - Without a session, it writes the same bags map to `res.locals.inertiaErrors` for the current response.
188
346
 
189
347
  ```ts
190
348
  app.post("/users", (req, res) => {
@@ -197,11 +355,22 @@ app.post("/users", (req, res) => {
197
355
  });
198
356
  ```
199
357
 
200
- You may pass an error bag name to namespace errors under `errors[bag]`.
358
+ The `errors` prop is shared on every page (`{}` when empty, listed in `sharedProps`, kept on partial reloads) whether or not `flashFromSession` is on, so no `sharedProps` wiring is needed. Bags are remapped when the page is read, as in inertia-laravel's `Middleware::resolveValidationErrors`:
359
+
360
+ - Errors stored without a bag come back flat: `props.errors.email`.
361
+ - If the request carries `X-Inertia-Error-Bag: signup`, those same errors come back as `props.errors.signup.email`.
362
+ - Errors stored under a named bag (`res.inertiaErrors(errors, "createUser")`) come back keyed by name, `props.errors.createUser.email`, when no `default` bag is stored. If a `default` bag is stored as well, only `default` is returned (flat, or under the header name) and the named bags are dropped.
363
+
364
+ An explicit `errors` key in your shared or page props wins over the shared one.
201
365
 
202
366
  ## Redirects
203
367
 
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`.
368
+ For Inertia requests, a `302` from `res.redirect(url)` after a `PUT`, `PATCH`, `DELETE` or `QUERY` is automatically promoted to status `303`. This is required by the Inertia protocol to ensure the follow-up request is a `GET`.
369
+
370
+ Two more inertia-laravel middleware rules apply to Inertia requests:
371
+
372
+ - **Fragment redirects** (`Middleware::onRedirectWithFragment`) — a redirect whose URL contains `#` is answered with `409`, `X-Inertia-Redirect: <url>` (encoded like `Location`) and an empty body, so the client can navigate and keep the fragment. Prefetch requests get the normal redirect.
373
+ - **Empty responses** (`Middleware::onEmptyResponse`) — a `200` with an empty body (`res.end()`, `res.send("")`) becomes a redirect back to the `Referer`, or `/` without one (`303` after PUT/PATCH/DELETE/QUERY, `302` otherwise). Responses whose headers were already sent (`res.flushHeaders()`, streaming) and handlers that never respond are not covered.
205
374
 
206
375
  For external redirects (different host or full reload), use `res.inertiaLocation()`:
207
376
 
@@ -213,7 +382,7 @@ For Inertia (XHR) visits this returns `409 X-Inertia-Location: <url>` which the
213
382
 
214
383
  ## Asset versioning
215
384
 
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.
385
+ 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 `X-Inertia-Version: <current version>` (inertia-laravel's `Middleware::onVersionChange`), and the Inertia client performs a full reload.
217
386
 
218
387
  ```ts
219
388
  inertia({
@@ -428,13 +597,13 @@ Failures on individual routes never halt the run — inspect `summary.results` t
428
597
 
429
598
  ## Flash from session
430
599
 
431
- 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:
600
+ Set `flashFromSession: true` to surface `req.session.flash` as the top-level `flash` page key (a sibling of `props`, read on the client via `usePage().flash`) and clear it after read:
432
601
 
433
602
  ```ts
434
603
  inertia({ flashFromSession: true });
435
604
  ```
436
605
 
437
- Off by default — most apps already wire flash/errors via `sharedProps`. Requires a session middleware that exposes `req.session`.
606
+ Off by default. Requires a session middleware that exposes `req.session`. The option gates only `flash`; validation errors are shared as the `errors` prop on every page regardless (see [Validation errors](#validation-errors)).
438
607
 
439
608
  ## Edge caching (CDN)
440
609
 
@@ -537,6 +706,7 @@ For typed page props on the client side, generic `res.inertia<TProps>("Component
537
706
  - The default HTML shell is intentionally minimal. Real apps should provide a `rootView`.
538
707
  - This adapter does not bundle a session implementation — bring your own (`express-session`, `cookie-session`, etc.).
539
708
  - Optimistic-update / `useHttp` server-side helpers are intentionally minimal here; the protocol primitives (`mergeProps`, `deepMergeProps`, `matchPropsOn`, `deferredProps`, `clearHistory`, `encryptHistory`) are exposed.
709
+ - Behaviour that differs from inertia-laravel or from 2.x is listed on the docs site's [Caveats](https://blacklizard.github.io/inertia-express/guide/caveats) page.
540
710
 
541
711
  ## License
542
712