@zerotal/arch 1.13.3 → 1.13.5
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/docs/changelog.md +79 -0
- package/docs/inertia/ssr.md +79 -40
- package/docs/site-gate.md +14 -3
- package/docs/upgrade.md +7 -0
- package/package.json +3 -3
package/docs/changelog.md
CHANGED
|
@@ -27,6 +27,85 @@ the section for every version you cross and apply its migration notes, not only
|
|
|
27
27
|
majors. [Releases and versioning](/docs/support-policy#releases-and-versioning) explains
|
|
28
28
|
when that carve-out ends.
|
|
29
29
|
|
|
30
|
+
## 1.13.5 — 2026-09-01
|
|
31
|
+
|
|
32
|
+
**`inertia.ssr: true` server-renders now.** One config line, every page, no controller
|
|
33
|
+
changes — which is what the option is named for and what it did not do.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **The `ssr` flag rendered nothing.** It registered `POST /__ssr` and nothing in the
|
|
38
|
+
request path consulted it. So an app that set `ssr: true` and read
|
|
39
|
+
[the SSR guide](/docs/inertia/ssr) — which stated that the server renders the component
|
|
40
|
+
into the template — got exactly the empty root it had before, and the documentation was
|
|
41
|
+
the reason nobody suspected otherwise.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// config/inertia.ts — this is now the whole of it
|
|
45
|
+
export default InertiaConfig({ ssr: true });
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`Inertia.render()` renders the component into the root, injects the page's `<Head>` into
|
|
49
|
+
the served `<head>`, and marks the root `data-server-rendered`. The scaffolded `app.tsx`
|
|
50
|
+
already hydrated on that attribute, so the client half needed nothing: turning SSR on is
|
|
51
|
+
one line, and there is no second step.
|
|
52
|
+
|
|
53
|
+
Server rendering was previously reachable only by rewriting each route to
|
|
54
|
+
`Inertia.stream()`, one call site at a time. Two teams did that. **They can go back to
|
|
55
|
+
`render()`** — and should, unless they wanted the streaming.
|
|
56
|
+
|
|
57
|
+
A component that fails to render falls back to the client-rendered document with a
|
|
58
|
+
warning rather than failing the route. The page still works in a browser, and taking a
|
|
59
|
+
route down because an _optimisation_ failed would make `ssr: true` a liability rather
|
|
60
|
+
than an improvement.
|
|
61
|
+
|
|
62
|
+
- **Streaming and SSR are separated in the docs.** `inertiaStream()` is not how you turn
|
|
63
|
+
server rendering on; it decides whether the bytes are **buffered or streamed**. Both
|
|
64
|
+
render the component and both hydrate. Conflating them is what made a per-route rewrite
|
|
65
|
+
look like the supported answer, so the comparison table and the "what a crawler sees"
|
|
66
|
+
remedies now lead with the config flag.
|
|
67
|
+
|
|
68
|
+
`POST /__ssr` is unchanged and documented for what it is: the contract for an
|
|
69
|
+
**external** renderer, not the in-process switch.
|
|
70
|
+
|
|
71
|
+
## 1.13.4 — 2026-09-01
|
|
72
|
+
|
|
73
|
+
**Take this one if you are on 1.13.3.** The site gate shipped a staff bypass that failed
|
|
74
|
+
open, and a type error that landed on apps not using it. Both found by a team upgrading,
|
|
75
|
+
within a day.
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- **The gate's staff bypass was a denylist, and let the public in.** 1.13.3 admitted any
|
|
80
|
+
authenticated user whose role was not literally `"customer"`. In an app whose roles are
|
|
81
|
+
`user` and `admin` — which is most of them — that is every signed-in visitor, so a
|
|
82
|
+
private preview showed the site to anyone with an account. A gate that fails open is
|
|
83
|
+
worse than no gate, because it reports success while doing nothing.
|
|
84
|
+
|
|
85
|
+
It is an allowlist now: `gate.staffRoles`, defaulting to `["admin"]`. An app whose staff
|
|
86
|
+
role is named something else gets no bypass and notices, which is the safe direction to
|
|
87
|
+
be wrong in. The token path is unchanged.
|
|
88
|
+
|
|
89
|
+
Worth naming the mistake, because this release cycle already contained its lesson: the
|
|
90
|
+
1.11.0 notes describe an app that wrote its own "is this error permanent" check as a
|
|
91
|
+
denylist and found it was a latent outage, and the fix was an allowlist. The same shape
|
|
92
|
+
went into the gate three releases later.
|
|
93
|
+
|
|
94
|
+
- **The same line broke `tsc` for apps that do not use the gate.** `role !== "customer"`
|
|
95
|
+
is a type error when an app's role union has no such member (`TS2367`), and the
|
|
96
|
+
framework ships TypeScript source, so the error arrived on a feature the app never
|
|
97
|
+
touched — failing its build. The role is read as `string` now, because the framework
|
|
98
|
+
cannot know an app's role names and must not narrow to them.
|
|
99
|
+
|
|
100
|
+
### Documented
|
|
101
|
+
|
|
102
|
+
- **A tilde still crosses a patch, and under this scheme a patch carries features.**
|
|
103
|
+
`~1.13.2` is `>=1.13.2 <1.14.0`, so it takes 1.13.3 without asking — which is exactly
|
|
104
|
+
how 1.13.3 reached the app that found the bugs above. That is the right default for most
|
|
105
|
+
apps, and it is weaker protection than the same range gives under strict semver, where a
|
|
106
|
+
patch is only ever a bug fix. [The upgrade guide](/docs/upgrade#versioning) now says so,
|
|
107
|
+
and says to pin the exact version when you need it to hold.
|
|
108
|
+
|
|
30
109
|
## 1.13.3 — 2026-08-31
|
|
31
110
|
|
|
32
111
|
Two things an app cannot see about itself, from two field reports. Both are the same
|
package/docs/inertia/ssr.md
CHANGED
|
@@ -9,22 +9,7 @@ By default Inertia renders the first page on the client. Server-side rendering (
|
|
|
9
9
|
renders the initial HTML on the server instead — better Time-to-First-Byte and
|
|
10
10
|
crawlable content — while subsequent navigations keep using the fast JSON path.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
**streaming SSR** via `inertiaStream()`.
|
|
14
|
-
|
|
15
|
-
## Which should I use?
|
|
16
|
-
|
|
17
|
-
- **Endpoint SSR** (`ssr: true`) — the standard Inertia SSR contract. Turn it on
|
|
18
|
-
globally and the Inertia client renders the first page through `POST /__ssr`. Use
|
|
19
|
-
this when you want crawlable, server-rendered HTML across the whole app with no
|
|
20
|
-
per-controller change.
|
|
21
|
-
- **Streaming SSR** (`inertiaStream()`) — swap `inertia()` for `inertiaStream()` in
|
|
22
|
-
the controllers whose initial document you want streamed for the fastest TTFB. Use
|
|
23
|
-
it selectively on heavy landing pages; everything else stays on `inertia()`.
|
|
24
|
-
|
|
25
|
-
## Endpoint SSR
|
|
26
|
-
|
|
27
|
-
Enable the SSR endpoint in config:
|
|
12
|
+
Turn `ssr` on and every first page load is server-rendered. That is the whole of it:
|
|
28
13
|
|
|
29
14
|
```ts
|
|
30
15
|
// config/inertia.ts
|
|
@@ -34,21 +19,70 @@ import { env } from "zerotal";
|
|
|
34
19
|
export default InertiaConfig({
|
|
35
20
|
htmlTemplate: "./resources/app.html",
|
|
36
21
|
version: env("ASSET_VERSION", "1"),
|
|
37
|
-
ssr: true,
|
|
22
|
+
ssr: true,
|
|
38
23
|
});
|
|
39
24
|
```
|
|
40
25
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
26
|
+
No controller changes. `Inertia.render()` renders the component into the root, injects
|
|
27
|
+
the page's `<Head>` tags into the document head, and marks the root
|
|
28
|
+
`data-server-rendered` so the client **hydrates** that markup instead of throwing it
|
|
29
|
+
away and painting again. The scaffolded `app.tsx` already does the hydrating half:
|
|
30
|
+
|
|
31
|
+
```tsx fragment
|
|
32
|
+
setup({ el, App, props }) {
|
|
33
|
+
const app = <App {...props} />;
|
|
34
|
+
if (el.hasAttribute("data-server-rendered")) {
|
|
35
|
+
hydrateRoot(el, app);
|
|
36
|
+
} else {
|
|
37
|
+
createRoot(el).render(app);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Subsequent navigations are unaffected — an `X-Inertia` XHR gets the page object as JSON
|
|
43
|
+
either way, because server rendering is about the _first_ arrival.
|
|
44
|
+
|
|
45
|
+
> **Before 1.13.4, `ssr: true` did not do this.** It registered `POST /__ssr` and nothing
|
|
46
|
+
> in the request path consulted it, so an app that set the flag and read this page got
|
|
47
|
+
> exactly the empty root it had before, and server rendering was reachable only by
|
|
48
|
+
> rewriting each route to `inertiaStream()`. If you worked around that with a per-route
|
|
49
|
+
> switch, you can delete it.
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
Pages render with the framework they are authored in: React `.tsx` via
|
|
52
|
+
`react-dom/server`, Vue `.vue` via `@inertiajs/vue3` + `vue/server-renderer`. Install the
|
|
53
|
+
server renderer for the framework your app uses.
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
**A page that fails to server-render still works.** The failure is logged and the
|
|
56
|
+
client-rendered document is served instead, because taking a route down because an
|
|
57
|
+
_optimisation_ failed would make `ssr: true` a liability rather than an improvement.
|
|
58
|
+
|
|
59
|
+
## What `POST /__ssr` is for
|
|
60
|
+
|
|
61
|
+
`ssr: true` also registers it. It accepts `{ component, props, url }` and returns
|
|
62
|
+
`{ body, head }` — the same contract as the Inertia Node SSR server — and it exists for
|
|
63
|
+
an **external** caller: a separate renderer process, or a deployment that renders
|
|
64
|
+
somewhere other than the web process.
|
|
65
|
+
|
|
66
|
+
You do not need it for the switch above, which renders in-process. It is throttled and
|
|
67
|
+
loopback-gated; see `ssrSecret` for reaching it from another host.
|
|
68
|
+
|
|
69
|
+
## Streaming SSR — a different question
|
|
70
|
+
|
|
71
|
+
`inertiaStream()` is **not** how you turn SSR on — that is the config flag. Streaming is
|
|
72
|
+
about **time to first byte**: the document goes out in pieces as the component renders,
|
|
73
|
+
rather than being buffered and sent whole.
|
|
74
|
+
|
|
75
|
+
That is a trade, which is why it stays a per-route choice rather than an app-wide one. A
|
|
76
|
+
streamed response starts arriving sooner and finishes no earlier, and on a page that is
|
|
77
|
+
mostly shell the buffering costs nothing worth reclaiming. Reach for it on a heavy
|
|
78
|
+
landing page; leave everything else on `Inertia.render()`.
|
|
79
|
+
|
|
80
|
+
Both render the component and both mark the root for hydration. The only difference is
|
|
81
|
+
whether the bytes are streamed.
|
|
82
|
+
|
|
83
|
+
### How it streams
|
|
84
|
+
|
|
85
|
+
It uses React 18's
|
|
52
86
|
`renderToReadableStream` to improve TTFB. Instead of buffering the whole render, it
|
|
53
87
|
streams the React output between the template's HTML prefix and suffix:
|
|
54
88
|
|
|
@@ -82,14 +116,17 @@ export class PostController {
|
|
|
82
116
|
|
|
83
117
|
### inertia vs. inertiaStream
|
|
84
118
|
|
|
85
|
-
| Criterion | `inertia()`
|
|
86
|
-
| -------------- |
|
|
87
|
-
| Return type | `Promise<void>`
|
|
88
|
-
| Rendering |
|
|
89
|
-
| Response body | Fully buffered string
|
|
90
|
-
| TTFB |
|
|
91
|
-
| Page `<Head>` |
|
|
92
|
-
| XHR navigation | JSON (the normal path)
|
|
119
|
+
| Criterion | `inertia()` | `inertiaStream()` |
|
|
120
|
+
| -------------- | --------------------------------------- | ---------------------------------- |
|
|
121
|
+
| Return type | `Promise<void>` | `Promise<void>` |
|
|
122
|
+
| Rendering | Server-rendered when `ssr: true` | Always server-rendered |
|
|
123
|
+
| Response body | Fully buffered string | Streaming `ReadableStream` |
|
|
124
|
+
| TTFB | After the render | As the component renders |
|
|
125
|
+
| Page `<Head>` | In the served `<head>` when `ssr: true` | Collected into the served `<head>` |
|
|
126
|
+
| XHR navigation | JSON (the normal path) | JSON — the same page object |
|
|
127
|
+
|
|
128
|
+
With `ssr: false` — the default — `inertia()` serves an empty root and the page object,
|
|
129
|
+
and the browser does all the rendering.
|
|
93
130
|
|
|
94
131
|
**Both implement the whole protocol.** An `X-Inertia: true` request gets the page
|
|
95
132
|
object as JSON from either one; the streaming half applies to the first arrival,
|
|
@@ -152,8 +189,9 @@ Two things it does not do:
|
|
|
152
189
|
|
|
153
190
|
## What a crawler sees
|
|
154
191
|
|
|
155
|
-
`
|
|
156
|
-
response body is the template with an empty root and the page object beside
|
|
192
|
+
**With `ssr: false` — the default — `inertia()` does not server-render the component at
|
|
193
|
+
all.** Its response body is the template with an empty root and the page object beside
|
|
194
|
+
it:
|
|
157
195
|
|
|
158
196
|
```html
|
|
159
197
|
<body>
|
|
@@ -179,10 +217,11 @@ readers of your site run JavaScript and which do not:
|
|
|
179
217
|
So the link preview a page produces is decided entirely by its `<head>` — which is
|
|
180
218
|
the template's, identically, on every page, unless you do one of these:
|
|
181
219
|
|
|
182
|
-
1. **
|
|
183
|
-
collected, and the served `<head>` is the page's own. This is the
|
|
184
|
-
|
|
185
|
-
2. **
|
|
220
|
+
1. **Turn on `ssr: true`.** One line, every page: the component is rendered, `<Head>` is
|
|
221
|
+
collected, and the served `<head>` is the page's own. This is the change to reach for
|
|
222
|
+
unless you have a reason not to.
|
|
223
|
+
2. **Switch a single page to `inertiaStream()`**, if you want that and streaming on one
|
|
224
|
+
route without turning rendering on everywhere.
|
|
186
225
|
3. **Set the tags in middleware**, if the metadata is server-side data the component
|
|
187
226
|
does not otherwise need.
|
|
188
227
|
|
package/docs/site-gate.md
CHANGED
|
@@ -64,9 +64,20 @@ on every outbound link, into analytics, into screenshots, and into the message w
|
|
|
64
64
|
somebody shares "the page I was looking at". Stripping it on first use leaves the
|
|
65
65
|
secret in a cookie and nowhere else.
|
|
66
66
|
|
|
67
|
-
**A signed-in staff account.** A request whose authenticated user
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
**A signed-in staff account.** A request whose authenticated user holds a role on the
|
|
68
|
+
allowlist is admitted without a token, so an app that already has staff accounts does not
|
|
69
|
+
need a second secret for the same people. It defaults to `["admin"]`:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// config/gate.ts
|
|
73
|
+
export default { staffRoles: ["admin", "editor"] };
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
An **allowlist**, and it matters. 1.13.3 shipped the inverse — "anyone whose role is not
|
|
77
|
+
`customer`" — which in an app whose roles are `user` and `admin` admitted every signed-in
|
|
78
|
+
visitor and let the public straight through. A gate that fails open is worse than no gate,
|
|
79
|
+
because it reports success while doing nothing. An app whose staff role is named something
|
|
80
|
+
this does not list gets no bypass and notices, which is the safe direction to be wrong in.
|
|
70
81
|
|
|
71
82
|
The cookie lasts seven days. A preview cookie with no lifetime means an ex-tester keeps
|
|
72
83
|
access to a site that has since gone live with real customer data.
|
package/docs/upgrade.md
CHANGED
|
@@ -33,6 +33,13 @@ something, and puts the work where it is useful: reading the notes for each mino
|
|
|
33
33
|
> install 1.11.0, and its breaking change, without asking. Read the notes for every
|
|
34
34
|
> minor you cross, or pin with a tilde (`~1.10.0`) and cross them deliberately.
|
|
35
35
|
|
|
36
|
+
> **Warning** — **a tilde still crosses a patch**, and under this scheme a patch carries
|
|
37
|
+
> features. `~1.13.2` means `>=1.13.2 <1.14.0`, so it takes 1.13.3 without asking. That is
|
|
38
|
+
> the right default for most apps and it is weaker protection than the same range gives
|
|
39
|
+
> under strict semver, where a patch is only ever a bug fix. If you need the version to
|
|
40
|
+
> hold exactly where you put it — a release you have certified, a machine you cannot
|
|
41
|
+
> re-test quickly — pin the exact version with no range operator at all.
|
|
42
|
+
|
|
36
43
|
> **Warning** — always upgrade the `@zerotal/*` packages together. Mixing versions across core, ORM, and feature packages leads to type and runtime mismatches.
|
|
37
44
|
|
|
38
45
|
## Upgrade steps
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/arch",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -35,11 +35,11 @@
|
|
|
35
35
|
"typecheck": "tsc --noEmit"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@zerotal/core": "1.13.
|
|
38
|
+
"@zerotal/core": "1.13.5"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^5.8.0",
|
|
42
|
-
"@zerotal/orm": "1.13.
|
|
42
|
+
"@zerotal/orm": "1.13.5"
|
|
43
43
|
},
|
|
44
44
|
"description": "The Zerotal agent surface — an MCP server that hands coding agents the framework's machine-readable truth: exact API signatures, live routes and schema, version-matched docs, and `zt doctor`.",
|
|
45
45
|
"keywords": [
|