@zerotal/arch 1.9.0 → 1.10.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/api-surface.md +6 -6
- package/docs/changelog.md +156 -0
- package/docs/deployment.md +52 -4
- package/docs/helpers.md +51 -0
- package/docs/inertia/middleware.md +44 -0
- package/docs/inertia/ssr.md +95 -10
- package/docs/middleware.md +31 -0
- package/docs/rate-limiting.md +45 -17
- package/docs/scheduler.md +71 -8
- package/docs/testing/index.md +99 -0
- package/docs/upgrade.md +48 -0
- package/package.json +3 -3
- package/src/install/guidelines.ts +1 -1
- package/src/mcp/stdio.ts +3 -3
- package/src/tools/_probe.ts +2 -2
package/api-surface.md
CHANGED
|
@@ -119,7 +119,7 @@ interface DoctorReport = {
|
|
|
119
119
|
interface GuidelineOptions = {
|
|
120
120
|
packages: string[]
|
|
121
121
|
serverName: string
|
|
122
|
-
shape?: ProjectShape
|
|
122
|
+
shape?: ProjectShape | undefined
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
interface InstalledPackage = {
|
|
@@ -177,8 +177,8 @@ interface SchemaReport = {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
interface SpawnProbeOptions = {
|
|
180
|
-
cwd?: string
|
|
181
|
-
timeoutMs?: number
|
|
180
|
+
cwd?: string | undefined
|
|
181
|
+
timeoutMs?: number | undefined
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
interface ToolContext = {
|
|
@@ -262,10 +262,10 @@ interface ServerIdentity = {
|
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
interface StdioOptions = {
|
|
265
|
-
input?: ReadableStream<Uint8Array<ArrayBufferLike>>
|
|
266
|
-
log?: (message: string) => void
|
|
265
|
+
input?: ReadableStream<Uint8Array<ArrayBufferLike>> | undefined
|
|
266
|
+
log?: ((message: string) => void) | undefined
|
|
267
267
|
server: McpServer
|
|
268
|
-
write?: (frame: string) => void
|
|
268
|
+
write?: ((frame: string) => void) | undefined
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
interface ToolOutcome = {
|
package/docs/changelog.md
CHANGED
|
@@ -27,6 +27,162 @@ 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.10.0 — 2026-08-30
|
|
31
|
+
|
|
32
|
+
A second report from the team building on Zerotal, and the things it found. Most of this
|
|
33
|
+
release is failures that were silent by construction — mail delivered nowhere, a page
|
|
34
|
+
shared as a grey rectangle, a schedule that never fired, a rate limiter with one bucket
|
|
35
|
+
for everybody. None of them logged anything.
|
|
36
|
+
|
|
37
|
+
**Three things to know before upgrading.**
|
|
38
|
+
|
|
39
|
+
- **React apps using SSR now need `@inertiajs/react` installed.** It is the same adapter
|
|
40
|
+
your browser entry point already uses; the server renders through its `<App>` so
|
|
41
|
+
`<Head>` works. A missing one is a named error rather than a silent omission.
|
|
42
|
+
- **`scheduler.timezone` does something now.** It was documented as informational and read
|
|
43
|
+
by nothing. Its default moved from the literal `"UTC"` to **the system zone**, so an app
|
|
44
|
+
that never set the key keeps doing exactly what it did — but an app that set it now gets
|
|
45
|
+
what it asked for. If you set it to `"UTC"` on a server that is not on UTC, your
|
|
46
|
+
schedules will move. See [the upgrade guide](/docs/upgrade#1-9-to-1-10).
|
|
47
|
+
- **Named rate limiters need `.trustedProxies(n)` behind a proxy.** `.byIp()`, `.byUser()`
|
|
48
|
+
and `.byApiKey()` now ignore `X-Forwarded-For` unless told how many proxies sit in
|
|
49
|
+
front, which is the same rule `ThrottleMiddleware` already followed. `zt doctor` reports
|
|
50
|
+
any that need it.
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- **`zt assets:prune`** — removes the chunks an earlier release left behind, on the machine
|
|
55
|
+
that never ran a build. `assets:build --clean` cleans the directory it _builds into_,
|
|
56
|
+
which does nothing for the usual release shape: build here, tar the output, extract it
|
|
57
|
+
over `public/` there. Extracting merges, so every deploy adds another set of
|
|
58
|
+
content-hashed chunks and none are ever removed. One app reached 225 chunk files for the
|
|
59
|
+
49 its entry point references. Ship `.zerotal/` with the release and this removes what
|
|
60
|
+
the build record does not claim. See [Deployment](/docs/deployment#assetsprune--clearing-up-after-the-extract-instead-of-before-it).
|
|
61
|
+
|
|
62
|
+
- **`zt deploy:<env> --check`** — the preflight gate on its own, for the point in a release
|
|
63
|
+
script where the new code is on disk and the service has not restarted. Exit 0 and
|
|
64
|
+
restart; exit non-zero and keep serving the previous release. Everything that can refuse
|
|
65
|
+
already runs by the end of preflight and none of it mutates, so stopping there is a
|
|
66
|
+
complete answer rather than half a deploy.
|
|
67
|
+
|
|
68
|
+
- **`RateLimiter.trustedProxies(n)`** on the fluent builder, and
|
|
69
|
+
**`res.assertInertiaRedirect(url)`** in `@zerotal/testing` — the assertion that checks
|
|
70
|
+
what actually breaks on an Inertia redirect, which is the `X-Inertia` marker rather than
|
|
71
|
+
the status and `Location` a normal redirect assertion already covers.
|
|
72
|
+
|
|
73
|
+
- **`@zerotal/core/runtime`** (`zerotal/runtime`) — the runtime checks as exports, so a
|
|
74
|
+
script or a test can make the same assertion `zt` makes: `runtimeBelowFloor`,
|
|
75
|
+
`declaredBunFloor`, `runtimeMismatch`, `bunBinary` and the messages that go with them.
|
|
76
|
+
|
|
77
|
+
- **`definedOnly()` and `Resolved<T>`** on `@zerotal/core/helpers`, for merging an options
|
|
78
|
+
bag over defaults without an explicit `undefined` overwriting one.
|
|
79
|
+
|
|
80
|
+
- **Scheduler timezone helpers** — `wallClockIn`, `isValidTimeZone`,
|
|
81
|
+
`CronExpression.matchesIn` and `CronExpression.nextRunAfterIn`, plus `SchedulerError` and
|
|
82
|
+
`UnknownTimeZoneError`.
|
|
83
|
+
|
|
84
|
+
- **A boot line when a convention is skipped in this environment.** An env-restricted
|
|
85
|
+
concern is skipped by _not looking_, which is correct and completely silent: an app ran
|
|
86
|
+
for weeks in production with `app/schedules` full and no worker process, and nothing
|
|
87
|
+
logged anything because from a web process's point of view nothing existed.
|
|
88
|
+
|
|
89
|
+
### Changed
|
|
90
|
+
|
|
91
|
+
- **Optional properties in public option shapes are declared `?: T | undefined`.** The
|
|
92
|
+
generated `tsconfig.json` enables `exactOptionalPropertyTypes`, under which
|
|
93
|
+
`image?: string` refuses a key that is present and holds `undefined` — so
|
|
94
|
+
`{ image: candidate ?? undefined }`, the most ordinary thing there is, did not compile
|
|
95
|
+
and every conditionally-absent field had to be spelled `...(x ? { x } : {})`. 438
|
|
96
|
+
properties across 115 files. Nothing changes for a reader: an absent optional property
|
|
97
|
+
already read as `undefined`.
|
|
98
|
+
|
|
99
|
+
- **`scheduler.timezone` is honoured**, and its default is the system zone rather than the
|
|
100
|
+
literal `"UTC"`. See the note above.
|
|
101
|
+
|
|
102
|
+
- **`mail.driver: "log"` fails `zt doctor` in production** when `mail.from.address` has been
|
|
103
|
+
configured, and warns when it is still the placeholder. Mail written to a log file is
|
|
104
|
+
delivered to nobody and says so nowhere.
|
|
105
|
+
|
|
106
|
+
### Fixed
|
|
107
|
+
|
|
108
|
+
- **React SSR emitted no `<Head>` tags at all.** The React branch rendered the page
|
|
109
|
+
component directly, and `<Head>` renders nothing — it reports its children to a head
|
|
110
|
+
manager it reads from context, and rendering the component alone puts none there. So
|
|
111
|
+
every page served the template's `<head>`: no title, no description, no card. Nothing
|
|
112
|
+
failed and nothing logged, because the page is perfect in a browser, where React has run.
|
|
113
|
+
Only the readers that do not run JavaScript saw it — which is every link-preview scraper
|
|
114
|
+
and every `curl`.
|
|
115
|
+
|
|
116
|
+
- **SMTP submission on port 587 sent nothing.** The STARTTLS handshake completed and then
|
|
117
|
+
the client's `EHLO` was dropped: `upgradeTLS()` returns the new socket while the
|
|
118
|
+
handshake is still in flight, and a write issued in that window is lost — not buffered,
|
|
119
|
+
not an error, gone. Port 465 was unaffected, so mail worked on the port nobody documents
|
|
120
|
+
and the 587 every provider _does_ document produced silence: no error, no bounce, no log
|
|
121
|
+
line, and password resets that never arrived.
|
|
122
|
+
|
|
123
|
+
- **TLS certificates were not actually verified, on either SMTP transport.**
|
|
124
|
+
`rejectUnauthorized` is not enforced by the runtime — it reports a self-signed
|
|
125
|
+
certificate as authorized and puts the real reason beside it — so the connection was
|
|
126
|
+
encrypted and would have accepted that encryption from anyone in the network path. The
|
|
127
|
+
driver reads the handshake result itself now and fails closed.
|
|
128
|
+
|
|
129
|
+
- **A scheduled task with a `timezone` took the whole scheduler down.** `Bun.cron`'s options
|
|
130
|
+
form throws, and it throws during registration, so the worker died on boot and
|
|
131
|
+
restart-looped: one task with a timezone stopped every task in the app. Zerotal evaluates
|
|
132
|
+
the zone itself now, and a task that cannot register takes only itself out.
|
|
133
|
+
|
|
134
|
+
- **Named rate limiters ignored `trustedProxies`, and `zt doctor` was told not to look.**
|
|
135
|
+
`.byIp()`, `.byUser()` and `.byApiKey()` used a resolver that read the socket address and
|
|
136
|
+
fell back to the leftmost `X-Forwarded-For` entry with no proxy count. Behind a reverse
|
|
137
|
+
proxy every visitor keyed on the proxy's own address and shared one bucket, so a `login`
|
|
138
|
+
limiter of five attempts a minute was five attempts a minute for the entire user base and
|
|
139
|
+
one attacker locked everybody out. The doctor check written to catch this exempted any
|
|
140
|
+
custom `keyResolver`, which is what all three are.
|
|
141
|
+
|
|
142
|
+
- **`ctx.session.intended()` could not read what `AuthMiddleware` stored.** It used the key
|
|
143
|
+
`intended` while the middleware and `redirect().intended()` used `intended_url`. Each pair
|
|
144
|
+
was internally consistent and separately tested, so every test passed — and an app that
|
|
145
|
+
mixed them, which the documentation invited, was silently sent to `/` after every sign-in.
|
|
146
|
+
|
|
147
|
+
- **`MonitorStore` overwrote its own defaults with `undefined`.** It applied `?? …` defaults
|
|
148
|
+
and then spread `...opts` after them, and spread copies own properties even when they
|
|
149
|
+
hold `undefined` — so an unset config put `undefined` back over the retention window and
|
|
150
|
+
`prune()` computed a `NaN` cutoff, pruning nothing and reporting nothing.
|
|
151
|
+
|
|
152
|
+
- **`engines.bun` is enforced.** Every generated app writes a floor and nothing read it.
|
|
153
|
+
`Intl` output moves between Bun releases, so a suite with currency or date assertions goes
|
|
154
|
+
red on a runtime that is otherwise fine and the failures name the code they touch rather
|
|
155
|
+
than the binary.
|
|
156
|
+
|
|
157
|
+
- **The asset build record is portable.** Its filename was hashed from the output
|
|
158
|
+
directory's _absolute_ path, so a record shipped with a release matched nothing at the
|
|
159
|
+
other end and moving a checkout silently orphaned it.
|
|
160
|
+
|
|
161
|
+
- **The React SSR root is marked `data-server-rendered`**, so the client hydrates the markup
|
|
162
|
+
instead of discarding it and rendering the page a second time. `POST /__ssr` returns the
|
|
163
|
+
same body shape as the Vue branch.
|
|
164
|
+
|
|
165
|
+
### Documented
|
|
166
|
+
|
|
167
|
+
- **["What a crawler sees"](/docs/inertia/ssr#what-a-crawler-sees)** — `inertia()` does not
|
|
168
|
+
server-render the component, which is the normal Inertia arrangement and worth saying out
|
|
169
|
+
loud: the served document is a `<title>` and a JSON blob. Which readers run JavaScript,
|
|
170
|
+
which do not, and the three ways to give the second group something to read.
|
|
171
|
+
|
|
172
|
+
- **[Which Inertia redirects are covered](/docs/inertia/middleware#which-redirects-are-covered)**
|
|
173
|
+
— all of them, because `useOnce()` registers the middleware globally. Written down because
|
|
174
|
+
the opposite belief is what keeps an app's own workaround on every request forever.
|
|
175
|
+
|
|
176
|
+
- **[`bun test` vs `bun zt test`](/docs/testing#bun-test-vs-bun-zt-test)** — the 30-second
|
|
177
|
+
timeout (the `bunfig.toml` key is ignored by Bun and `setDefaultTimeout()` in a preload
|
|
178
|
+
reaches only the first file, so the flag is the only mechanism that works), the runtime
|
|
179
|
+
check, and the fact that configuration resolves once per process — so a test that mutates
|
|
180
|
+
the environment in `beforeAll` is testing whichever file booted first.
|
|
181
|
+
|
|
182
|
+
- **[Timezones](/docs/scheduler#timezones)** in the scheduler, **[the middleware names the
|
|
183
|
+
framework occupies](/docs/middleware#names-the-framework-already-occupies)**, and why
|
|
184
|
+
`X-Forwarded-For` is [counted from the right](/docs/rate-limiting#trustedproxies).
|
|
185
|
+
|
|
30
186
|
## 1.9.0 — 2026-08-29
|
|
31
187
|
|
|
32
188
|
The gaps an app was filling in for itself: one Bun per project, a database backup that is not
|
package/docs/deployment.md
CHANGED
|
@@ -38,6 +38,7 @@ deliberate: systemd, your container runtime or your deploy script owns process
|
|
|
38
38
|
lifecycle, and this gives it a gate to restart behind.
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
|
+
bun zt deploy:production --check # run the gate only — see below
|
|
41
42
|
bun zt deploy:production --dry-run # print the plan, run none of it
|
|
42
43
|
bun zt deploy:production --skip-migrations # release without touching the schema
|
|
43
44
|
bun zt deploy:production --probe=https://example.com # real handshake at the end
|
|
@@ -75,10 +76,11 @@ Omit the file entirely and you get `DEFAULT_DEPLOY_TARGETS`: `production` and
|
|
|
75
76
|
### Your own release gate
|
|
76
77
|
|
|
77
78
|
The framework's preflight knows the things a framework can know: that this really is the
|
|
78
|
-
environment you think it is, that the config validators pass, that `doctor` is happy
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
environment you think it is, that the config validators pass, that `doctor` is happy —
|
|
80
|
+
which now includes refusing a production release whose `mail.driver` is still `log`,
|
|
81
|
+
because mail written to a log file is delivered to nobody and says so nowhere. It
|
|
82
|
+
cannot know that this workspace has no cancellation policy, or that the owner account
|
|
83
|
+
is still on the password `admin:create` issued it. Those refusals are yours.
|
|
82
84
|
|
|
83
85
|
Write them as a command and name it `release:check` (exported as
|
|
84
86
|
`CONVENTIONAL_PREFLIGHT_COMMAND`). The pipeline finds it by name — nothing to wire up:
|
|
@@ -124,6 +126,27 @@ Two things worth adding while you are there: `assets:build` and `inertia:build`
|
|
|
124
126
|
`--clean`, which removes anything in the output directory the build did not write — see
|
|
125
127
|
[Build assets](#build-assets).
|
|
126
128
|
|
|
129
|
+
### `--check`: the gate on its own
|
|
130
|
+
|
|
131
|
+
A release script has a moment where the new code is on disk and the service has not
|
|
132
|
+
restarted yet. That is the moment to ask whether this release is fit to go live, and
|
|
133
|
+
`--check` is the whole preflight and nothing else:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# on the box, after the new release is unpacked and before the restart
|
|
137
|
+
APP_ENV=production bun zt deploy:production --check || exit 1
|
|
138
|
+
systemctl restart app
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
It runs the environment check, the config validators with production semantics,
|
|
142
|
+
`doctor`, and your own `release:check` — everything that can refuse — and builds
|
|
143
|
+
nothing, migrates nothing, restarts nothing. Exit 0 and restart; exit non-zero and
|
|
144
|
+
keep serving the previous release, which is the point. A workspace that has lost its
|
|
145
|
+
banking details, or had its mail driver knocked back to `log`, never goes live broken.
|
|
146
|
+
|
|
147
|
+
`--check` and `--dry-run` answer different questions. `--dry-run` prints the plan
|
|
148
|
+
without running any of it, including the gate. `--check` runs the gate for real.
|
|
149
|
+
|
|
127
150
|
> **Note** — `deploy:<env>` runs **where the app runs**, with that environment's
|
|
128
151
|
> variables. It does not reach another machine over SSH. Run it on the box, or in
|
|
129
152
|
> the container build, as the step before the restart.
|
|
@@ -310,6 +333,31 @@ Ordering matters if the old release is still serving traffic: clearing the direc
|
|
|
310
333
|
its bundles away, so do it as close to the swap as you can, or stage the release in a new
|
|
311
334
|
directory and move it into place.
|
|
312
335
|
|
|
336
|
+
### `assets:prune` — clearing up after the extract instead of before it
|
|
337
|
+
|
|
338
|
+
Clearing the directory first has a cost the ordering note above hints at: between the
|
|
339
|
+
`rm -rf` and the new files landing, the release still serving traffic has no bundles.
|
|
340
|
+
If you would rather extract over the top and tidy afterwards, `assets:build` records
|
|
341
|
+
what it wrote and `assets:prune` removes what that record does not claim:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
# on the build machine — the record is written under .zerotal/
|
|
345
|
+
bun zt assets:build
|
|
346
|
+
tar -czf release.tgz public .zerotal # ship the record with the release
|
|
347
|
+
|
|
348
|
+
# on the server, after extracting and before the restart
|
|
349
|
+
bun zt assets:prune --dry-run # list what would go
|
|
350
|
+
bun zt assets:prune
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
**Ship `.zerotal/` with the release** — that is the part that makes it work. Without
|
|
354
|
+
the record, "which files belong to this release" has no answer on a server that never
|
|
355
|
+
ran a build, and `assets:prune` says so and removes nothing rather than guessing.
|
|
356
|
+
|
|
357
|
+
It is conservative by design: it deletes a file only when the record does not claim it
|
|
358
|
+
_and_ it is named the way the bundler names a code-split chunk. Your images and
|
|
359
|
+
favicon are never candidates, because an output directory is usually `public/`.
|
|
360
|
+
|
|
313
361
|
### `--clean` for a directory the build does not own outright
|
|
314
362
|
|
|
315
363
|
The cleanup above recognises the filenames `Bun.build()` produces. An app that sets its own
|
package/docs/helpers.md
CHANGED
|
@@ -393,6 +393,57 @@ matches how you want overrides to behave.
|
|
|
393
393
|
> configured `driver`) are replaced by reference — they keep their prototype and are
|
|
394
394
|
> never merged into.
|
|
395
395
|
|
|
396
|
+
### `definedOnly` and `Resolved<T>` — merging a shallow options bag
|
|
397
|
+
|
|
398
|
+
Every public option shape in the framework declares its optional properties as
|
|
399
|
+
`?: T | undefined`, so that the most ordinary thing there is compiles under the
|
|
400
|
+
`exactOptionalPropertyTypes` the generated `tsconfig.json` turns on:
|
|
401
|
+
|
|
402
|
+
```typescript
|
|
403
|
+
// in a controller
|
|
404
|
+
import { Media } from "zerotal/media";
|
|
405
|
+
|
|
406
|
+
await Media.store(file, { collection: request.input("collection") ?? undefined });
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
That flexibility moves a problem to the merge. **Object spread copies own properties
|
|
410
|
+
even when their value is `undefined`**, so `{ ...DEFAULTS, ...options }` lets an
|
|
411
|
+
explicitly-`undefined` field overwrite a default rather than leave it standing —
|
|
412
|
+
which is how a `pingInterval` of `undefined` once became `setInterval(fn, 0)` and
|
|
413
|
+
~830 pings a second. `definedOnly` is the fix: it drops the keys whose value is
|
|
414
|
+
`undefined`, so "supplied as undefined" reads as "not supplied".
|
|
415
|
+
|
|
416
|
+
```typescript fragment
|
|
417
|
+
// in a class that takes options
|
|
418
|
+
const merged = { ...DEFAULTS, ...definedOnly(options) };
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
`deepMerge` already does this for you — it skips `undefined` overrides at every
|
|
422
|
+
depth — so `definedOnly` is for the shallow case where a spread is all you need.
|
|
423
|
+
|
|
424
|
+
`Resolved<T>` is the type of what comes out. **`Required<T>` is not the same thing**,
|
|
425
|
+
and that is the trap: `-?` removes the optionality a `?` introduced, but it does not
|
|
426
|
+
remove an `undefined` written into the type. So `Required<MediaOptions>` still hands
|
|
427
|
+
back `string | undefined` for a `collection?: string | undefined`, and a "defaults
|
|
428
|
+
have been applied" type quietly stops meaning that.
|
|
429
|
+
|
|
430
|
+
```typescript fragment
|
|
431
|
+
// in a class that takes options
|
|
432
|
+
interface Options {
|
|
433
|
+
retentionDays?: number | undefined;
|
|
434
|
+
storage?: string | undefined;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Required<Pick<Options, "retentionDays">> → { retentionDays: number | undefined } ✗
|
|
438
|
+
// Resolved<Pick<Options, "retentionDays">> → { retentionDays: number } ✓
|
|
439
|
+
private readonly _opts: Resolved<Pick<Options, "retentionDays">> &
|
|
440
|
+
Omit<Options, "retentionDays">;
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
`Omit` rather than `& Options` for the rest: intersecting with the whole shape puts
|
|
444
|
+
the optional declaration of each resolved field back alongside the required one, so
|
|
445
|
+
the field reads as possibly `undefined` in the very code that just gave it a default.
|
|
446
|
+
|
|
396
447
|
## Fluent wrappers
|
|
397
448
|
|
|
398
449
|
### fluent
|
|
@@ -50,6 +50,50 @@ would just register it twice (and `useOnce` guards against that anyway).
|
|
|
50
50
|
> the authenticated user is always populated by the time props are built; you don't
|
|
51
51
|
> need to hand-order `InertiaMiddleware` relative to auth.
|
|
52
52
|
|
|
53
|
+
## Which redirects are covered
|
|
54
|
+
|
|
55
|
+
**All of them.** `useOnce()` registers `InertiaMiddleware` as _global_ middleware, so it
|
|
56
|
+
runs on every request the app serves — however that route declared its own middleware,
|
|
57
|
+
whether as an array, a map form (`{ ALL, POST }`), a group, or nothing at all. There is
|
|
58
|
+
no route that reaches a controller without passing through it, so there is no redirect
|
|
59
|
+
it does not mark.
|
|
60
|
+
|
|
61
|
+
This is worth stating plainly because the opposite belief is expensive. An app that
|
|
62
|
+
thinks some routes miss the middleware writes its own global `InertiaRedirectMiddleware`
|
|
63
|
+
to cover them, and then cannot tell whether it is still needed: removing it leaves every
|
|
64
|
+
test green either way, because the tests assert a status and a `Location` and those were
|
|
65
|
+
never the part that broke.
|
|
66
|
+
|
|
67
|
+
Three things have to be true for the Inertia client to follow a redirect, and the
|
|
68
|
+
middleware guarantees all three:
|
|
69
|
+
|
|
70
|
+
| | Set on |
|
|
71
|
+
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
72
|
+
| A redirect status — `303` after a non-GET, so the browser follows with `GET` | every Inertia redirect |
|
|
73
|
+
| `Location` | your handler; carried through untouched, along with `Set-Cookie` |
|
|
74
|
+
| `X-Inertia: true` | **every** Inertia redirect, including one your handler already returned as a `303` |
|
|
75
|
+
|
|
76
|
+
That last row is the one that was wrong before 1.8.0: the marker was set inside the
|
|
77
|
+
302→303 conversion, so a handler doing the protocol-correct thing already —
|
|
78
|
+
`http.redirect(to, 303)` — skipped the only line that marked the response as Inertia's.
|
|
79
|
+
The request succeeded, the row was written, and the form sat there with its fields still
|
|
80
|
+
filled in.
|
|
81
|
+
|
|
82
|
+
### Pinning it from a test
|
|
83
|
+
|
|
84
|
+
`assertRedirect` checks the two headers that were never the problem. `assertInertiaRedirect`
|
|
85
|
+
checks all three:
|
|
86
|
+
|
|
87
|
+
```typescript fragment
|
|
88
|
+
// tests/Feature/OrdersTest.ts
|
|
89
|
+
const res = await app.post("/orders", data, { headers: { "X-Inertia": "true" } });
|
|
90
|
+
|
|
91
|
+
res.assertInertiaRedirect("/orders/1");
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Send the request with the `X-Inertia` header, or there is nothing to assert — a redirect
|
|
95
|
+
to a browser that is not running Inertia is just a redirect.
|
|
96
|
+
|
|
53
97
|
## Asset versioning
|
|
54
98
|
|
|
55
99
|
The asset version is a string sent as part of every page object. When it changes, the
|
package/docs/inertia/ssr.md
CHANGED
|
@@ -75,27 +75,112 @@ export class PostController {
|
|
|
75
75
|
### Requirements
|
|
76
76
|
|
|
77
77
|
- `react-dom/server` ≥ 18 (for `renderToReadableStream`)
|
|
78
|
+
- `@inertiajs/react` — the same adapter the browser entry point uses; the server
|
|
79
|
+
renders through its `<App>` so `<Head>` works (see below)
|
|
78
80
|
- The HTML template must contain `<!-- @inertia -->`
|
|
79
81
|
- The page component must exist under your pages directory (`resources/js/pages/<component>.tsx`)
|
|
80
82
|
|
|
81
|
-
It throws if the template hasn't loaded, or if the component name contains path
|
|
82
|
-
traversal (`..` or a leading `/`).
|
|
83
|
-
|
|
84
83
|
### inertia vs. inertiaStream
|
|
85
84
|
|
|
86
|
-
| Criterion | `inertia()`
|
|
87
|
-
| -------------- |
|
|
88
|
-
| Return type | `Promise<void>`
|
|
89
|
-
| Rendering |
|
|
90
|
-
| Response body | Fully buffered string
|
|
91
|
-
| TTFB |
|
|
92
|
-
|
|
|
85
|
+
| Criterion | `inertia()` | `inertiaStream()` |
|
|
86
|
+
| -------------- | ------------------------ | ---------------------------------- |
|
|
87
|
+
| Return type | `Promise<void>` | `Promise<void>` |
|
|
88
|
+
| Rendering | None — empty root + JSON | Streaming `renderToReadableStream` |
|
|
89
|
+
| Response body | Fully buffered string | Streaming `ReadableStream` |
|
|
90
|
+
| TTFB | Immediate | After the shell is ready |
|
|
91
|
+
| Page `<Head>` | Client only | Collected into the served `<head>` |
|
|
92
|
+
| XHR navigation | JSON (the normal path) | N/A — only the first-page document |
|
|
93
93
|
|
|
94
94
|
For XHR navigations (`X-Inertia: true`), keep using `inertia()` — streaming only
|
|
95
95
|
benefits the initial HTML document load.
|
|
96
96
|
|
|
97
97
|
> **Tip** — Stream the heaviest landing pages and leave everything else on `inertia()`.
|
|
98
98
|
|
|
99
|
+
## Page metadata: `<Head>` on the server
|
|
100
|
+
|
|
101
|
+
Both server-rendered paths — `inertiaStream()` and the `/__ssr` endpoint — collect
|
|
102
|
+
whatever your page's `<Head>` declares and splice it into the template's `<head>`
|
|
103
|
+
before the response goes out. A page writes its metadata once, in the component, and
|
|
104
|
+
gets it in the HTML as well as in the browser:
|
|
105
|
+
|
|
106
|
+
```tsx fragment
|
|
107
|
+
// resources/js/pages/Trips/Show.tsx
|
|
108
|
+
import { Head } from "@inertiajs/react";
|
|
109
|
+
|
|
110
|
+
export default function Show({ trip }) {
|
|
111
|
+
return (
|
|
112
|
+
<>
|
|
113
|
+
<Head>
|
|
114
|
+
<title>{trip.name}</title>
|
|
115
|
+
<meta name="description" content={trip.summary} />
|
|
116
|
+
<meta property="og:title" content={trip.name} />
|
|
117
|
+
<meta property="og:image" content={trip.heroUrl} />
|
|
118
|
+
</Head>
|
|
119
|
+
…
|
|
120
|
+
</>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
An injected tag **replaces** the template's tag of the same identity rather than
|
|
126
|
+
being added after it — `<title>` by being a title, `<meta>` by its `name` or
|
|
127
|
+
`property`. That is not a detail: a document with two `<title>` tags is a document
|
|
128
|
+
with the _first_ one, so an appended title would be present, correct and ignored.
|
|
129
|
+
Anything the template does not already declare is appended before `</head>`.
|
|
130
|
+
|
|
131
|
+
Two things it does not do:
|
|
132
|
+
|
|
133
|
+
- **The title callback is client-side.** `createInertiaApp({ title })` in your
|
|
134
|
+
browser entry point is not visible to the server, so a page rendering
|
|
135
|
+
`<Head><title>Kruger</title></Head>` serves `Kruger` and the browser then shows
|
|
136
|
+
`Kruger — App`. Put the suffix in the `<Head>` itself if the served title matters
|
|
137
|
+
to you, which for a link preview it usually does.
|
|
138
|
+
- **`inertia()` does not render, so it does not collect.** A page returned through
|
|
139
|
+
plain `inertia()` sends the template's `<head>` as written. See
|
|
140
|
+
[What a crawler sees](#what-a-crawler-sees).
|
|
141
|
+
|
|
142
|
+
## What a crawler sees
|
|
143
|
+
|
|
144
|
+
`inertia()` — the default — **does not server-render the component at all.** Its
|
|
145
|
+
response body is the template with an empty root and the page object beside it:
|
|
146
|
+
|
|
147
|
+
```html
|
|
148
|
+
<body>
|
|
149
|
+
<div id="app"></div>
|
|
150
|
+
<script type="application/json" data-page="app">
|
|
151
|
+
{ … }
|
|
152
|
+
</script>
|
|
153
|
+
</body>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
That is the normal Inertia arrangement and it is the right default: the page is
|
|
157
|
+
built by the client, and every navigation after the first is JSON. But it means the
|
|
158
|
+
served document contains **a title and a JSON blob**, and it is worth knowing which
|
|
159
|
+
readers of your site run JavaScript and which do not:
|
|
160
|
+
|
|
161
|
+
| Reader | Runs JavaScript | Sees your page |
|
|
162
|
+
| ---------------------------------------------------- | --------------------- | ----------------- |
|
|
163
|
+
| A browser | yes | yes |
|
|
164
|
+
| Googlebot, Bingbot | yes, on a second pass | yes, later |
|
|
165
|
+
| WhatsApp, Slack, iMessage, X, Facebook link previews | **no** | title + meta only |
|
|
166
|
+
| `curl`, uptime checks, most RSS and reader tools | **no** | title + meta only |
|
|
167
|
+
|
|
168
|
+
So the link preview a page produces is decided entirely by its `<head>` — which is
|
|
169
|
+
the template's, identically, on every page, unless you do one of these:
|
|
170
|
+
|
|
171
|
+
1. **Switch the page to `inertiaStream()`.** The component is rendered, `<Head>` is
|
|
172
|
+
collected, and the served `<head>` is the page's own. This is the smallest change
|
|
173
|
+
and the one to reach for on pages that get shared.
|
|
174
|
+
2. **Turn on endpoint SSR** (`ssr: true`) for the whole app.
|
|
175
|
+
3. **Set the tags in middleware**, if the metadata is server-side data the component
|
|
176
|
+
does not otherwise need.
|
|
177
|
+
|
|
178
|
+
`curl` is also how most people first check whether a deploy worked. An empty
|
|
179
|
+
`<div id="app">` in that output is not a broken deploy.
|
|
180
|
+
|
|
181
|
+
It throws if the template hasn't loaded, or if the component name contains path
|
|
182
|
+
traversal (`..` or a leading `/`).
|
|
183
|
+
|
|
99
184
|
## Next steps
|
|
100
185
|
|
|
101
186
|
- [Inertia overview](/docs/inertia) — the guide's front page and the rest of the sections.
|
package/docs/middleware.md
CHANGED
|
@@ -206,6 +206,37 @@ The package ships several middleware you can drop straight into `app.use([...])`
|
|
|
206
206
|
or a route's middleware array. Each extends `BaseMiddleware`, so `.with({ … })`
|
|
207
207
|
bakes options into a zero-argument class.
|
|
208
208
|
|
|
209
|
+
### Names the framework already occupies
|
|
210
|
+
|
|
211
|
+
Middleware live in a flat namespace: your `app/middleware/` classes are discovered by
|
|
212
|
+
class name, and so are the ones a package exports. Naming one of yours after one of
|
|
213
|
+
these is not caught as a conflict — it surfaces later as a type error somewhere that
|
|
214
|
+
does not mention either file, which is a confusing way to learn that
|
|
215
|
+
`TwoFactorMiddleware` was taken.
|
|
216
|
+
|
|
217
|
+
The full list, so you can check before you name:
|
|
218
|
+
|
|
219
|
+
| Middleware | Package |
|
|
220
|
+
| -------------------------------------------------------------------------------------- | -------------------------------- |
|
|
221
|
+
| `CorsMiddleware`, `SecureHeadersMiddleware`, `ThrottleMiddleware`, `WebhookMiddleware` | `@zerotal/core` |
|
|
222
|
+
| `AuthMiddleware`, `GuestMiddleware`, `PersistUserMiddleware`, `RememberMeMiddleware` | `@zerotal/auth` |
|
|
223
|
+
| `BasicAuthMiddleware`, `BearerTokenMiddleware`, `JwtGuardMiddleware` | `@zerotal/auth` |
|
|
224
|
+
| `RequireRoleMiddleware`, `RequirePermissionMiddleware`, `TwoFactorMiddleware` | `@zerotal/auth` |
|
|
225
|
+
| `ValidateSignatureMiddleware` | `@zerotal/auth` |
|
|
226
|
+
| `SessionMiddleware`, `CsrfMiddleware`, `AuthSessionMiddleware` | `@zerotal/session` |
|
|
227
|
+
| `InertiaMiddleware`, `PrecognitionMiddleware` | `@zerotal/inertia` |
|
|
228
|
+
| `AdminGuardMiddleware`, `AdminAbilityMiddleware` | `@zerotal/admin` |
|
|
229
|
+
| `MonitorAuthMiddleware`, `MonitorPayloadMiddleware` | `@zerotal/monitor` |
|
|
230
|
+
| `IdempotencyMiddleware` | `@zerotal/cache` |
|
|
231
|
+
| `LocaleMiddleware` | `@zerotal/i18n` |
|
|
232
|
+
| `EnsureTenancyMiddleware` | `@zerotal/tenancy` |
|
|
233
|
+
| `TelemetryMiddleware` | `@zerotal/telemetry` |
|
|
234
|
+
| `BaseMiddleware` | `@zerotal/core` (the base class) |
|
|
235
|
+
|
|
236
|
+
If yours does something different from the framework's, say so in the name rather
|
|
237
|
+
than shadowing it — `RequireTwoFactorMiddleware` for "fence the console until staff
|
|
238
|
+
have enrolled" reads better than `TwoFactorMiddleware` anyway, and cannot collide.
|
|
239
|
+
|
|
209
240
|
### CorsMiddleware
|
|
210
241
|
|
|
211
242
|
```ts fragment
|
package/docs/rate-limiting.md
CHANGED
|
@@ -70,6 +70,14 @@ ThrottleMiddleware.with({
|
|
|
70
70
|
which entry is not attacker-controlled. Left `undefined` (or `0`), the unspoofable socket
|
|
71
71
|
address is used.
|
|
72
72
|
|
|
73
|
+
**Counted from the right, and that is the whole of it.** Each proxy _appends_ the address
|
|
74
|
+
it received the request from, so the rightmost entries are the ones your own
|
|
75
|
+
infrastructure wrote and the leftmost is whatever the client sent. Reading the header
|
|
76
|
+
left-to-right — the obvious way, and how most hand-rolled versions do it — hands the
|
|
77
|
+
limiter's key to the attacker: they set `X-Forwarded-For: <your CFO's IP>`, spend the
|
|
78
|
+
budget, and the person whose address they borrowed is locked out of the form. A limiter
|
|
79
|
+
that can be aimed is worse than no limiter, because it looks like it is working.
|
|
80
|
+
|
|
73
81
|
> **Danger** — That default is right, and it is the wrong answer the moment you deploy
|
|
74
82
|
> behind a proxy. The socket address is then the _proxy's_ — `127.0.0.1` for every visitor
|
|
75
83
|
> — so everyone shares one bucket per form and the limiter inverts into the thing it was
|
|
@@ -117,8 +125,9 @@ import { RateLimiter } from "zerotal";
|
|
|
117
125
|
// 1000 req/hour per authenticated user (falls back to IP when unauthenticated)
|
|
118
126
|
RateLimiter.for("api").limit(1000).every(3600).byUser().register();
|
|
119
127
|
|
|
120
|
-
// 5 login attempts per minute, per IP
|
|
121
|
-
|
|
128
|
+
// 5 login attempts per minute, per IP. `.trustedProxies(1)` because this app is
|
|
129
|
+
// behind one reverse proxy — without it every visitor shares the proxy's address.
|
|
130
|
+
RateLimiter.for("login").limit(5).every(60).byIp().trustedProxies(1).register();
|
|
122
131
|
|
|
123
132
|
// 500 req/min keyed by an API-key header (unknown key → per IP)
|
|
124
133
|
RateLimiter.for("partner").limit(500).every(60).byApiKey("x-api-key").register();
|
|
@@ -140,12 +149,30 @@ RateLimiter.for("upload")
|
|
|
140
149
|
Each `.by*()` call sets how requests are bucketed. The default (no `.by*()` call)
|
|
141
150
|
is the client IP.
|
|
142
151
|
|
|
143
|
-
| Method | Keys on
|
|
144
|
-
| -------------------- |
|
|
145
|
-
| `.byUser()` | `ctx.user.id`
|
|
146
|
-
| `.byApiKey(header?)` | `x-api-key` header (or a custom header)
|
|
147
|
-
| `.byIp()` |
|
|
148
|
-
| `.by(fn)` | Return value of your function
|
|
152
|
+
| Method | Keys on | Falls back to |
|
|
153
|
+
| -------------------- | --------------------------------------- | ------------------------ |
|
|
154
|
+
| `.byUser()` | `ctx.user.id` | IP when unauthenticated |
|
|
155
|
+
| `.byApiKey(header?)` | `x-api-key` header (or a custom header) | IP when header is absent |
|
|
156
|
+
| `.byIp()` | Client IP (the explicit default) | `'unknown'` |
|
|
157
|
+
| `.by(fn)` | Return value of your function | — |
|
|
158
|
+
|
|
159
|
+
> **Danger** — **Every one of the built-in strategies can end up keying on an
|
|
160
|
+
> address**, including `.byUser()` and `.byApiKey()` — for a request with no user and
|
|
161
|
+
> no key, which on a login form is every request that matters. So a named limiter
|
|
162
|
+
> behind a reverse proxy needs `.trustedProxies(n)` for exactly the reason
|
|
163
|
+
> [`ThrottleMiddleware` does](#throttlemiddleware--inline): without it the address is
|
|
164
|
+
> the socket's, which is the _proxy's_, and every visitor shares one bucket. A
|
|
165
|
+
> `login` limiter of five attempts a minute becomes five attempts a minute for your
|
|
166
|
+
> whole user base, and one attacker locks everybody out.
|
|
167
|
+
>
|
|
168
|
+
> ```typescript fragment
|
|
169
|
+
> // config/limiters.ts — behind one reverse proxy
|
|
170
|
+
> RateLimiter.for("login").limit(5).every(60).byIp().trustedProxies(1).register();
|
|
171
|
+
> ```
|
|
172
|
+
>
|
|
173
|
+
> `zt doctor` reports a named limiter that keys on an address and was never told
|
|
174
|
+
> about a proxy. `.by(fn)` is yours — it is exempt, and resolving the address is on
|
|
175
|
+
> you if you use one.
|
|
149
176
|
|
|
150
177
|
### Applying a named limiter
|
|
151
178
|
|
|
@@ -263,15 +290,16 @@ await app.post("/login", { email: "a@b.c" }, { "X-Forwarded-For": "10.0.0.7" });
|
|
|
263
290
|
|
|
264
291
|
### `LimiterDefinition` (fluent)
|
|
265
292
|
|
|
266
|
-
| Method
|
|
267
|
-
|
|
|
268
|
-
| `limit`
|
|
269
|
-
| `every`
|
|
270
|
-
| `byUser`
|
|
271
|
-
| `byApiKey`
|
|
272
|
-
| `byIp`
|
|
273
|
-
| `by`
|
|
274
|
-
| `
|
|
293
|
+
| Method | Signature | Description |
|
|
294
|
+
| ---------------- | -------------------------------------------- | ---------------------------------------------------------- |
|
|
295
|
+
| `limit` | `limit(max: number): this` | Maximum requests in the window (default `60`). |
|
|
296
|
+
| `every` | `every(seconds: number): this` | Window duration in seconds (default `60`). |
|
|
297
|
+
| `byUser` | `byUser(): this` | Key by `ctx.user.id`; IP when unauthenticated. |
|
|
298
|
+
| `byApiKey` | `byApiKey(header?: string): this` | Key by header value (default `x-api-key`); IP when absent. |
|
|
299
|
+
| `byIp` | `byIp(): this` | Key by client IP (the explicit default). |
|
|
300
|
+
| `by` | `by(fn: (ctx: HttpContext) => string): this` | Key by your own resolver. |
|
|
301
|
+
| `trustedProxies` | `trustedProxies(count: number): this` | Proxies in front of the app — required behind one. |
|
|
302
|
+
| `register` | `register(): this` | Register the limiter with the global registry. |
|
|
275
303
|
|
|
276
304
|
### `ThrottleMiddleware`
|
|
277
305
|
|
package/docs/scheduler.md
CHANGED
|
@@ -61,17 +61,43 @@ import { SchedulerConfig } from "@zerotal/scheduler";
|
|
|
61
61
|
import { env } from "zerotal";
|
|
62
62
|
|
|
63
63
|
export default SchedulerConfig({
|
|
64
|
-
timezone: env("APP_TIMEZONE", "
|
|
64
|
+
timezone: env("APP_TIMEZONE", "Africa/Johannesburg"),
|
|
65
65
|
});
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
| Field | Required | Default
|
|
69
|
-
| ---------- | -------- |
|
|
70
|
-
| `timezone` | no |
|
|
68
|
+
| Field | Required | Default | Description |
|
|
69
|
+
| ---------- | -------- | --------------- | ---------------------------------------------------------------------------------- |
|
|
70
|
+
| `timezone` | no | the system zone | IANA zone every cron expression is read in, unless a task sets its own `timezone`. |
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
## Timezones
|
|
73
|
+
|
|
74
|
+
A cron expression is a wall clock: `0 3 * * *` means three in the morning
|
|
75
|
+
_somewhere_. By default that somewhere is the server's zone. Set `scheduler.timezone`
|
|
76
|
+
to make it one zone for the whole app, or `timezone` on a single schedule to override
|
|
77
|
+
it:
|
|
78
|
+
|
|
79
|
+
```typescript fragment
|
|
80
|
+
export class SendDailyReports extends Schedule {
|
|
81
|
+
cron = "0 8 * * *";
|
|
82
|
+
timezone = "Africa/Johannesburg"; // 08:00 there, whatever the server is on
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The zone is evaluated by Zerotal, not by `Bun.cron` — which reads the system zone and
|
|
87
|
+
has no option to change it. A zoned task ticks every minute and runs on the ticks
|
|
88
|
+
where its expression matches the clock in its own zone, so it stays correct across a
|
|
89
|
+
daylight-saving change rather than drifting by an hour twice a year. A minute is also
|
|
90
|
+
the finest granularity `Bun.cron` accepts, so nothing is given up.
|
|
91
|
+
|
|
92
|
+
Two consequences worth knowing:
|
|
93
|
+
|
|
94
|
+
- **A skipped hour skips the schedules inside it, and a repeated hour runs them
|
|
95
|
+
twice.** That is what every cron does. A task at `0 2 * * *` in a zone that springs
|
|
96
|
+
from 01:59 to 03:00 does not run that day.
|
|
97
|
+
- **An unknown zone name refuses at boot, loudly, and takes only its own task out.**
|
|
98
|
+
A registration failure used to propagate: the worker died during boot and
|
|
99
|
+
restart-looped, so one bad schedule stopped every schedule in the app. Now the
|
|
100
|
+
others start and the log names the one that did not.
|
|
75
101
|
|
|
76
102
|
## Defining schedules
|
|
77
103
|
|
|
@@ -134,7 +160,7 @@ Every setting is an optional property (or method) on your `Schedule` subclass:
|
|
|
134
160
|
| `cron` | `string` | Cron expression (5- or 6-field). Set this **or** override `frequency()`. |
|
|
135
161
|
| `frequency(every)` | method | Build the cadence fluently; return the task (see helpers below). |
|
|
136
162
|
| `name` | `string` | Task name in `schedule:list` and logs. Defaults to the class name. |
|
|
137
|
-
| `timezone` | `string` | IANA timezone the cron is evaluated in.
|
|
163
|
+
| `timezone` | `string` | IANA timezone the cron is evaluated in — overrides `scheduler.timezone`. See [Timezones](#timezones). |
|
|
138
164
|
| `withoutOverlapping` | `boolean \| OverlapLockOptions` | Skip a tick while a previous run is active; also takes a cross-process lock when a lock driver is configured. |
|
|
139
165
|
| `environments` | `string[]` | Only run when `APP_ENV` is one of these. |
|
|
140
166
|
| `inBackground` | `boolean` | Run the body without blocking the scheduler tick. |
|
|
@@ -489,6 +515,19 @@ override async onStarted(): Promise<void> {
|
|
|
489
515
|
For production, run the worker as a separate process so it can be scaled, restarted,
|
|
490
516
|
and monitored independently of the web server.
|
|
491
517
|
|
|
518
|
+
> **A web process says so when it is not running your schedules.** `app/schedules/`
|
|
519
|
+
> is only discovered in `worker` and `console`, so a web process skips it by not
|
|
520
|
+
> looking — which used to be completely silent, and is how an app runs for weeks in
|
|
521
|
+
> production with every schedule written and none of them ever firing. A boot line
|
|
522
|
+
> now names it:
|
|
523
|
+
>
|
|
524
|
+
> ```
|
|
525
|
+
> Skipping 3 file(s) in app/schedules — the "schedules" convention does not run in env=web (it runs in: worker, console).
|
|
526
|
+
> ```
|
|
527
|
+
>
|
|
528
|
+
> Seeing that on a web process is correct. Seeing it and having no worker running is
|
|
529
|
+
> the hole.
|
|
530
|
+
|
|
492
531
|
## References
|
|
493
532
|
|
|
494
533
|
The `Scheduler` facade resolves the `scheduler` container binding — a
|
|
@@ -514,6 +553,30 @@ The `Scheduler` facade resolves the `scheduler` container binding — a
|
|
|
514
553
|
| `stop` | `stop(): void` | Stop every running task. |
|
|
515
554
|
| `tasks` | `get tasks(): ReadonlyMap<string, ScheduledTask>` | The registered tasks, keyed by name. |
|
|
516
555
|
|
|
556
|
+
### Timezone helpers
|
|
557
|
+
|
|
558
|
+
The zone arithmetic the scheduler uses to evaluate a cron somewhere other than the
|
|
559
|
+
server, exported because an app doing its own time-window logic needs the same
|
|
560
|
+
answers.
|
|
561
|
+
|
|
562
|
+
| Export | Signature | Description |
|
|
563
|
+
| ------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
564
|
+
| `isValidTimeZone` | `isValidTimeZone(tz: string): boolean` | Whether this runtime knows the IANA zone. Check before storing one a user typed. |
|
|
565
|
+
| `wallClockIn` | `wallClockIn(date: Date, tz: string): Date` | The same instant, shifted so the Date's _local_ getters read that zone's clock face. |
|
|
566
|
+
| `CronExpression.matchesIn` | `matchesIn(date: Date, tz: string): boolean` | Whether the expression fires at `date`, read in `tz`. |
|
|
567
|
+
| `CronExpression.nextRunAfterIn` | `nextRunAfterIn(expr, from: Date, tz): Date \| null` | The next real instant the expression fires on that zone's clock — correct across a DST change. |
|
|
568
|
+
|
|
569
|
+
`wallClockIn` returns a Date that is a lie about the instant and true about the clock
|
|
570
|
+
face: its epoch value is off by the zone offset. Pass it to a field comparison, never
|
|
571
|
+
back to a caller.
|
|
572
|
+
|
|
573
|
+
### Errors
|
|
574
|
+
|
|
575
|
+
| Error | Thrown when |
|
|
576
|
+
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
|
577
|
+
| `SchedulerError` | Base class for everything this package throws. Catch it to catch them all. |
|
|
578
|
+
| `UnknownTimeZoneError` | A task declares a `timezone` this runtime does not know — at registration, naming the task. |
|
|
579
|
+
|
|
517
580
|
### ScheduledTask introspection
|
|
518
581
|
|
|
519
582
|
| Member | Signature | Description |
|
package/docs/testing/index.md
CHANGED
|
@@ -209,6 +209,105 @@ observers, global scopes, and state-machine callbacks, plus framework event
|
|
|
209
209
|
subscriptions. `createTestApp()` and `testApp.close()` call it for you, so suites
|
|
210
210
|
using those helpers don't need the explicit `afterEach`.
|
|
211
211
|
|
|
212
|
+
## `bun test` vs `bun zt test`
|
|
213
|
+
|
|
214
|
+
Both run the same files. `bun zt test` is a wrapper that sets up three things Bun's
|
|
215
|
+
runner does not, and each of them has cost somebody a day:
|
|
216
|
+
|
|
217
|
+
| | `bun test` | `bun zt test` |
|
|
218
|
+
| ---------------- | --------------------- | ---------------------------------------------------------- |
|
|
219
|
+
| Per-test timeout | Bun's default, 5000ms | 30000ms (`--timeout`, override with `--timeout=`) |
|
|
220
|
+
| Runtime check | none | refuses a Bun below the project's `engines.bun` |
|
|
221
|
+
| DB wiring | none | preloads `@zerotal/testing/preload` and passes `ZT_DB_URL` |
|
|
222
|
+
|
|
223
|
+
### The timeout
|
|
224
|
+
|
|
225
|
+
Bun's default per-test timeout is 5000ms, and a suite that boots an app per file
|
|
226
|
+
exceeds it on a loaded machine — CI, or a laptop that has just run `tsc`. The
|
|
227
|
+
failures look like flakes, which is the expensive part: a flake gets re-run, and a
|
|
228
|
+
re-run passes.
|
|
229
|
+
|
|
230
|
+
`bun zt test` sets `--timeout=30000`. If you run `bun test` directly, pass it
|
|
231
|
+
yourself, because **the two documented-looking alternatives do not work**:
|
|
232
|
+
|
|
233
|
+
- `[test] timeout` in `bunfig.toml` — ignored.
|
|
234
|
+
- `setDefaultTimeout()` in a preload — applies to the first test file only. Bun
|
|
235
|
+
re-imports the preload per file, but the setting does not survive.
|
|
236
|
+
|
|
237
|
+
The command-line flag is the only mechanism that covers hooks as well as tests,
|
|
238
|
+
which matters because it is usually a `beforeAll` that boots the app.
|
|
239
|
+
|
|
240
|
+
### The runtime
|
|
241
|
+
|
|
242
|
+
`engines.bun` in your `package.json` is a floor, and until you enforce it, it is a
|
|
243
|
+
comment. The shell's `bun` and the project's can differ, and the difference between
|
|
244
|
+
two Bun releases is real and narrow: `Intl` formatting, the SQLite bindings and
|
|
245
|
+
`node:` compatibility all move. So a handful of currency or date assertions go red
|
|
246
|
+
and the rest pass, and you go looking for a bug in the code they touch, because
|
|
247
|
+
nothing in the failure says "wrong binary".
|
|
248
|
+
|
|
249
|
+
`bun zt test` refuses to run below the declared floor. Direct `bun test` runs get the
|
|
250
|
+
same check as a warning if you load the preload:
|
|
251
|
+
|
|
252
|
+
```toml
|
|
253
|
+
# bunfig.toml
|
|
254
|
+
[test]
|
|
255
|
+
preload = ["@zerotal/testing/preload"]
|
|
256
|
+
timeout = 30000 # note: currently ignored by Bun — pass --timeout on the command line
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Set `ZT_ALLOW_RUNTIME_MISMATCH=1` to downgrade the refusal to a warning while you
|
|
260
|
+
are mid-upgrade.
|
|
261
|
+
|
|
262
|
+
### `@zerotal/core/runtime`
|
|
263
|
+
|
|
264
|
+
The checks behind the two paragraphs above, exported so a script or a test of your
|
|
265
|
+
own can make the same assertion. `zt` runs both at the top of every command; the test
|
|
266
|
+
preload runs the floor check as a warning.
|
|
267
|
+
|
|
268
|
+
| Export | Signature | What it answers |
|
|
269
|
+
| -------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
270
|
+
| `declaredBunFloor` | `declaredBunFloor(cwd): { range, manifest } \| null` | The nearest `engines.bun` up the tree from `cwd`. |
|
|
271
|
+
| `runtimeBelowFloor` | `runtimeBelowFloor(cwd?): RuntimeFloor \| null` | Is this process below that floor? `null` when it is met or none is declared. |
|
|
272
|
+
| `runtimeBelowFloorMessage` | `runtimeBelowFloorMessage(floor): string` | The explanation to print — both versions, the manifest, and the way out. |
|
|
273
|
+
| `installedBunVersion` | `installedBunVersion(cwd): { version, manifest } \| null` | The Bun in `node_modules`, if the project installs one as a package. |
|
|
274
|
+
| `runtimeMismatch` | `runtimeMismatch(cwd?): RuntimeMismatch \| null` | Does the running Bun differ from the installed one? Compared exactly — a patch is a binary. |
|
|
275
|
+
| `runtimeMismatchMessage` | `runtimeMismatchMessage(mismatch): string` | The explanation for that one. |
|
|
276
|
+
| `runtimeMismatchAllowed` | `runtimeMismatchAllowed(): boolean` | Whether `ZT_ALLOW_RUNTIME_MISMATCH` is set. |
|
|
277
|
+
| `bunBinary` | `bunBinary(): string` | The binary to spawn a child with — `process.execPath`, never the name PATH resolves. |
|
|
278
|
+
| `RUNTIME_MISMATCH_ESCAPE` | `"ZT_ALLOW_RUNTIME_MISMATCH"` | The env var name, so a script can set it without hardcoding the string. |
|
|
279
|
+
|
|
280
|
+
`RuntimeFloor` is `{ running, required, manifest }`; `RuntimeMismatch` is
|
|
281
|
+
`{ running, installed, manifest }`. Both name the file the second version came from,
|
|
282
|
+
because "which one is wrong" is the question you actually have.
|
|
283
|
+
|
|
284
|
+
## Configuration is per-process, and `bun test` is one process
|
|
285
|
+
|
|
286
|
+
Zerotal resolves configuration once, at boot. `bun test` runs every file in the same
|
|
287
|
+
process, so **whichever file boots the app first fixes the configuration for all of
|
|
288
|
+
them.**
|
|
289
|
+
|
|
290
|
+
A test that sets an environment variable in its own `beforeAll` and then asserts on
|
|
291
|
+
the resulting behaviour passes alone and fails in the suite — or worse, passes in the
|
|
292
|
+
suite for a reason unrelated to what it claims to test:
|
|
293
|
+
|
|
294
|
+
```typescript fragment
|
|
295
|
+
// Passes alone. In a suite, the app may already be booted with CSRF on, and the
|
|
296
|
+
// three "rejects without a token" assertions below pass on a 419 they would have
|
|
297
|
+
// got anyway — never reaching the guard they name.
|
|
298
|
+
beforeAll(() => {
|
|
299
|
+
Bun.env.CSRF_DISABLED = "1";
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Assert on the _relationship_ rather than on a literal — the published origin equals
|
|
304
|
+
the configured one, whatever it is — or boot a dedicated app for the case:
|
|
305
|
+
|
|
306
|
+
```typescript fragment
|
|
307
|
+
const app = await createTestApp({ config: { app: { url: "https://example.test" } } });
|
|
308
|
+
expect(page.canonical).toBe(config("app.url"));
|
|
309
|
+
```
|
|
310
|
+
|
|
212
311
|
## Running the suite from a script
|
|
213
312
|
|
|
214
313
|
A script that gates on the tests has to read the tests' exit status, and a pipe hides it:
|
package/docs/upgrade.md
CHANGED
|
@@ -169,6 +169,54 @@ these are the changes that need action. Full detail is in the
|
|
|
169
169
|
interface so pages that read them do not look unpassed; see
|
|
170
170
|
[Typed props](/docs/inertia/props#typed-props).
|
|
171
171
|
|
|
172
|
+
## 1.9 to 1.10
|
|
173
|
+
|
|
174
|
+
Three settings changed meaning. Each is quiet if it does not apply to you, and each is
|
|
175
|
+
worth thirty seconds of checking if it does.
|
|
176
|
+
|
|
177
|
+
1. **`scheduler.timezone` is honoured.** It was documented as informational and read by
|
|
178
|
+
nothing, so whatever you put there had no effect and your schedules ran in the
|
|
179
|
+
server's zone. It is now the zone every schedule is evaluated in unless the task sets
|
|
180
|
+
its own.
|
|
181
|
+
|
|
182
|
+
Its default moved from the literal `"UTC"` to **the system zone**, so an app that never
|
|
183
|
+
set the key keeps doing exactly what it did. The case to check is an app that _did_:
|
|
184
|
+
|
|
185
|
+
```ts fragment
|
|
186
|
+
// config/scheduler.ts
|
|
187
|
+
export default SchedulerConfig({ timezone: env("APP_TIMEZONE", "UTC") });
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
On a server that is not on UTC, that line used to do nothing and now moves every
|
|
191
|
+
schedule. Either set it to the zone you actually want your crons read in — which is
|
|
192
|
+
the point of the setting — or delete the key to keep the server's zone.
|
|
193
|
+
|
|
194
|
+
`bun zt schedule:list` prints each task's next run in its own zone, which is the
|
|
195
|
+
quickest way to see whether anything moved.
|
|
196
|
+
|
|
197
|
+
2. **Named rate limiters need `.trustedProxies(n)` behind a proxy.** `RateLimiter`'s
|
|
198
|
+
`.byIp()`, `.byUser()` and `.byApiKey()` ignored the proxy count entirely and read
|
|
199
|
+
`X-Forwarded-For` unconditionally. They now follow the same rule `ThrottleMiddleware`
|
|
200
|
+
already did — the header is consulted only when you say how many proxies sit in front:
|
|
201
|
+
|
|
202
|
+
```ts fragment
|
|
203
|
+
RateLimiter.for("login").limit(5).every(60).byIp().trustedProxies(1).register();
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Without it the address used is the socket's, which behind a proxy is the _proxy's_, and
|
|
207
|
+
every visitor shares one bucket. `bun zt doctor` reports any limiter that needs this —
|
|
208
|
+
it could not before, because its check exempted custom key resolvers and all three of
|
|
209
|
+
these are one.
|
|
210
|
+
|
|
211
|
+
3. **React apps using SSR need `@inertiajs/react` installed.** The same adapter your
|
|
212
|
+
browser entry point already uses. Server-side rendering now goes through its `<App>`,
|
|
213
|
+
which is what makes `<Head>` produce a title and an og: card in the HTML your server
|
|
214
|
+
actually sends. If it is missing you get a named error at render time, not a silent
|
|
215
|
+
omission.
|
|
216
|
+
|
|
217
|
+
Nothing to change if you already have it as a dependency, which every React Inertia app
|
|
218
|
+
does.
|
|
219
|
+
|
|
172
220
|
## The managed zt.ts
|
|
173
221
|
|
|
174
222
|
`zt.ts` is framework-managed — the header says _do not modify_. If a release
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/arch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
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.
|
|
38
|
+
"@zerotal/core": "1.10.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^5.8.0",
|
|
42
|
-
"@zerotal/orm": "1.
|
|
42
|
+
"@zerotal/orm": "1.10.0"
|
|
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": [
|
|
@@ -150,7 +150,7 @@ export interface GuidelineOptions {
|
|
|
150
150
|
* How this project is configured, from {@link detectShape}. Omitted, the block
|
|
151
151
|
* is what it always was — a function of the package list.
|
|
152
152
|
*/
|
|
153
|
-
shape?: ProjectShape;
|
|
153
|
+
shape?: ProjectShape | undefined;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
/**
|
package/src/mcp/stdio.ts
CHANGED
|
@@ -27,11 +27,11 @@ import type { JsonRpcResponse } from "./types.ts";
|
|
|
27
27
|
export interface StdioOptions {
|
|
28
28
|
server: McpServer;
|
|
29
29
|
/** Byte source. Defaults to this process's stdin. */
|
|
30
|
-
input?: ReadableStream<Uint8Array
|
|
30
|
+
input?: ReadableStream<Uint8Array> | undefined;
|
|
31
31
|
/** Frame sink. Defaults to this process's stdout. Injected in tests. */
|
|
32
|
-
write?: (frame: string) => void;
|
|
32
|
+
write?: ((frame: string) => void) | undefined;
|
|
33
33
|
/** Diagnostics sink. Defaults to stderr — never stdout. */
|
|
34
|
-
log?: (message: string) => void;
|
|
34
|
+
log?: ((message: string) => void) | undefined;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
package/src/tools/_probe.ts
CHANGED
|
@@ -35,8 +35,8 @@ export interface ProbeRunner {
|
|
|
35
35
|
|
|
36
36
|
export interface SpawnProbeOptions {
|
|
37
37
|
/** Where to start looking for the app. Defaults to the server's working directory. */
|
|
38
|
-
cwd?: string;
|
|
39
|
-
timeoutMs?: number;
|
|
38
|
+
cwd?: string | undefined;
|
|
39
|
+
timeoutMs?: number | undefined;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|