@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 +212 -42
- package/dist/core/index.d.ts +2 -552
- package/dist/core/index.js +2 -662
- package/dist/core-PN_yVSCS.js +1741 -0
- package/dist/core-PN_yVSCS.js.map +1 -0
- package/dist/express/index.d.ts +72 -13
- package/dist/express/index.d.ts.map +1 -1
- package/dist/express/index.js +160 -39
- package/dist/express/index.js.map +1 -1
- package/dist/index-C7vBpEDY.d.ts +1326 -0
- package/dist/index-C7vBpEDY.d.ts.map +1 -0
- package/package.json +6 -2
- package/dist/core/index.d.ts.map +0 -1
- package/dist/core/index.js.map +0 -1
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
|
-
-
|
|
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` |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
// Always evaluated.
|
|
136
|
-
user: req.user,
|
|
220
|
+
Laravel: `Inertia::merge()` / `Inertia::deepMerge()`
|
|
137
221
|
|
|
138
|
-
|
|
139
|
-
recent: lazy(() => userService.recent()),
|
|
222
|
+
### `once`
|
|
140
223
|
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
// follow-up partial reload after mount, grouped by `"dashboard"`.
|
|
146
|
-
stats: defer(() => dashboard.getStats(), "dashboard"),
|
|
240
|
+
Laravel: `Inertia::once()`
|
|
147
241
|
|
|
148
|
-
|
|
149
|
-
flash: always(() => req.session.flash),
|
|
242
|
+
### `scroll`
|
|
150
243
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
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
|
|