@ultimat3/cli 19.2.0 → 19.3.2
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/CLAUDE.md +135 -9
- package/README.md +1 -1
- package/package.json +29 -29
- package/src/app-agents-md.ts +14 -3
- package/src/app-boundaries.ts +11 -2
- package/src/app-load.ts +96 -25
- package/src/budgets.ts +17 -6
- package/src/cmd-dev-fixture.ts +25 -0
- package/src/cmd-dev.ts +48 -46
- package/src/cmd-doctor.ts +61 -23
- package/src/cmd-generate.ts +25 -3
- package/src/cmd-i18n.ts +10 -3
- package/src/cmd-jobs.ts +56 -10
- package/src/cmd-test.ts +15 -10
- package/src/db-seed.ts +2 -1
- package/src/dev-queue.ts +16 -2
- package/src/dev-reload.ts +46 -0
- package/src/dev-render.ts +35 -9
- package/src/dev-runtime.ts +4 -1
- package/src/dev-sync.ts +11 -3
- package/src/dev-watch-tree.ts +226 -0
- package/src/dev-watch.ts +59 -37
- package/src/doctor-offline.ts +122 -0
- package/src/error-catalog.ts +4 -5
- package/src/fix-command.ts +40 -1
- package/src/fix-path.ts +10 -11
- package/src/flag-number.ts +15 -0
- package/src/generate-files.ts +24 -2
- package/src/generate-kinds.ts +54 -4
- package/src/generate-write.ts +25 -2
- package/src/gitignore.ts +145 -0
- package/src/hold.ts +50 -17
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +2 -1
- package/src/island-states-load.ts +2 -1
- package/src/jobs-driver.ts +4 -1
- package/src/mcp-host.ts +18 -9
- package/src/parse.ts +17 -0
- package/src/path-segments.ts +14 -0
- package/src/prerender.ts +46 -20
- package/src/retry-memo.ts +37 -0
- package/src/scaffold-fixture.ts +17 -0
- package/src/serve.ts +40 -5
- package/src/source-files.ts +3 -1
- package/src/sw-artifacts.ts +71 -7
- package/src/templates/action.ts +47 -16
- package/src/templates/admin-page.ts +49 -1
- package/src/templates/island.ts +4 -2
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-docs.ts +7 -0
- package/src/templates/scaffold-entries.ts +4 -2
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/slice-foundation.ts +36 -0
- package/src/test-passes.ts +79 -0
- package/src/test-shards.ts +110 -36
- package/src/verify-checks.ts +11 -8
- package/src/verify-floor.ts +59 -3
- package/src/verify-step.ts +4 -4
- package/src/verify-tests.ts +14 -2
package/CLAUDE.md
CHANGED
|
@@ -8,12 +8,13 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
|
|
|
8
8
|
| stdout | `write-line.ts`'s `writeLine` — synchronous fd 1, never `process.stdout.write`, which truncates at the 64KB pipe buffer when `process.exit` follows. Exported, because `create-ultimate`'s entry point needs the same one |
|
|
9
9
|
| stderr | `write-line.ts`'s `writeErrorLine` — the same loop on fd 2, for a line that is not the command's answer. A `CommandResult` declaring `stream: 'stderr'` is routed there by `dispatch.ts`'s `sinkFor`, and `x mcp serve --transport stdio` is the one case: its fd 1 carries JSON-RPC frames, so the `✓ mcp stdio serving 13 tools` line rendered after the loop was a malformed frame. Neither renderer carries `stream`, exactly like `hold` |
|
|
10
10
|
| Boot logs under `--json` | `dispatch.ts` calls core's `setLogStream('stderr')` when `args.json` is set, once, for all thirty commands. `x db migrate --json` printed the boot logger's `ultimate migrate applied` and then the command's own object, so `json.load` raised on the second document. A server's stdout stays its log stream; this is the CLI process only |
|
|
11
|
-
| Test execution | `test-shards.ts`'s `testArgs` — ONE `bun test --parallel=N
|
|
11
|
+
| Test execution | `test-shards.ts`'s `testArgs` — ONE `bun test --parallel=N` per PASS (`test-passes.ts`; a selection mixing `live`/`e2e` files with the rest is two passes, the serial one at `--parallel=1`), never N processes this repo packs itself. It did pack them, largest-first greedy over file SIZE, and the packer was deleted for buying **nothing**: four interleaved runs each on the 1296-file unit corpus gave 58.2/60.0/65.0/66.5s hand-packed against 54.5/57.8/61.7/64.5s under `--parallel=8`, within noise, because both are work-bound — 436.7s of file time is a 54.6s floor on 8 workers and the slowest single file is 20.5s. A greedy pack of 1296 small items lands near-optimal by accident. `--timings` is refused on the same evidence (#342). `--parallel` implies `--isolate`, so the per-file module registry is unchanged, and the per-worker database is too: `@ultimat3/testing`'s `workerId` already read `BUN_TEST_WORKER_ID`, which Bun sets 1..N. `ULTIMATE_TEST_WORKER` is set only for a single-shard `x test --worker I` rerun, which is one process |
|
|
12
12
|
| Numeric flags | `flag-number.ts` — one reader for `--port` / `--workers` / `--shard`. A bare `Number.parseInt` accepts `4abc` and answers `NaN`, which turned three checks into ones that cannot fail |
|
|
13
13
|
| Shell quoting | `shell-quote.ts`'s `quoteArg` — every value the CLI pastes into a `fix:` or a reproduce line, `exec.ts`'s missing-program refusal and `test-shards.ts`'s reproduce command both. A name holding a space or a `;` interpolated bare is an instruction that runs something else |
|
|
14
14
|
| Missing positionals | `MissingPositionalError`, never `BadFlagError` (names a flag that does not exist) and never `UnknownCommandError` (says a known command form is not one). Its `example` is a REAL invocation — `x g route <name>` in a shell is a redirect |
|
|
15
15
|
| Bare subcommands | `CommandSpec.defaultSubcommand`, **declared**. The parser answered `subcommands[0]` until 1.2.0, so `x db` ran `gen` — the migration GENERATOR — because it sorted first, and `x mcp` started a server. A command with no defensible default declares none and `MissingSubcommandError` refuses the bare form; `parse.test.ts` pins the set at exactly `db` and `mcp`. Its fix is `x help <command>`. Both forms answer now: `--help` is read off the flag loop and `readSubcommand` is SKIPPED when it is set, so `x db --help`, `x mcp --help` and `x pr --help` print usage instead of exiting 1 with this same refusal — which is what they did on every command taking a subcommand until 2026-08 (`parse.test.ts` pins it across the shipped registry) |
|
|
16
16
|
| Closed flag values | a flag whose values are a closed set is READ through a function that refuses the rest — `cmd-build.ts`'s `readTarget`, `cmd-deploy.ts`'s `readMethod`, `cmd-routes.ts`'s `readSurfaceFilter`, `cmd-mcp.ts`'s `isTransport`. `=== 'helm' ? 'helm' : 'compose'` made `x deploy --method helmm` a COMPOSE deploy reporting `method: "compose"`, and `--surface App` reported `0 routes` and exit 0 — a typo and an empty table rendering identically. The set is the framework's own where one exists (`SURFACES` from `@ultimat3/render`), never a list restated here |
|
|
17
|
+
| Passthrough | `CommandSpec.passthrough`, **declared**. `ParsedArgs.passthrough` says "handed to the underlying tool verbatim" and had no reader anywhere, so `x test unit -- --coverage --bail` parsed both flags and dropped both — a run that measured nothing reporting what a coverage run reports. `x test` forwards them to `bun test` (before the file list) and prints them back in its reproduce line; every other command refuses a non-empty `--` tail with `X_CLI_BAD_FLAG` rather than dropping it. `help`/`version` short-circuit ahead of the command and are exempt |
|
|
17
18
|
| App root | `CommandSpec.requiresApp`, **enforced by `dispatch.ts`** before `target.run` — the field's doc said so for 17 commands and nothing read it, so the promise was kept only by each command remembering to call `requireAppRoot` itself. Those 17 calls stay (they hand the command its root, and name subcommands the dispatcher cannot see), but the DECLARATION is what decides, ahead of any check a command makes about its own arguments; `--help` is exempt, because `target` is the help command by then |
|
|
18
19
|
| Result helpers | `command.ts`'s `ok()` / `failed()` write `ok` **after** the `extra` spread: the function's name is the verdict and nothing a caller passes can overturn it. Spread last, `failed('verify', '1 of 20 steps failed', { ok: true })` answered `ok: true` and `exitCodeFor` exited **0** on it — a green CI over a red command (`command.test.ts`) |
|
|
19
20
|
| I/O | only `dispatch.ts` renders or exits; commands return `CommandResult` |
|
|
@@ -81,7 +82,7 @@ source only: a test file's import is not judged, because `packages/*` here decla
|
|
|
81
82
|
read is its own finding rather than a silent skip — a skipped workspace is a hiding place for the
|
|
82
83
|
very edge the rule is looking for.
|
|
83
84
|
|
|
84
|
-
`app-permissions.ts` is the `policy` step
|
|
85
|
+
`app-permissions.ts` is the `policy` step. Two references in the whole
|
|
85
86
|
framework are bare strings nothing checks — `RoleDef.grants` and `RouteGuard.permission` — while
|
|
86
87
|
`can()` calls `assertPermission` and throws `X_PERMISSION_UNKNOWN` on the first request that
|
|
87
88
|
reaches the route. So `x new` shipped an app that granted `dashboard:read`, required it on
|
|
@@ -108,9 +109,19 @@ halves are `undefined`/empty with no replica configured, and an EMBEDDED binding
|
|
|
108
109
|
PGlite has no standby. Not `@ultimat3/http`'s pipeline, which would make the HTTP tier know what a
|
|
109
110
|
database is; the boot is the only tier that may know about a request and a pool.
|
|
110
111
|
|
|
112
|
+
`flag-number.ts`'s `portPairAfter` is the one suggestion for a caller that binds a PAIR, and
|
|
113
|
+
`neighbouringPort` is for a caller that binds one port. `x dev` takes `PORT` **and** `PORT + 1`, so
|
|
114
|
+
the neighbour of a taken web port is the sync port — both `x doctor`'s `X_PORT_IN_USE` and
|
|
115
|
+
`dev-sync.ts`'s handed back the port the finding had just named as taken, each under a test that
|
|
116
|
+
said it ended the failure. It answers downward at the top of the range for the reason
|
|
117
|
+
`neighbouringPort` does, and one further: the suggestion's own neighbour has to exist, or
|
|
118
|
+
`syncPortFor` refuses it with `X_PORT_INVALID`.
|
|
119
|
+
|
|
111
120
|
`port-probe.ts` is the one `portFree`, because two commands ask it and must not disagree:
|
|
112
|
-
`x doctor` reports it as a finding for BOTH ports `x dev` binds — the web port and the
|
|
113
|
-
|
|
121
|
+
`x doctor` reports it as a finding for BOTH ports `x dev` binds — the web port and the sync port,
|
|
122
|
+
which it derives with `syncPortFor` (the boot's own rule: a `--port` at the top of the range has no
|
|
123
|
+
sync port at all, so the probe reports `X_PORT_INVALID` instead of probing the port BELOW it, which
|
|
124
|
+
is what `neighbouringPort` answered there) — each labelled with the role that wants it — and `startSync` asks it after a failed
|
|
114
125
|
`listenSyncNode` so a taken neighbour is `X_PORT_IN_USE` rather than `X_CLI_UNEXPECTED` over
|
|
115
126
|
`Bun.serve`'s own English rendered into a `cause:`. It is ASKED, never read off the caught value,
|
|
116
127
|
which is what `scripts/catch-render.ts` refuses; anything else the listener failed on is re-thrown
|
|
@@ -119,6 +130,36 @@ untouched. `x doctor` also probes `DATABASE_URL` with a real `select 1` through
|
|
|
119
130
|
credentials, which is the case an operator most needs told about — and reports `X_DB_UNAVAILABLE`
|
|
120
131
|
with that package's own two-branch fix. An EMBEDDED binding is not probed: that lock is `x dev`'s.
|
|
121
132
|
|
|
133
|
+
`doctor-offline.ts` is `x doctor`'s offline-fallback check, and it is the shape of a diagnostic
|
|
134
|
+
that could not be closed. The probe was the literal `apps/web/app/offline.tsx` — a filename
|
|
135
|
+
`assertRouteFilename` REFUSES (`X_ROUTE_FILE_INVALID`: the directory is the URL, so a page is
|
|
136
|
+
`page.tsx`) — while `x new` scaffolds `apps/web/site/offline/page.tsx` and the finding's own `fix:`
|
|
137
|
+
writes `apps/web/app/offline/page.tsx`. Three different paths, so every app the framework has ever
|
|
138
|
+
produced reported `X_PWA_NO_OFFLINE_FALLBACK` from its first `x doctor`, running the fix changed
|
|
139
|
+
nothing, and the app that HAD the route was told it did not. It now reads the DECLARED
|
|
140
|
+
`pwa.offline.fallback` through `loadPwaArtifacts` and matches it against `describeRoutes()` —
|
|
141
|
+
**`site/` only**, `As of 2026-09`. It accepted `app/` too, on the argument that both surfaces answer
|
|
142
|
+
the same URL; true, and not the question. `SURFACE_SPECS` allows `app/` exactly `stream | ssr`, only
|
|
143
|
+
a `static` route is prerendered, and `serviceWorkerArtifacts` precaches a rendered DOCUMENT
|
|
144
|
+
(`documents.get(fallback)`) — so an `app/` fallback has nothing to precache and the offline
|
|
145
|
+
navigation reaches the network it exists to survive without. The check and its own `fix:` disagreed
|
|
146
|
+
about one code. Not closed by this: a `site/` route declaring `render: 'ssr'` is not prerendered
|
|
147
|
+
either, and `NavigableRoute` carries no render mode. An app whose modules will not import is not
|
|
148
|
+
judged at all, which is
|
|
149
|
+
`appEntities`' rule (`schema-drift.ts`) one registry over. The fix is `x g route <name>
|
|
150
|
+
--surface site` only where the fallback is one path segment the generator can really produce: a
|
|
151
|
+
nested path slugifies to a DIFFERENT url, so there it is the config edit instead — a `fix:` that
|
|
152
|
+
runs and leaves the finding where it was is the defect this file exists to end. **`site`, not
|
|
153
|
+
`app`**: the document that answers a lost network has to render with no network, no session and no
|
|
154
|
+
database, which `app/` (`ssr | stream`) cannot promise, and it is the line `@ultimat3/pwa`'s own
|
|
155
|
+
`X_PWA_NO_OFFLINE_FALLBACK` hands out for the same code — two fixes for one code are two answers.
|
|
156
|
+
|
|
157
|
+
`X_ENV_MISSING`'s fix is the file write, for the same reason. It was `x new --force`, which cannot
|
|
158
|
+
run where the reader is standing: `x new` takes a `<name>` positional (`X_CLI_BAD_FLAG` without
|
|
159
|
+
one) and with one it scaffolds a SECOND app beside the broken one. `cp .env.example
|
|
160
|
+
.env.development` — the committed projection of `envSchema`, which is what `x env example` writes
|
|
161
|
+
and `X_ENV_EXAMPLE_DRIFT` keeps honest.
|
|
162
|
+
|
|
122
163
|
`i18n-index.ts` is the one writer of an app's `packages/i18n/src/index.ts`, shared by `x g` and
|
|
123
164
|
`x i18n add|sync`. A catalog on disk and a SELECTABLE locale were two different sets: `x i18n add
|
|
124
165
|
fr` wrote the file, exited 0, and left `x verify --only i18n` red with `X_CATALOG_UNREGISTERED`
|
|
@@ -406,7 +447,26 @@ cannot be missing from the precache manifest.
|
|
|
406
447
|
|---|---|
|
|
407
448
|
| `cmd-dev.ts` | mounts `/sw.js` and `/x-sw-register.js`; built ONCE at boot and deliberately not rebuilt on the watcher tick — a worker that changes under a page it already controls is the update path, and re-emitting one per keystroke exercises it on every save |
|
|
408
449
|
| `serve.ts` | the same two routes in the container, from the same call |
|
|
409
|
-
| `prerender.ts` | writes both as FILES into the export — a static host runs no route table, so a `<script src="/x-sw-register.js">` in every document is a 404 unless the bytes are in the artifact |
|
|
450
|
+
| `prerender.ts` | writes both as FILES into the export — a static host runs no route table, so a `<script src="/x-sw-register.js">` in every document is a 404 unless the bytes are in the artifact. In TWO halves, `As of 2026-09-06`: `serviceWorkerHead(pwa)` before the render loop, because every document has to name the script, and `serviceWorkerArtifacts` AFTER it, because a precache revision is the content hash of a document that does not exist yet |
|
|
451
|
+
|
|
452
|
+
**A precache revision is the DOCUMENT's content hash, and until 2026-09-06 it was the build id.**
|
|
453
|
+
`pwaRoutes` projected four of `PwaRoute`'s eight fields, so every route entry read
|
|
454
|
+
`{"url":"/","revision":"<buildId>","bytes":0}`: two deploys of a byte-identical site re-fetched
|
|
455
|
+
every precached page, which is the one thing `packages/pwa/src/precache.ts`' own header says must
|
|
456
|
+
never happen, and `DEFAULT_PRECACHE_WARN_BYTES` was a 5 MB budget over a total that could not count
|
|
457
|
+
one byte of HTML. The cause was ORDER — `prerender.ts` emitted the worker before the render loop —
|
|
458
|
+
so the emission moved after it and `ServiceWorkerInput.documents` carries `renderStatic`'s own
|
|
459
|
+
`hash` and byte count per rendered path. `x dev` and `serve.ts` pass none and keep the build id:
|
|
460
|
+
neither has rendered a page at boot, and inventing a hash for bytes that do not exist is a revision
|
|
461
|
+
that never changes when the page does. **The offline document is fed through its own pair**, because
|
|
462
|
+
`buildPrecacheManifest` adds it ITSELF as `reason: 'fallback'` ahead of every route and `add()`
|
|
463
|
+
keeps the first entry per url — so the route entry of the same url is shadowed and only
|
|
464
|
+
`offlineFallbackRevision` / `offlineFallbackBytes` (`ServiceWorkerConfig`, `@ultimat3/pwa`) can
|
|
465
|
+
decide it. That pair was declared and fed by nobody for one commit; `serviceWorkerArtifacts` reads
|
|
466
|
+
the fallback path out of the same `documents` map, so the one page an offline navigation depends on
|
|
467
|
+
is content-addressed like every other. A fallback this pass did not render — no route serves it,
|
|
468
|
+
which `x doctor` reports as `X_PWA_NO_OFFLINE_FALLBACK` — is absent from the map and keeps the
|
|
469
|
+
build id.
|
|
410
470
|
|
|
411
471
|
**Registration is an EXTERNAL script, never inline**, and that is a CSP fact rather than a
|
|
412
472
|
preference: `startWeb` computes a `script-src` sha256 per inline script, so an unhashed one is
|
|
@@ -996,7 +1056,11 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
|
|
|
996
1056
|
| `statement-loop.ts` | one verdict → the finding, the panel fact, the overlay notice and the log line |
|
|
997
1057
|
| `dev-policy.ts` | which actors to ask about, and which capability each policy gates |
|
|
998
1058
|
| `cmd-dev.ts` | boot order, mounting `/_x`, installing the span exporter, the file watcher |
|
|
999
|
-
| `dev-watch.ts` | which
|
|
1059
|
+
| `dev-watch.ts` | which paths under the app root `x dev` may not watch: the app's own `.gitignore` (`gitignore.ts`) plus a floor of five directory names an ignore file cannot be relied on to carry — `.git`, `.x`, `node_modules`, `.personal`, `.claude` — every one of them dotted or an install, matched as a path SEGMENT at any depth |
|
|
1060
|
+
| `dev-watch-tree.ts` | the watch SET: one `watch(dir, { recursive: false })` per ADMITTED directory, a new directory picked up on `rename`, a removed one giving its descriptor back, and the 30ms trailing debounce |
|
|
1061
|
+
| `dev-reload.ts` | one rebuild at a time — a tick arriving mid-build coalesces into ONE trailing rebuild, for the newest file |
|
|
1062
|
+
| `gitignore.ts` | what git ignores, as data: parse, the ancestor chain up to the repository, and last-match-wins |
|
|
1063
|
+
| `path-segments.ts` | `hasPathSegment` — a directory of that exact name, never a substring of one |
|
|
1000
1064
|
| `style-bundle.ts` / `style-routes.ts` | a surface's CSS as one content-hashed file under `/styles/`, served `immutable` — `island-bundle.ts` / `island-routes.ts`' shape one asset over. It was an inline `<style>` until 2026-09-06: 156,738 bytes, identical on every page, inside a `private, no-store` document. The URL is the hash alone, no surface in the name: a surface is not a property of the bytes, and an app whose only CSS is its global layer would otherwise write three identical files into its static export and three entries into a precache manifest that has a budget |
|
|
1001
1065
|
| `mcp-host.ts` | the `DevCapabilities` half of `@ultimat3/mcp`'s `DevHost` — db, tests, logs, verify |
|
|
1002
1066
|
| `mcp-db-target.ts` | which database the host is pointed at: whether it is a branch, and whether it is production |
|
|
@@ -1005,6 +1069,44 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
|
|
|
1005
1069
|
| `mcp-test-output.ts` | reading `bun test`'s own summary back into a `TestRun` |
|
|
1006
1070
|
| `cmd-mcp.ts` | `x mcp serve`: the two transports, and the local developer's caller |
|
|
1007
1071
|
|
|
1072
|
+
**The watch set is a REGISTRATION decision, not a filter, `As of 2026-09-06`.** `watch(root, {
|
|
1073
|
+
recursive: true })` takes one inotify descriptor per directory in the tree — measured on `x dev`
|
|
1074
|
+
against `examples/dummy`: **110 descriptors, 39 of them (35%) under `.x/` and `node_modules/`**, and
|
|
1075
|
+
on a monorepo root **1901, of which 1490 (78%) were `.git/` and `node_modules/`**, where one
|
|
1076
|
+
`git status` delivered 5 `.git/index` events into the JS callback. `isIgnoredPath` answered
|
|
1077
|
+
correctly every time and answered too late: the kernel queue entry, the callback and the slot out of
|
|
1078
|
+
`max_user_watches` (8192 on many distributions) were already spent. Bun 1.4.0's `fs.watch` has no
|
|
1079
|
+
ignore option, so `dev-watch-tree.ts` walks the root itself and registers one non-recursive watcher
|
|
1080
|
+
per admitted directory, pruning at descent.
|
|
1081
|
+
|
|
1082
|
+
**And the ignore set is the app's own `.gitignore`.** Seven hand-listed directory names were wrong
|
|
1083
|
+
in both directions. Nothing read the ignore file, so `touch tsconfig.tsbuildinfo` — the file every
|
|
1084
|
+
`bun run typecheck` rewrites, named by `x new`'s own scaffolded `.gitignore` — logged
|
|
1085
|
+
`reloaded tsconfig.tsbuildinfo in 113ms`, a full `appManifest()` plus `buildIslands()`; on the
|
|
1086
|
+
framework root 54 git-ignored directories were unfiltered. And `dist` and `coverage` were matched at
|
|
1087
|
+
ANY depth, so an app's own `/dist` or `/coverage` ROUTE never reloaded — silently, which the file's
|
|
1088
|
+
own header calls worse than a spurious rebuild. `gitignore.ts` reads it with git's own anchoring
|
|
1089
|
+
(unanchored at any depth, a leading `/` or an inner slash where it is written, `!` re-including,
|
|
1090
|
+
a trailing `/` directory-only) and walks ANCESTOR ignore files up to the directory holding `.git` —
|
|
1091
|
+
`examples/dummy` carries none of its own and every rule about it lives in the repository root's. The
|
|
1092
|
+
file is re-read, and the whole watch set re-walked, on any write naming `.gitignore`; nothing ever
|
|
1093
|
+
spawns `git check-ignore`. `x new`'s scaffold writes `/dist/` and `/coverage/` **root-anchored** for
|
|
1094
|
+
this reason, plus `packages/*/dist/`. It is `fix-path.ts`'s parser, lifted: two readers of one file
|
|
1095
|
+
are two answers to what an app committed.
|
|
1096
|
+
|
|
1097
|
+
**A watcher event carries no filename when the WATCHED directory itself moves.** Bun's recursive
|
|
1098
|
+
watcher delivers `undefined` — not `null` — on `mv myapp myapp2`, a re-clone or a volume remount,
|
|
1099
|
+
and `isIgnoredPath(undefined)` threw a `TypeError` inside an fs callback, outside any `try`, with no
|
|
1100
|
+
`uncaughtException` handler: `x dev` died with a stack trace. The listener is total over
|
|
1101
|
+
`string | Buffer | null | undefined` and logs `dev.watch.unnamed_event` once.
|
|
1102
|
+
|
|
1103
|
+
**The reload has an in-flight guard, and it is the state that needed it.** A 45ms drip — a slow
|
|
1104
|
+
`git checkout`, a formatter walking files, `x db gen` — measured **40 reloads for 40 files**, each
|
|
1105
|
+
launching `Promise.all([appManifest, buildIslands])` while the previous still ran and assigning
|
|
1106
|
+
`state.manifest` / `state.islands` in COMPLETION order, so a slower earlier tick could land on top
|
|
1107
|
+
of a newer one. `coalesceReloads` (`dev-reload.ts`) keeps the LAST tick that arrived during a
|
|
1108
|
+
rebuild and starts exactly one more.
|
|
1109
|
+
|
|
1008
1110
|
`api-routes.ts` is the app's own API surface, composed **once** and mounted by both `cmd-dev.ts`
|
|
1009
1111
|
and `serve.ts`: `listActions().map(toRoute)` from `@ultimat3/action` plus
|
|
1010
1112
|
`listQueries().map(toQueryRoute)` from `@ultimat3/query`. Two lists is how `query.client()`
|
|
@@ -1022,6 +1124,16 @@ The roles live in `@ultimat3/core` (`ROLES`, `isRole`), never in a second list h
|
|
|
1022
1124
|
driver, a dev-only authorizer or a dev-only queue is the bug this design exists to prevent — the
|
|
1023
1125
|
only thing dev changes is which driver is behind an interface.
|
|
1024
1126
|
|
|
1127
|
+
### `HOST` is the interface, read the way `PORT` is
|
|
1128
|
+
|
|
1129
|
+
`serve.ts`'s `hostnameFromEnv` — `HOST`, trimmed, empty is `0.0.0.0` — and `ServeOptions.hostname`
|
|
1130
|
+
overrides it as `port` overrides `PORT`; `containerBinding(env, hostname)` is the one `WebBinding`
|
|
1131
|
+
`serveApp` hands `startRoles`, so `web`, `sync` and the metrics endpoint bind the same interface.
|
|
1132
|
+
Before 2026-09-07 `CONTAINER_BINDING` was the only production binding and an app whose auth mode
|
|
1133
|
+
admits one implicit actor without a login — which must refuse a public interface — could not run in
|
|
1134
|
+
a container at all. A loopback bind in a container is unreachable through `-p`; the wiki row says
|
|
1135
|
+
where it IS reachable. Not `HOSTNAME`: Docker sets that to the container id.
|
|
1136
|
+
|
|
1025
1137
|
### `RuntimeOverrides` is the only way to hand the framework a driver
|
|
1026
1138
|
|
|
1027
1139
|
`ServeOptions` was `{ root, env, role?, port?, metricsPort? }`, so the ONLY way an app could
|
|
@@ -1154,9 +1266,23 @@ Delete `graphHash` the day `Bun.build` is deterministic.
|
|
|
1154
1266
|
|
|
1155
1267
|
**`x dev`, the container and the static export all mount the same table.** `serve.ts` builds the
|
|
1156
1268
|
islands at boot for the same reason it mounts `apiRoutes()`: a seam that works in dev and not in the
|
|
1157
|
-
image is the same failure one release later. `x dev` rebuilds them on the watcher tick
|
|
1158
|
-
the
|
|
1159
|
-
|
|
1269
|
+
image is the same failure one release later. `x dev` rebuilds them on the watcher tick — an island
|
|
1270
|
+
is the single module this process never imports, so there is no Bun module cache to invalidate —
|
|
1271
|
+
and the SAME tick re-imports the route module beside them when its source changed
|
|
1272
|
+
(`app-load.ts`'s `reloadRoute`: `<path>?x-reload=<hash>` is the one cache key Bun honours,
|
|
1273
|
+
`registerRoute` replaces the entry for the same file, and `dev-render.ts` reads the entry back from
|
|
1274
|
+
the table on every request rather than closing over the one it was built from). Until 2026-09-07
|
|
1275
|
+
the island was the only reload that took effect, so a save served a new island under an old page —
|
|
1276
|
+
the old props, the placeholder the new island renders when they are missing. A route module and
|
|
1277
|
+
nothing else: an action, a query or an entity is held by every module that imported it, and no
|
|
1278
|
+
re-import can rebind those. `@ultimat3/render`'s loader admits the query
|
|
1279
|
+
(`/\.tsx(?:\?[^/]*)?$/`) and strips it before reading the file — anchored on `.tsx$`, the
|
|
1280
|
+
re-import fell through to Bun's own JSX loader and every reloaded page died on `__xh`.
|
|
1281
|
+
|
|
1282
|
+
**The dev fixture is its own repository** (`.git/HEAD` in `DEV_FIXTURE_FILES`). The framework's root
|
|
1283
|
+
`.gitignore` lists `packages/cli/.dev-fixture/`, `devIgnore` honours every ancestor up to a `.git`,
|
|
1284
|
+
and so the watcher admitted the fixture root and nothing under it: every run booted the reload path
|
|
1285
|
+
and none exercised it. The marker is what lets `cmd-dev.test.ts` save a page and await the tick.
|
|
1160
1286
|
|
|
1161
1287
|
**`app-load.ts` skips `*.island.tsx` deliberately.** It registers no primitive, and importing it
|
|
1162
1288
|
would put the one module guaranteed to be outside the server's graph inside this process's, where a
|
package/README.md
CHANGED
|
@@ -123,7 +123,7 @@ is held to the same error contract shipped source is (`X_GUARD_INVALID`, `X_GUAR
|
|
|
123
123
|
| `app-openapi.ts` | `openapi.json`, projected by `@ultimat3/action` |
|
|
124
124
|
| `app-boundaries.ts` | app import boundaries, over `@ultimat3/render`'s surface check |
|
|
125
125
|
| `app-agents-md.ts` | `AGENTS.md` exists and stays short, over `@ultimat3/manifest`'s check |
|
|
126
|
-
| `serve.ts` | **what a container starts** — `runRole(options)`, the same boot `x dev` runs minus the watcher, `/_x` and `dev: true`. `x new`'s `apps/web/server.ts` is three lines that call it |
|
|
126
|
+
| `serve.ts` | **what a container starts** — `runRole(options)`, the same boot `x dev` runs minus the watcher, `/_x` and `dev: true`. `x new`'s `apps/web/server.ts` is three lines that call it. `ROLE`, `PORT` and `HOST` are read from `env`; `role`, `port` and `hostname` on `ServeOptions` override each |
|
|
127
127
|
| `prerender.ts` | `x build --target static`: which `site/` routes qualify, and where the bytes land |
|
|
128
128
|
| `metrics-endpoint.ts` | the `METRICS_PATH` scrape listener every role opens, on `METRICS_PORT` |
|
|
129
129
|
| `otlp-export.ts` | the exporters `OTEL_EXPORTER_OTLP_ENDPOINT` switches on, and their drain hooks |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "19.2
|
|
3
|
+
"version": "19.3.2",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,34 +37,34 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "19.2
|
|
41
|
-
"@ultimat3/admin": "19.2
|
|
42
|
-
"@ultimat3/ai": "19.2
|
|
43
|
-
"@ultimat3/auth": "19.2
|
|
44
|
-
"@ultimat3/cache": "19.2
|
|
45
|
-
"@ultimat3/core": "19.2
|
|
46
|
-
"@ultimat3/db": "19.2
|
|
47
|
-
"@ultimat3/entity": "19.2
|
|
48
|
-
"@ultimat3/flags": "19.2
|
|
49
|
-
"@ultimat3/http": "19.2
|
|
50
|
-
"@ultimat3/i18n": "19.2
|
|
51
|
-
"@ultimat3/jobs": "19.2
|
|
52
|
-
"@ultimat3/mail": "19.2
|
|
53
|
-
"@ultimat3/manifest": "19.2
|
|
54
|
-
"@ultimat3/mcp": "19.2
|
|
55
|
-
"@ultimat3/money": "19.2
|
|
56
|
-
"@ultimat3/notify": "19.2
|
|
57
|
-
"@ultimat3/policy": "19.2
|
|
58
|
-
"@ultimat3/pwa": "19.2
|
|
59
|
-
"@ultimat3/query": "19.2
|
|
60
|
-
"@ultimat3/realtime": "19.2
|
|
61
|
-
"@ultimat3/render": "19.2
|
|
62
|
-
"@ultimat3/schema": "19.2
|
|
63
|
-
"@ultimat3/scraping": "19.2
|
|
64
|
-
"@ultimat3/seo": "19.2
|
|
65
|
-
"@ultimat3/storage": "19.2
|
|
66
|
-
"@ultimat3/testing": "19.2
|
|
67
|
-
"@ultimat3/time": "19.2
|
|
40
|
+
"@ultimat3/action": "19.3.2",
|
|
41
|
+
"@ultimat3/admin": "19.3.2",
|
|
42
|
+
"@ultimat3/ai": "19.3.2",
|
|
43
|
+
"@ultimat3/auth": "19.3.2",
|
|
44
|
+
"@ultimat3/cache": "19.3.2",
|
|
45
|
+
"@ultimat3/core": "19.3.2",
|
|
46
|
+
"@ultimat3/db": "19.3.2",
|
|
47
|
+
"@ultimat3/entity": "19.3.2",
|
|
48
|
+
"@ultimat3/flags": "19.3.2",
|
|
49
|
+
"@ultimat3/http": "19.3.2",
|
|
50
|
+
"@ultimat3/i18n": "19.3.2",
|
|
51
|
+
"@ultimat3/jobs": "19.3.2",
|
|
52
|
+
"@ultimat3/mail": "19.3.2",
|
|
53
|
+
"@ultimat3/manifest": "19.3.2",
|
|
54
|
+
"@ultimat3/mcp": "19.3.2",
|
|
55
|
+
"@ultimat3/money": "19.3.2",
|
|
56
|
+
"@ultimat3/notify": "19.3.2",
|
|
57
|
+
"@ultimat3/policy": "19.3.2",
|
|
58
|
+
"@ultimat3/pwa": "19.3.2",
|
|
59
|
+
"@ultimat3/query": "19.3.2",
|
|
60
|
+
"@ultimat3/realtime": "19.3.2",
|
|
61
|
+
"@ultimat3/render": "19.3.2",
|
|
62
|
+
"@ultimat3/schema": "19.3.2",
|
|
63
|
+
"@ultimat3/scraping": "19.3.2",
|
|
64
|
+
"@ultimat3/seo": "19.3.2",
|
|
65
|
+
"@ultimat3/storage": "19.3.2",
|
|
66
|
+
"@ultimat3/testing": "19.3.2",
|
|
67
|
+
"@ultimat3/time": "19.3.2",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/app-agents-md.ts
CHANGED
|
@@ -15,11 +15,22 @@ export interface AgentsMdOutcome {
|
|
|
15
15
|
readonly warnings: readonly string[];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* `assertAgentsMd` throws `X_AGENTS_MD_*`; a gate step reports, so the error becomes a finding.
|
|
20
|
+
*
|
|
21
|
+
* `maxBytes` is this repository's own budget, out of `x.verify.json`, and `undefined` means the
|
|
22
|
+
* 12kB default. `@ultimat3/manifest` has taken the option since it was written and this function
|
|
23
|
+
* never passed one, so the default was the only budget any app could have: a repository whose
|
|
24
|
+
* conventions genuinely need more room had to delete a rule to make space, which is the opposite
|
|
25
|
+
* of what a context-file budget is for.
|
|
26
|
+
*/
|
|
27
|
+
export async function checkAgentsMd(root: string, maxBytes?: number): Promise<AgentsMdOutcome> {
|
|
20
28
|
const path = join(root, AGENTS_MD_FILENAME);
|
|
21
29
|
try {
|
|
22
|
-
const { warnings } = await assertAgentsMd({
|
|
30
|
+
const { warnings } = await assertAgentsMd({
|
|
31
|
+
path,
|
|
32
|
+
...(maxBytes === undefined ? {} : { maxBytes }),
|
|
33
|
+
});
|
|
23
34
|
return { findings: [], warnings };
|
|
24
35
|
} catch (error) {
|
|
25
36
|
return { findings: [{ ...findingFrom(error), at: AGENTS_MD_FILENAME }], warnings: [] };
|
package/src/app-boundaries.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
|
18
18
|
import type { BoundaryRule, ImportGraph } from '@ultimat3/render';
|
|
19
19
|
import { checkSurfaceBoundary, importGraph, SURFACES } from '@ultimat3/render';
|
|
20
20
|
import type { Finding } from './output';
|
|
21
|
+
import { hasPathSegment } from './path-segments';
|
|
21
22
|
import { quoteArg } from './shell-quote';
|
|
22
23
|
|
|
23
24
|
export const BOUNDARY_CODES = [
|
|
@@ -56,8 +57,16 @@ const isDbSpecifier = (specifier: string): boolean =>
|
|
|
56
57
|
specifier.endsWith('/db') ||
|
|
57
58
|
/^@[^/]+\/db$/.test(specifier) ||
|
|
58
59
|
specifier === 'drizzle-orm';
|
|
60
|
+
/**
|
|
61
|
+
* `node:http` and `node:https` are HTTP too, and the anchor could not reach them: `http` had to
|
|
62
|
+
* follow a `/` or start the string, and a `node:` specifier has a colon there. So the one spelling
|
|
63
|
+
* that needs no dependency at all — a service reaching straight for the runtime's own client — was
|
|
64
|
+
* the one spelling this rule could not see. Matched on the boundary character rather than by
|
|
65
|
+
* listing every prefix, and still anchored at both ends: `node:http2-fake` and
|
|
66
|
+
* `@ultimat3/https-client` are names, not HTTP.
|
|
67
|
+
*/
|
|
59
68
|
const isHttpSpecifier = (specifier: string): boolean =>
|
|
60
|
-
specifier === '@ultimat3/http' || /(
|
|
69
|
+
specifier === '@ultimat3/http' || /(^|[/:])https?($|\/)/.test(specifier);
|
|
61
70
|
|
|
62
71
|
/** The transpiler rejects a shebang, and an app's `bin/` entry points legitimately have one. */
|
|
63
72
|
export const stripShebang = (source: string): string =>
|
|
@@ -236,7 +245,7 @@ export async function readAppSources(root: string): Promise<readonly SourceFile[
|
|
|
236
245
|
for (const pattern of APP_GLOBS) {
|
|
237
246
|
const glob = new Bun.Glob(pattern);
|
|
238
247
|
for await (const path of glob.scan({ cwd: root, absolute: false })) {
|
|
239
|
-
if (path
|
|
248
|
+
if (hasPathSegment(path, 'node_modules') || path.includes('.test.')) continue;
|
|
240
249
|
const posix = path.split('\\').join('/');
|
|
241
250
|
files.push({ path: posix, source: await Bun.file(joinPath(root, posix)).text() });
|
|
242
251
|
}
|
package/src/app-load.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { registerActions } from '@ultimat3/action';
|
|
|
10
10
|
import { localeConfig } from '@ultimat3/i18n';
|
|
11
11
|
import type { ErrorCodeFact } from '@ultimat3/manifest';
|
|
12
12
|
import { registerQueries } from '@ultimat3/query';
|
|
13
|
+
import type { RouteConfig } from '@ultimat3/render';
|
|
13
14
|
import { isRouteConfig, pageComponentOf, registerRoute } from '@ultimat3/render';
|
|
14
15
|
// For the SIDE EFFECT, and it is this module's to hold: importing `@ultimat3/render/server`
|
|
15
16
|
// installs the `.tsx`/`.scss` Bun plugin, a plugin only transforms modules loaded AFTER it, and
|
|
@@ -21,6 +22,7 @@ import '@ultimat3/render/server';
|
|
|
21
22
|
import { collectDeclaredCodes } from './error-contract';
|
|
22
23
|
import type { Finding } from './output';
|
|
23
24
|
import { findingFrom } from './output';
|
|
25
|
+
import { hasPathSegment } from './path-segments';
|
|
24
26
|
|
|
25
27
|
/** Every place an app keeps code the framework has to see. */
|
|
26
28
|
const APP_GLOBS = [
|
|
@@ -73,20 +75,32 @@ export interface LoadedApp {
|
|
|
73
75
|
readonly findings: readonly Finding[];
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
// A module is imported and registered
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
78
|
+
// A module is imported and registered once per PROCESS: `import()` caches, and a registry rejects
|
|
79
|
+
// a second registration of a name. So a rescan refreshes the facts DERIVED from the registries —
|
|
80
|
+
// the manifest and its build id — and, for exactly one kind of module, the primitive itself. A
|
|
81
|
+
// ROUTE module whose source changed is imported again under `?x-reload=<hash>`, the one cache key
|
|
82
|
+
// Bun honours, and `registerRoute` replaces the entry for the same file; its own imports resolve
|
|
83
|
+
// to the modules already cached, which is what makes it safe. An action, a query or an entity
|
|
84
|
+
// stays registered once: its exports are held by every module that imported it, a second instance
|
|
85
|
+
// would be a duplicate name in its registry, and no re-import can rebind the importers — those
|
|
86
|
+
// edits need a restart. Until 2026-09-07 the route module took the same rule, so a save re-bundled
|
|
87
|
+
// the island (`buildIslands` reads the disk) and kept the FIRST page component — a new island
|
|
88
|
+
// rendering under an old page's props, which is the mixed generation `x dev` served.
|
|
82
89
|
const registered = new Set<string>();
|
|
83
90
|
// A registration failure is sticky: the file is never retried, so the finding is replayed.
|
|
84
91
|
const failures = new Map<string, Finding>();
|
|
92
|
+
// Route modules only: the hash of the source each one registered from, which is what a rescan
|
|
93
|
+
// compares the disk against. A save that leaves the bytes alone re-imports nothing.
|
|
94
|
+
const routeSources = new Map<string, bigint>();
|
|
95
|
+
|
|
96
|
+
/** The query a re-imported route module carries. `module-loader.ts`'s filter admits it. */
|
|
97
|
+
const RELOAD_QUERY = 'x-reload';
|
|
85
98
|
|
|
86
99
|
/** Test seam, and what `x dev` would call if it ever restarted the registries in-process. */
|
|
87
100
|
export function resetAppLoad(): void {
|
|
88
101
|
registered.clear();
|
|
89
102
|
failures.clear();
|
|
103
|
+
routeSources.clear();
|
|
90
104
|
}
|
|
91
105
|
|
|
92
106
|
export async function loadApp(root: string): Promise<LoadedApp> {
|
|
@@ -95,11 +109,21 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
95
109
|
|
|
96
110
|
for (const pattern of APP_GLOBS) {
|
|
97
111
|
for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
|
|
98
|
-
|
|
112
|
+
// A SEGMENT, never a substring: an app checked out under
|
|
113
|
+
// `~/dev/node_modules-experiments/myapp` answered `includes('node_modules')` for every
|
|
114
|
+
// file it holds, so this loop imported none of them and the app registered nothing.
|
|
115
|
+
if (hasPathSegment(absolute, 'node_modules') || absolute.includes('.test.')) continue;
|
|
99
116
|
const file = relative(root, absolute).split(sep).join('/');
|
|
100
117
|
if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file) || STATES_FILE.test(file)) {
|
|
101
118
|
continue;
|
|
102
119
|
}
|
|
120
|
+
// The source is read BEFORE the import, and only on the file's first pass — a rescan of a
|
|
121
|
+
// registered module reads nothing here. A route entry is bound to the bytes the module was
|
|
122
|
+
// evaluated from, and a read AFTER the import cannot know which bytes those were: a save
|
|
123
|
+
// landing between the two bound V1's component to V2's hash, so the next scan saw nothing to
|
|
124
|
+
// do and served V1 until the save after. Read first, the worst case is one re-import the
|
|
125
|
+
// next tick, of a file that did change.
|
|
126
|
+
const snapshot = registered.has(absolute) ? undefined : await Bun.file(absolute).text();
|
|
103
127
|
let module: Record<string, unknown>;
|
|
104
128
|
try {
|
|
105
129
|
module = (await import(absolute)) as Record<string, unknown>;
|
|
@@ -108,7 +132,7 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
108
132
|
continue;
|
|
109
133
|
}
|
|
110
134
|
files.push(file);
|
|
111
|
-
const finding = await register(absolute, file, module);
|
|
135
|
+
const finding = await register(absolute, file, module, snapshot);
|
|
112
136
|
if (finding !== undefined) findings.push(finding);
|
|
113
137
|
}
|
|
114
138
|
}
|
|
@@ -124,35 +148,29 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
124
148
|
};
|
|
125
149
|
}
|
|
126
150
|
|
|
127
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* Registers a module once; every later call replays whatever the first one reported — except for
|
|
153
|
+
* a route module, which a later call re-registers from disk when its source has changed.
|
|
154
|
+
* `snapshot` is the source read before the module's first import, and absent on every later call.
|
|
155
|
+
*/
|
|
128
156
|
async function register(
|
|
129
157
|
absolute: string,
|
|
130
158
|
file: string,
|
|
131
159
|
module: Record<string, unknown>,
|
|
160
|
+
snapshot: string | undefined,
|
|
132
161
|
): Promise<Finding | undefined> {
|
|
133
162
|
const previous = failures.get(absolute);
|
|
134
163
|
if (previous !== undefined) return previous;
|
|
135
|
-
if (
|
|
164
|
+
if (snapshot === undefined) return reloadRoute(absolute, file);
|
|
136
165
|
registered.add(absolute);
|
|
137
166
|
try {
|
|
138
167
|
const config = module['config'];
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
// source, and `render: 'stream'` is rejected without one — so count them in the text.
|
|
142
|
-
const source = await Bun.file(absolute).text();
|
|
143
|
-
// The page component comes from the same module as its config, resolved by render's own
|
|
144
|
-
// rule — the CLI does not decide which export is a page any more than it decides what a
|
|
145
|
-
// route is. A module with no component registers without one, and renders a bare shell.
|
|
146
|
-
const component = pageComponentOf(module);
|
|
147
|
-
registerRoute({
|
|
148
|
-
file,
|
|
149
|
-
config,
|
|
150
|
-
suspenseBoundaries: countSuspense(source),
|
|
151
|
-
...(component === undefined ? {} : { component }),
|
|
152
|
-
});
|
|
153
|
-
}
|
|
168
|
+
const route = isRouteConfig(config) ? config : undefined;
|
|
169
|
+
if (route !== undefined) registerRouteModule(file, module, route, snapshot);
|
|
154
170
|
registerActions(module);
|
|
155
171
|
registerQueries(module);
|
|
172
|
+
// Only a route module is ever re-imported, so only a route module's hash is worth keeping.
|
|
173
|
+
if (route !== undefined) routeSources.set(absolute, Bun.hash.wyhash(snapshot));
|
|
156
174
|
return undefined;
|
|
157
175
|
} catch (error) {
|
|
158
176
|
const finding: Finding = { ...findingFrom(error), at: file };
|
|
@@ -161,6 +179,59 @@ async function register(
|
|
|
161
179
|
}
|
|
162
180
|
}
|
|
163
181
|
|
|
182
|
+
/**
|
|
183
|
+
* The route half of a registration, and the whole of a re-registration. `source` is the text the
|
|
184
|
+
* module was imported from — read by the caller BEFORE the import, never here after it — and the
|
|
185
|
+
* hash the entry is bound to is that text's. Until 2026-09-07 this read the file again, and a save
|
|
186
|
+
* between the import and that read registered the old component under the new hash: the next
|
|
187
|
+
* scan compared equal, and the page on disk was not served until the save after it.
|
|
188
|
+
*/
|
|
189
|
+
function registerRouteModule(
|
|
190
|
+
file: string,
|
|
191
|
+
module: Record<string, unknown>,
|
|
192
|
+
config: RouteConfig,
|
|
193
|
+
source: string,
|
|
194
|
+
): void {
|
|
195
|
+
// The page component comes from the same module as its config, resolved by render's own
|
|
196
|
+
// rule — the CLI does not decide which export is a page any more than it decides what a
|
|
197
|
+
// route is. A module with no component registers without one, and renders a bare shell.
|
|
198
|
+
const component = pageComponentOf(module);
|
|
199
|
+
registerRoute({
|
|
200
|
+
file,
|
|
201
|
+
config,
|
|
202
|
+
// The build counts boundaries from the compiled JSX; before a build there is only the
|
|
203
|
+
// source, and `render: 'stream'` is rejected without one — so count them in the text.
|
|
204
|
+
suspenseBoundaries: countSuspense(source),
|
|
205
|
+
...(component === undefined ? {} : { component }),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A registered route module, on a rescan: imported again if the file no longer hashes to what it
|
|
211
|
+
* registered from, and left alone otherwise. A save that will not import is a finding at the file
|
|
212
|
+
* and NOT a sticky one — the next save is tried again — and the entry it would have replaced stays
|
|
213
|
+
* registered, so the last page that did import is the one served meanwhile.
|
|
214
|
+
*/
|
|
215
|
+
async function reloadRoute(absolute: string, file: string): Promise<Finding | undefined> {
|
|
216
|
+
const registeredFrom = routeSources.get(absolute);
|
|
217
|
+
if (registeredFrom === undefined) return undefined;
|
|
218
|
+
const source = await Bun.file(absolute).text();
|
|
219
|
+
const hash = Bun.hash.wyhash(source);
|
|
220
|
+
if (hash === registeredFrom) return undefined;
|
|
221
|
+
try {
|
|
222
|
+
const module = (await import(`${absolute}?${RELOAD_QUERY}=${hash}`)) as Record<string, unknown>;
|
|
223
|
+
const config = module['config'];
|
|
224
|
+
// A file that stopped being a route is not un-registered — the table has no verb for it, and a
|
|
225
|
+
// deleted file needs a restart either way. Its hash is recorded so the same save is not
|
|
226
|
+
// re-imported on every later tick.
|
|
227
|
+
if (isRouteConfig(config)) registerRouteModule(file, module, config, source);
|
|
228
|
+
routeSources.set(absolute, hash);
|
|
229
|
+
return undefined;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return { ...findingFrom(error), at: file };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
164
235
|
const countSuspense = (source: string): number => source.match(/<Suspense[\s/>]/g)?.length ?? 0;
|
|
165
236
|
|
|
166
237
|
/** `packages/db/src/errors.ts` → `packages/db`; `apps/web/app/posts/errors.ts` → `apps/web`. */
|
package/src/budgets.ts
CHANGED
|
@@ -227,9 +227,24 @@ export interface MeasuredJs {
|
|
|
227
227
|
export async function measureDocumentJs(html: string, out: string): Promise<MeasuredJs> {
|
|
228
228
|
let jsBytes = 0;
|
|
229
229
|
const entries: MeasuredEntry[] = [];
|
|
230
|
+
// Deduped ONCE, across both readers below, and the unit is the FETCH: a browser downloads a URL
|
|
231
|
+
// once however many times the document names it, so `budget.js` — a byte budget — counts it
|
|
232
|
+
// once. Two instances of one island are two wrappers and one chunk; so are a `<script src>`
|
|
233
|
+
// repeated by a page and its layout, and a src that is also an island entry. Only the island
|
|
234
|
+
// half was deduped, so a document naming one script twice was charged twice and could fail a
|
|
235
|
+
// budget it clears.
|
|
236
|
+
//
|
|
237
|
+
// EXECUTION is a different count and this is deliberately not it. A repeated classic
|
|
238
|
+
// `<script src>` runs once per element (a module runs once per document, off the module map), so
|
|
239
|
+
// the layout-plus-page case above really does execute twice — for zero extra bytes. That is a
|
|
240
|
+
// CPU cost, and this gate is a bound on what the browser downloads and parses. An INLINE script
|
|
241
|
+
// is not in this set for the same reason: two identical inline bodies are two copies of the
|
|
242
|
+
// bytes in the document, so both are charged.
|
|
243
|
+
const fetched = new Set<string>();
|
|
230
244
|
const weigh = async (url: string): Promise<void> => {
|
|
231
245
|
// Only a path inside the artifact can be weighed; a cross-origin script is not this build's.
|
|
232
|
-
if (!url.startsWith('/')) return;
|
|
246
|
+
if (!url.startsWith('/') || fetched.has(url)) return;
|
|
247
|
+
fetched.add(url);
|
|
233
248
|
const file = Bun.file(join(out, url.slice(1)));
|
|
234
249
|
const bytes = (await file.exists()) ? file.size : 0;
|
|
235
250
|
entries.push({ url, bytes });
|
|
@@ -246,13 +261,9 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
|
|
|
246
261
|
}
|
|
247
262
|
await weigh(src);
|
|
248
263
|
}
|
|
249
|
-
// Deduped: two instances of one island are two wrappers and one chunk, and a browser that
|
|
250
|
-
// imports the same module twice fetches and executes it once.
|
|
251
|
-
const booted = new Set<string>();
|
|
252
264
|
for (const match of html.matchAll(ENTRY_ATTR)) {
|
|
253
265
|
const url = match.groups?.['url'];
|
|
254
|
-
if (url === undefined
|
|
255
|
-
booted.add(url);
|
|
266
|
+
if (url === undefined) continue;
|
|
256
267
|
await weigh(url);
|
|
257
268
|
}
|
|
258
269
|
return { jsBytes, entries };
|