@ultimat3/cli 19.1.2 → 19.2.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/CLAUDE.md +18 -3
- package/package.json +29 -29
- package/src/app-openapi.ts +13 -5
- package/src/app-permissions.ts +0 -0
- package/src/browser-launcher.ts +53 -4
- package/src/budgets.ts +43 -1
- package/src/cmd-dev.ts +27 -7
- package/src/cmd-shot.ts +3 -1
- package/src/dev-render.ts +28 -7
- package/src/dev-roles.ts +9 -8
- package/src/dev-sync.ts +7 -1
- package/src/dev-watch.ts +53 -0
- package/src/duplicate-packages.ts +278 -0
- package/src/error-codes.ts +6 -0
- package/src/i18n-registration.ts +34 -5
- package/src/index.ts +2 -0
- package/src/island-bundle.ts +121 -9
- package/src/island-harness.ts +11 -4
- package/src/mcp-errors.ts +2 -0
- package/src/mcp-host.ts +3 -0
- package/src/prerender.ts +29 -3
- package/src/serve.ts +12 -1
- package/src/shot-browser.ts +23 -4
- package/src/static-report.ts +21 -1
- package/src/style-bundle.ts +124 -0
- package/src/style-csp.ts +14 -12
- package/src/style-routes.ts +56 -0
- package/src/sw-artifacts.ts +13 -5
- package/src/templates/resource-form-island.ts +13 -3
- package/src/templates/scaffold-repo.ts +6 -0
- package/src/verify-checks.ts +7 -1
- package/src/verify-tests.ts +20 -7
- package/src/web-binding.ts +22 -0
package/CLAUDE.md
CHANGED
|
@@ -156,6 +156,7 @@ them is answered by this table rather than by a second convention:
|
|
|
156
156
|
| `x tasks` | `cmd-tasks.ts`, `tasks-facts.ts` | `registeredTasks()` + `@ultimat3/time`'s cron resolution |
|
|
157
157
|
| `x policy` | `cmd-policy.ts`, `policy-facts.ts` | `@ultimat3/policy`'s `policyMatrix()` over the app's own `Policy` objects |
|
|
158
158
|
| `x i18n` | `cmd-i18n.ts`, `i18n-audit.ts`, `i18n-registration.ts` | `@ultimat3/i18n`'s `extractFromFiles` + `auditCatalogs`, then the live catalog registry |
|
|
159
|
+
| `x i18n check` and the `policy` step, before either registry | `duplicate-packages.ts` | `Bun.resolveSync` of `@ultimat3/i18n` / `@ultimat3/policy` / `@ultimat3/entity` from the app root, every workspace and the CLI's own directory, keyed by REALPATH. Two real directories is `X_PACKAGE_DUPLICATED`: a registry is per module instance, so the app registers into one copy and the CLI reads the other — `x i18n check` said "move `defineCatalogs()`" about a call that was already where it belongs (ai-maxxing, 2026-09-05), and the policy step was green over an undeclared grant. A workspace symlink to one checkout is one copy; two store entries at ONE version are two |
|
|
159
160
|
|
|
160
161
|
Each pairs a `cmd-*.ts` of CLI wiring with a facts module that takes plain inputs and returns plain
|
|
161
162
|
data, so the projection is testable without a `ParsedArgs` — the `cmd-jobs.ts` / `jobs-report.ts`
|
|
@@ -981,7 +982,7 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
|
|
|
981
982
|
| `sync-authenticator.ts` | the app's HTTP authenticator, seen as the sync node's |
|
|
982
983
|
| `otlp-export.ts` | the exporters `OTEL_EXPORTER_OTLP_ENDPOINT` switches on, and their drain hooks |
|
|
983
984
|
| `dev-render.ts` | one HTTP route per registered `route`, through render's own mode function |
|
|
984
|
-
| `style-csp.ts` | the `style-src` sha256 of every inline `<style>` the web role serves |
|
|
985
|
+
| `style-csp.ts` | the `style-src` sha256 of every inline `<style>` the web role **still** serves — the app's own surface CSS is a file under `/styles/` (`style-bundle.ts`) admitted by `'self'`, so a production boot extends the directive with nothing |
|
|
985
986
|
| `script-csp.ts` | the `script-src` sha256 of every inline `<script>` it serves — the hydration runtime, from `@ultimat3/render`'s own `HYDRATE_RUNTIME_BODIES` |
|
|
986
987
|
| `dev-assets.ts` | the image pipeline's only HTTP surface: `/icons/*` and `/media/*` |
|
|
987
988
|
| `favicon.ts` | `/favicon.ico`: the app's own file, and the bytes the framework answers with when there is none |
|
|
@@ -995,6 +996,8 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
|
|
|
995
996
|
| `statement-loop.ts` | one verdict → the finding, the panel fact, the overlay notice and the log line |
|
|
996
997
|
| `dev-policy.ts` | which actors to ask about, and which capability each policy gates |
|
|
997
998
|
| `cmd-dev.ts` | boot order, mounting `/_x`, installing the span exporter, the file watcher |
|
|
999
|
+
| `dev-watch.ts` | which writes under the app root are a source change. Seven directories are never one — `.x`, `node_modules`, `.git`, `.personal`, `.claude`, `dist`, `coverage` — and each is matched as a path SEGMENT: the rule was `filename.includes('.x/') \|\| filename.includes('node_modules')`, so `git status`, an agent's scratch file and a coverage run each ran a full `appManifest()` + `buildIslands()` (measured in ai-maxxing, which keeps two whole copies of the app under `.claude/worktrees/`), while a directory named `my-node_modules-notes/` got no reload at all |
|
|
1000
|
+
| `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 |
|
|
998
1001
|
| `mcp-host.ts` | the `DevCapabilities` half of `@ultimat3/mcp`'s `DevHost` — db, tests, logs, verify |
|
|
999
1002
|
| `mcp-db-target.ts` | which database the host is pointed at: whether it is a branch, and whether it is production |
|
|
1000
1003
|
| `mcp-errors.ts` | `errors.explain`: one runnable command per code, typed over `CliErrorCode` |
|
|
@@ -1134,8 +1137,20 @@ island's `src` is a string, so no import edge reaches it and the page's graph st
|
|
|
1134
1137
|
against bytes. Two islands that both import the same helper each carry a copy; that is the honest
|
|
1135
1138
|
number for what booting either one costs.
|
|
1136
1139
|
|
|
1137
|
-
**The chunk URL is
|
|
1138
|
-
|
|
1140
|
+
**The chunk URL is SOURCE-addressed, not byte-addressed, `As of 2026-09-06`.** `graphHash` hashes
|
|
1141
|
+
the build's inputs — the source map's `sourcesContent`, sorted, plus the entry's app-relative path,
|
|
1142
|
+
the framework version and `Bun.version` — with render's own `contentHash`, the function that already
|
|
1143
|
+
stamps an ETag and a precache revision. Hashing the OUTPUT is what it did, and `Bun.build` is not
|
|
1144
|
+
byte-deterministic under `minify`: measured on 1.4.0, one entry point, no source file touched, a
|
|
1145
|
+
131,589-byte island alternated between two outputs of identical length differing only in minified
|
|
1146
|
+
identifier names, roughly one build in ten. The URL then flapped — ten names in ten minutes in
|
|
1147
|
+
ai-maxxing — so the service worker precached a chunk that 404ed and the `immutable` cache never hit.
|
|
1148
|
+
The cost of the alternative was measured and refused: `minify: { identifiers: false }` IS
|
|
1149
|
+
deterministic and is +47% raw, +20% gzipped, on every island of every app. `sourcemap: 'external'`
|
|
1150
|
+
pays for the input list (277ms against 276ms) and its `//# debugId=` line is stripped, so the
|
|
1151
|
+
shipped bytes are unchanged. Two processes can therefore serve two byte strings at one URL — the
|
|
1152
|
+
same program under different local names — and a per-file cache keeps ONE process serving one.
|
|
1153
|
+
Delete `graphHash` the day `Bun.build` is deterministic.
|
|
1139
1154
|
|
|
1140
1155
|
**`x dev`, the container and the static export all mount the same table.** `serve.ts` builds the
|
|
1141
1156
|
islands at boot for the same reason it mounts `apiRoutes()`: a seam that works in dev and not in the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "19.
|
|
3
|
+
"version": "19.2.0",
|
|
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.
|
|
41
|
-
"@ultimat3/admin": "19.
|
|
42
|
-
"@ultimat3/ai": "19.
|
|
43
|
-
"@ultimat3/auth": "19.
|
|
44
|
-
"@ultimat3/cache": "19.
|
|
45
|
-
"@ultimat3/core": "19.
|
|
46
|
-
"@ultimat3/db": "19.
|
|
47
|
-
"@ultimat3/entity": "19.
|
|
48
|
-
"@ultimat3/flags": "19.
|
|
49
|
-
"@ultimat3/http": "19.
|
|
50
|
-
"@ultimat3/i18n": "19.
|
|
51
|
-
"@ultimat3/jobs": "19.
|
|
52
|
-
"@ultimat3/mail": "19.
|
|
53
|
-
"@ultimat3/manifest": "19.
|
|
54
|
-
"@ultimat3/mcp": "19.
|
|
55
|
-
"@ultimat3/money": "19.
|
|
56
|
-
"@ultimat3/notify": "19.
|
|
57
|
-
"@ultimat3/policy": "19.
|
|
58
|
-
"@ultimat3/pwa": "19.
|
|
59
|
-
"@ultimat3/query": "19.
|
|
60
|
-
"@ultimat3/realtime": "19.
|
|
61
|
-
"@ultimat3/render": "19.
|
|
62
|
-
"@ultimat3/schema": "19.
|
|
63
|
-
"@ultimat3/scraping": "19.
|
|
64
|
-
"@ultimat3/seo": "19.
|
|
65
|
-
"@ultimat3/storage": "19.
|
|
66
|
-
"@ultimat3/testing": "19.
|
|
67
|
-
"@ultimat3/time": "19.
|
|
40
|
+
"@ultimat3/action": "19.2.0",
|
|
41
|
+
"@ultimat3/admin": "19.2.0",
|
|
42
|
+
"@ultimat3/ai": "19.2.0",
|
|
43
|
+
"@ultimat3/auth": "19.2.0",
|
|
44
|
+
"@ultimat3/cache": "19.2.0",
|
|
45
|
+
"@ultimat3/core": "19.2.0",
|
|
46
|
+
"@ultimat3/db": "19.2.0",
|
|
47
|
+
"@ultimat3/entity": "19.2.0",
|
|
48
|
+
"@ultimat3/flags": "19.2.0",
|
|
49
|
+
"@ultimat3/http": "19.2.0",
|
|
50
|
+
"@ultimat3/i18n": "19.2.0",
|
|
51
|
+
"@ultimat3/jobs": "19.2.0",
|
|
52
|
+
"@ultimat3/mail": "19.2.0",
|
|
53
|
+
"@ultimat3/manifest": "19.2.0",
|
|
54
|
+
"@ultimat3/mcp": "19.2.0",
|
|
55
|
+
"@ultimat3/money": "19.2.0",
|
|
56
|
+
"@ultimat3/notify": "19.2.0",
|
|
57
|
+
"@ultimat3/policy": "19.2.0",
|
|
58
|
+
"@ultimat3/pwa": "19.2.0",
|
|
59
|
+
"@ultimat3/query": "19.2.0",
|
|
60
|
+
"@ultimat3/realtime": "19.2.0",
|
|
61
|
+
"@ultimat3/render": "19.2.0",
|
|
62
|
+
"@ultimat3/schema": "19.2.0",
|
|
63
|
+
"@ultimat3/scraping": "19.2.0",
|
|
64
|
+
"@ultimat3/seo": "19.2.0",
|
|
65
|
+
"@ultimat3/storage": "19.2.0",
|
|
66
|
+
"@ultimat3/testing": "19.2.0",
|
|
67
|
+
"@ultimat3/time": "19.2.0",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/app-openapi.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
// `openapi.json`, projected from the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// `openapi.json`, projected from the two registries: the actions by `@ultimat3/action`'s
|
|
2
|
+
// `buildOpenApi`, the queries by `@ultimat3/query`'s `queryOpenApiPaths`. The CLI merges the two
|
|
3
|
+
// `paths` maps — those packages are one tier and cannot compose each other — writes the file and
|
|
4
|
+
// compares the bytes; it does not know how an operation is shaped, which is why there is no third
|
|
5
|
+
// OpenAPI builder to drift from the ones the packages serve. Until 2026-09 only the actions were
|
|
6
|
+
// here, and every `GET /_x/query/<name>` the server mounted was a route the spec had never heard of.
|
|
4
7
|
|
|
5
8
|
import { buildOpenApi, serializeOpenApi } from '@ultimat3/action';
|
|
6
9
|
import type { Manifest } from '@ultimat3/manifest';
|
|
10
|
+
import { queryOpenApiPaths } from '@ultimat3/query';
|
|
7
11
|
|
|
8
12
|
export const OPENAPI_FILE = 'openapi.json';
|
|
9
13
|
|
|
10
14
|
/** The exact bytes on disk — deterministic, so `x verify` can compare them literally. */
|
|
11
|
-
export const openApiJson = (manifest: Manifest): string =>
|
|
12
|
-
|
|
15
|
+
export const openApiJson = (manifest: Manifest): string => {
|
|
16
|
+
const document = buildOpenApi({ title: manifest.app.name, version: manifest.app.version });
|
|
17
|
+
// Action paths are `/api/...`, query paths `/_x/query/...`: disjoint by prefix, so the spread
|
|
18
|
+
// can never shadow one with the other. `serializeOpenApi` sorts the merged keys.
|
|
19
|
+
return serializeOpenApi({ ...document, paths: { ...document.paths, ...queryOpenApiPaths() } });
|
|
20
|
+
};
|
package/src/app-permissions.ts
CHANGED
|
Binary file
|
package/src/browser-launcher.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { existsSync } from 'node:fs';
|
|
|
13
13
|
import { UltimateError } from '@ultimat3/core';
|
|
14
14
|
import type { CdpLauncherLike, ScrapeDriver } from '@ultimat3/scraping';
|
|
15
15
|
import { localBrowser, remoteBrowser } from '@ultimat3/scraping';
|
|
16
|
+
import { CHROME_CANDIDATES } from './cdp-launch';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* The one library this works against. Playwright is not an alternative and is not a flag:
|
|
@@ -56,6 +57,33 @@ export class ShotBrowserMissingError extends UltimateError {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The OTHER half of "no browser": the library is installed and there is no Chrome for it to start.
|
|
62
|
+
*
|
|
63
|
+
* The same code as the class above because it is the same question to a reader — `x shot` has no
|
|
64
|
+
* browser — and a code is a stable public name, not a taxonomy. The cause and the fix are what
|
|
65
|
+
* differ, and they are the half that is acted on: `bun add -d puppeteer-core` cannot install
|
|
66
|
+
* Chrome, and `export CHROME_PATH=…` cannot install a client that speaks CDP.
|
|
67
|
+
*
|
|
68
|
+
* Raised BEFORE the dev server, which is the whole point of it: the launch that used to report this
|
|
69
|
+
* happens inside `driver.open()`, one embedded Postgres past the point where the answer was already
|
|
70
|
+
* decidable from the environment and the filesystem.
|
|
71
|
+
*/
|
|
72
|
+
export class ShotChromeMissingError extends UltimateError {
|
|
73
|
+
constructor() {
|
|
74
|
+
super({
|
|
75
|
+
code: 'X_SHOT_BROWSER_MISSING',
|
|
76
|
+
cause: `x shot launches a browser here and none was named or found — no ${BROWSER_PATH_VARS.join(' or ')} is set, and none of ${CHROME_CANDIDATES.join(', ')} is on disk`,
|
|
77
|
+
// One literal, for `fix-scan.ts`'s reason — the same rule the class above states. It names an
|
|
78
|
+
// export rather than an install because a path is the one repair that works whatever the
|
|
79
|
+
// browser is and wherever the distribution put it; `--cdp-url` is the answer for a box that
|
|
80
|
+
// will never have one, and it is a flag `x shot` already ships.
|
|
81
|
+
fix: 'export CHROME_PATH=/usr/bin/google-chrome # any Chrome or Chromium binary; a box that will never have one attaches instead: x shot / --cdp-url wss://cdp.example.com/session/abc',
|
|
82
|
+
meta: { tried: [...CHROME_CANDIDATES], vars: [...BROWSER_PATH_VARS] },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
59
87
|
/**
|
|
60
88
|
* Structural, because this is somebody else's module: a namespace object, a CJS `default`, or a
|
|
61
89
|
* transpiled interop wrapper are all shapes `import()` legitimately hands back, and only one
|
|
@@ -99,22 +127,43 @@ export interface AppBrowserOptions {
|
|
|
99
127
|
readonly load?: (path: string) => Promise<unknown>;
|
|
100
128
|
}
|
|
101
129
|
|
|
102
|
-
/**
|
|
130
|
+
/** True when a named executable is really there — a bad `--browser` is refused before a boot. */
|
|
131
|
+
export const browserBinaryExists = (path: string): boolean => existsSync(path);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The path a run will launch, or `undefined` when this machine has no browser to launch.
|
|
135
|
+
*
|
|
136
|
+
* NOT "let the library find its own": `puppeteer-core` ships no browser and has no default, so a
|
|
137
|
+
* launch with no `executablePath` throws ``An `executablePath` or `channel` must be specified``
|
|
138
|
+
* from inside somebody else's library, one dev-server boot after the point where the answer was
|
|
139
|
+
* knowable. So the last step is a PROBE — the same four paths `cdp-launch.ts` already tries for the
|
|
140
|
+
* e2e driver, imported rather than restated, because two lists of Chrome locations that must agree
|
|
141
|
+
* is the drift axiom 2 refuses.
|
|
142
|
+
*
|
|
143
|
+
* A value NAMED in the flag or the environment is answered without touching the filesystem, even
|
|
144
|
+
* when nothing is there: an operator who typed a path has a belief about which binary runs, and
|
|
145
|
+
* silently substituting a probed one would photograph a page in a browser they did not choose.
|
|
146
|
+
* `shotBrowserChoice` reports that path as absent instead.
|
|
147
|
+
*
|
|
148
|
+
* `exists` is the injectable seam, so the probe is asserted the same way on a machine with Chrome
|
|
149
|
+
* and on one without.
|
|
150
|
+
*/
|
|
103
151
|
export const executablePathFrom = (
|
|
104
152
|
flag: string | undefined,
|
|
105
153
|
env: Readonly<Record<string, string | undefined>>,
|
|
154
|
+
exists: (path: string) => boolean = browserBinaryExists,
|
|
106
155
|
): string | undefined => {
|
|
107
156
|
if (flag !== undefined && flag.length > 0) return flag;
|
|
108
157
|
for (const name of BROWSER_PATH_VARS) {
|
|
109
158
|
const value = env[name];
|
|
110
159
|
if (value !== undefined && value.length > 0) return value;
|
|
111
160
|
}
|
|
161
|
+
for (const candidate of CHROME_CANDIDATES) {
|
|
162
|
+
if (exists(candidate)) return candidate;
|
|
163
|
+
}
|
|
112
164
|
return undefined;
|
|
113
165
|
};
|
|
114
166
|
|
|
115
|
-
/** True when a named executable is really there — a bad `--browser` is refused before a boot. */
|
|
116
|
-
export const browserBinaryExists = (path: string): boolean => existsSync(path);
|
|
117
|
-
|
|
118
167
|
/** The endpoint a run will attach to, or `undefined` for "launch one here". */
|
|
119
168
|
export const cdpUrlFrom = (
|
|
120
169
|
flag: string | undefined,
|
package/src/budgets.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
|
12
12
|
import type { Manifest, RouteFact } from '@ultimat3/manifest';
|
|
13
13
|
import { formatBytes, parseByteBudget } from '@ultimat3/render';
|
|
14
14
|
import type { Finding } from './output';
|
|
15
|
+
import type { UnmeasuredRoute } from './static-report';
|
|
15
16
|
|
|
16
17
|
export const BUILD_STATS_FILE = join('.x', 'build-stats.json');
|
|
17
18
|
|
|
@@ -87,14 +88,52 @@ function unmeasuredFinding(url: string, declared: string, built: boolean): Findi
|
|
|
87
88
|
};
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The ONE code a failed measurement is reported under by its own name rather than as
|
|
93
|
+
* `X_BUDGET_UNMEASURED`. Its cause is complete — the island, the prop, its bytes, the cap — and
|
|
94
|
+
* its fix is an edit to the page, so the step's own "run x build and read the list" would put a
|
|
95
|
+
* second command between the author and a sentence the build had already composed. Every other
|
|
96
|
+
* render failure keeps the generic finding: a `TypeError` from a `load` that wanted a request is
|
|
97
|
+
* a reason to read the report, not an instruction.
|
|
98
|
+
*
|
|
99
|
+
* ONE code and not "any coded error", deliberately. `X_NO_CONTEXT`, `X_UNAUTHENTICATED` and
|
|
100
|
+
* `X_DB_UNAVAILABLE` from a measurement render are facts about the BUILD's environment, and
|
|
101
|
+
* reporting them under their own codes would tell the author to fix a database the gate never
|
|
102
|
+
* had. The list grows by a decision, per code, here.
|
|
103
|
+
*/
|
|
104
|
+
const REPORTED_BY_OWN_CODE: ReadonlySet<string> = new Set(['X_ISLAND_PROPS_INVALID']);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The build's own finding for a route it could not weigh, when that failure is an instruction.
|
|
108
|
+
* Read off the static report's `unmeasured` list — the same list `X_BUDGET_UNMEASURED`'s `fix:`
|
|
109
|
+
* sends its reader to, now read by the step itself for the one code it can act on.
|
|
110
|
+
*/
|
|
111
|
+
function ownCodeFinding(url: string, unmeasured: readonly UnmeasuredRoute[]): Finding | undefined {
|
|
112
|
+
const entry = unmeasured.find((one) => one.path === url);
|
|
113
|
+
if (entry?.code === undefined || !REPORTED_BY_OWN_CODE.has(entry.code)) return undefined;
|
|
114
|
+
return {
|
|
115
|
+
code: entry.code,
|
|
116
|
+
cause: entry.cause ?? entry.reason,
|
|
117
|
+
fix: entry.fix ?? `x build --target static --json # its "unmeasured" list has ${url}`,
|
|
118
|
+
docs: ERROR_DOCS_URL,
|
|
119
|
+
at: url,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
90
123
|
/**
|
|
91
124
|
* `undefined` stats means no build has run; `{ routes: [] }` means one ran and emitted nothing.
|
|
92
125
|
* The parameter is widened rather than defaulted, because collapsing the two here is exactly the
|
|
93
126
|
* distinction the finding above exists to make.
|
|
127
|
+
*
|
|
128
|
+
* `unmeasured` is the static report's list of routes the build rendered and could not weigh, with
|
|
129
|
+
* each failure's code when it had one. Optional because the report is written beside the stats
|
|
130
|
+
* and can be absent for the same reason; with it, a route whose measurement failed on a code in
|
|
131
|
+
* `REPORTED_BY_OWN_CODE` is reported under that code, with the build's own cause and fix.
|
|
94
132
|
*/
|
|
95
133
|
export function checkBudgets(
|
|
96
134
|
manifest: Manifest,
|
|
97
135
|
stats: BuildStats | undefined,
|
|
136
|
+
unmeasured: readonly UnmeasuredRoute[] = [],
|
|
98
137
|
): readonly Finding[] {
|
|
99
138
|
const byPath = new Map((stats?.routes ?? []).map((route) => [route.path, route]));
|
|
100
139
|
const findings: Finding[] = [];
|
|
@@ -104,7 +143,10 @@ export function checkBudgets(
|
|
|
104
143
|
const lcp = route.budget?.lcp;
|
|
105
144
|
if (measured === undefined) {
|
|
106
145
|
if (js !== null || lcp !== undefined) {
|
|
107
|
-
findings.push(
|
|
146
|
+
findings.push(
|
|
147
|
+
ownCodeFinding(route.url, unmeasured) ??
|
|
148
|
+
unmeasuredFinding(route.url, declaredBudgets(js, lcp), stats !== undefined),
|
|
149
|
+
);
|
|
108
150
|
}
|
|
109
151
|
continue;
|
|
110
152
|
}
|
package/src/cmd-dev.ts
CHANGED
|
@@ -39,10 +39,12 @@ import type { DevServices } from './dev-services';
|
|
|
39
39
|
import { describeServices, reportedUrls, resolveServices } from './dev-services';
|
|
40
40
|
import { storageRoutes } from './dev-storage';
|
|
41
41
|
import { createTraceRecorder } from './dev-traces';
|
|
42
|
+
import { isIgnoredPath } from './dev-watch';
|
|
42
43
|
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
43
44
|
import { holdUntilShutdown } from './hold';
|
|
44
45
|
import type { IslandBundle } from './island-bundle';
|
|
45
46
|
import { buildIslands } from './island-bundle';
|
|
47
|
+
import { FRAME_STYLE } from './island-harness';
|
|
46
48
|
import { islandHarnessRoutes } from './island-harness-route';
|
|
47
49
|
import { islandRoutes } from './island-routes';
|
|
48
50
|
import { loadIslandStates } from './island-states-load';
|
|
@@ -53,6 +55,8 @@ import { flagString } from './parse';
|
|
|
53
55
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
54
56
|
import { metricsPortFor } from './serve';
|
|
55
57
|
import { loopFacts, loopFinding, loopNotice } from './statement-loop';
|
|
58
|
+
import { styleBundle } from './style-bundle';
|
|
59
|
+
import { styleRoutes } from './style-routes';
|
|
56
60
|
import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
57
61
|
import { serviceWorkerRoutes } from './sw-routes';
|
|
58
62
|
|
|
@@ -92,12 +96,16 @@ interface DevState {
|
|
|
92
96
|
islands: IslandBundle;
|
|
93
97
|
}
|
|
94
98
|
|
|
95
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* Debounced: a save that touches five files is one reload, not five. What counts as a save at all
|
|
101
|
+
* is `dev-watch.ts` — a reload is a full `appManifest()` plus a `buildIslands()` over every island,
|
|
102
|
+
* so a write this cannot rule out is the most expensive no-op the dev loop has.
|
|
103
|
+
*/
|
|
96
104
|
function watchApp(root: string, onChange: (file: string) => void): () => void {
|
|
97
105
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
98
106
|
let last = '';
|
|
99
107
|
const watcher = watch(root, { recursive: true }, (_event, filename) => {
|
|
100
|
-
if (filename === null ||
|
|
108
|
+
if (filename === null || isIgnoredPath(filename)) return;
|
|
101
109
|
last = filename;
|
|
102
110
|
if (timer !== undefined) clearTimeout(timer);
|
|
103
111
|
timer = setTimeout(() => onChange(last), 30);
|
|
@@ -191,7 +199,13 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
191
199
|
const serviceWorker =
|
|
192
200
|
pwa === undefined
|
|
193
201
|
? undefined
|
|
194
|
-
: serviceWorkerArtifacts({
|
|
202
|
+
: serviceWorkerArtifacts({
|
|
203
|
+
pwa,
|
|
204
|
+
buildId,
|
|
205
|
+
routes: describeRoutes(),
|
|
206
|
+
islands: state.islands,
|
|
207
|
+
styles: styleBundle(),
|
|
208
|
+
});
|
|
195
209
|
|
|
196
210
|
// The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
|
|
197
211
|
// call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
|
|
@@ -215,6 +229,10 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
215
229
|
// The chunks the documents below name. Mounted before the app's routes for the reason
|
|
216
230
|
// `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
|
|
217
231
|
...islandRoutes(() => state.islands),
|
|
232
|
+
// And the stylesheet every one of those documents links. Read through the getter for the
|
|
233
|
+
// reason the islands are: a rebuilt island registers CSS, which mints a new URL, and a table
|
|
234
|
+
// captured at boot would answer 404 for the href the document now carries.
|
|
235
|
+
...styleRoutes(() => styleBundle()),
|
|
218
236
|
// `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
|
|
219
237
|
// lives here rather than in a second server because everything it needs is in THIS process:
|
|
220
238
|
// the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
|
|
@@ -258,10 +276,12 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
258
276
|
// The same seam `serve.ts` passes: the app's own error page is a FILE, so the root is what
|
|
259
277
|
// `startWeb` needs to find one.
|
|
260
278
|
root: options.root,
|
|
261
|
-
// The
|
|
262
|
-
// app's
|
|
263
|
-
//
|
|
264
|
-
|
|
279
|
+
// The documents this process serves that the app did not write — the `/_x` shell and the
|
|
280
|
+
// screenshot harness's frame. The app's OWN surfaces need no entry any more: their CSS is a
|
|
281
|
+
// content-hashed file `'self'` already admits (`style-bundle.ts`). `x dev` sends the policy
|
|
282
|
+
// report-only, so an uncovered `<style>` here is a console report rather than a blank page —
|
|
283
|
+
// which is how this reached production.
|
|
284
|
+
inlineStyles: [await devShellStyle(), FRAME_STYLE],
|
|
265
285
|
// The fourth surface, and the only one an author sees without leaving the page they broke:
|
|
266
286
|
// the overlay renders this request's own loops under the error it is already showing.
|
|
267
287
|
// `serve.ts` boots through the same `startRoles` and passes nothing, so production has no
|
package/src/cmd-shot.ts
CHANGED
|
@@ -322,7 +322,9 @@ export const shotCommand: CliCommand = {
|
|
|
322
322
|
const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
|
|
323
323
|
// Which browser this run gets — start one here, or attach to one somebody else is running.
|
|
324
324
|
// Decided by `shot-browser.ts` over plain inputs, and decided HERE, before a dev server or a
|
|
325
|
-
// provider session exists to pay for a typo.
|
|
325
|
+
// provider session exists to pay for a typo. It also PROBES for an installed Chrome and refuses
|
|
326
|
+
// when there is none: `puppeteer-core` bundles no browser, so a missing one used to surface as
|
|
327
|
+
// a library throw one embedded Postgres later.
|
|
326
328
|
const { cdpUrl, executablePath } = shotBrowserChoice({
|
|
327
329
|
cdpFlag: flagString(ctx.args, 'cdp-url'),
|
|
328
330
|
browserFlag: flagString(ctx.args, 'browser'),
|
package/src/dev-render.ts
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
// document, it never decides what a mode means or what headers it earns.
|
|
4
4
|
//
|
|
5
5
|
// The document is head + the route's own component, rendered by `@ultimat3/render`'s server JSX
|
|
6
|
-
// writer, with the surface's compiled CSS
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// writer, with the surface's compiled CSS LINKED — one content-hashed file per surface
|
|
7
|
+
// (`style-bundle.ts`), served `immutable`.
|
|
8
|
+
//
|
|
9
|
+
// It was inlined until 2026-09-06, on the argument that a `site/` page is a 0kb-JS artifact a CDN
|
|
10
|
+
// serves as one file and a link would add a round trip. The round trip is real and it is paid
|
|
11
|
+
// once: measured against ai-maxxing, every `app/` document carried the SAME 156,738-byte `<style>`
|
|
12
|
+
// block — 92% of the dashboard document — inside a response the pipeline sends
|
|
13
|
+
// `Cache-Control: private, no-store`, so the trip that argument saved was re-paid in full on every
|
|
14
|
+
// navigation, with a re-parse on top. The static export writes the file (`writeStyles`), so the
|
|
15
|
+
// "second file" cost is one `Bun.write`.
|
|
9
16
|
|
|
10
17
|
import type { Ctx } from '@ultimat3/core';
|
|
11
18
|
import type { RouteMeta as HttpRouteMeta, Route, RouteParams } from '@ultimat3/http';
|
|
@@ -32,8 +39,8 @@ import {
|
|
|
32
39
|
renderSsr,
|
|
33
40
|
staticHeaders,
|
|
34
41
|
streamResult,
|
|
35
|
-
stylesFor,
|
|
36
42
|
} from '@ultimat3/render/server';
|
|
43
|
+
import { styleBundle } from './style-bundle';
|
|
37
44
|
|
|
38
45
|
/**
|
|
39
46
|
* Specifier → built chunk URL, bound to the route file the specifier is written relative to.
|
|
@@ -92,10 +99,24 @@ const headFor = async (
|
|
|
92
99
|
),
|
|
93
100
|
) + (options.pwaHead ?? '');
|
|
94
101
|
|
|
95
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* `<link rel="stylesheet">` for the surface's own stylesheets, or nothing at all when the surface
|
|
104
|
+
* imports none.
|
|
105
|
+
*
|
|
106
|
+
* In `<head>`, which is what keeps this a swap and not a regression: a `<link rel="stylesheet">`
|
|
107
|
+
* there is render-blocking in every browser, exactly as the inline block was, so there is no
|
|
108
|
+
* window in which the document paints unstyled. Moving it to the body, or deferring it, is what
|
|
109
|
+
* would introduce a flash — never do that here.
|
|
110
|
+
*
|
|
111
|
+
* Read through `styleBundle()` rather than passed in through `DocumentOptions`: the registry it
|
|
112
|
+
* derives from is process-global (importing the app IS what fills it), so a caller that forgot to
|
|
113
|
+
* thread a resolver would serve a document with no CSS at all — and `appRoutes` is on this
|
|
114
|
+
* package's public surface, called as `appRoutes({ buildId })` by both tracked apps' contract
|
|
115
|
+
* tests. One reader, and it is the same one `styleRoutes` serves from.
|
|
116
|
+
*/
|
|
96
117
|
const styleTag = (entry: RouteEntry): string => {
|
|
97
|
-
const
|
|
98
|
-
return
|
|
118
|
+
const href = styleBundle().hrefFor(entry.surface);
|
|
119
|
+
return href === undefined ? '' : `<link rel="stylesheet" href="${href}">`;
|
|
99
120
|
};
|
|
100
121
|
|
|
101
122
|
/**
|
package/src/dev-roles.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
|
|
|
43
43
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
44
44
|
import { inlineScriptSources } from './script-csp';
|
|
45
45
|
import { inlineStyleSources } from './style-csp';
|
|
46
|
+
import { DEV_BINDING, type WebBinding } from './web-binding';
|
|
46
47
|
|
|
47
48
|
/** The roles `x dev` starts when `--role` names none, in boot order. */
|
|
48
49
|
export const DEV_ROLES: readonly Role[] = ['web', 'sync', 'worker', 'scheduler'];
|
|
@@ -113,13 +114,9 @@ export interface StartRolesOptions {
|
|
|
113
114
|
readonly overrides?: RuntimeOverrides;
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Loopback and dev-mode. What `x dev` means, and what a container must override. */
|
|
122
|
-
export const DEV_BINDING: WebBinding = { dev: true, hostname: 'localhost' };
|
|
117
|
+
// Re-exported, not re-declared: `web-binding.ts` is a leaf so `dev-sync` can read the default
|
|
118
|
+
// without importing this module, which imports it.
|
|
119
|
+
export { DEV_BINDING, type WebBinding } from './web-binding';
|
|
123
120
|
|
|
124
121
|
export interface RunningRoles {
|
|
125
122
|
readonly roles: readonly Role[];
|
|
@@ -366,9 +363,13 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
366
363
|
// First, and for every role rather than only the two that open an HTTP socket: `worker` and
|
|
367
364
|
// `sync` are precisely the roles whose HPAs read a series the process itself has to publish,
|
|
368
365
|
// and a `worker` container with no listener is an HPA pinned at `<unknown>` forever.
|
|
366
|
+
// `?? DEV_BINDING`, not "omit the key when `options.http` is undefined". The old spread left
|
|
367
|
+
// the metrics endpoint on Bun's `0.0.0.0` for exactly the caller that asked for loopback —
|
|
368
|
+
// `x dev`, which passes no `http` at all — so the same gap the sync node had was here too.
|
|
369
|
+
const binding = options.http ?? DEV_BINDING;
|
|
369
370
|
const metrics = startMetricsEndpoint({
|
|
370
371
|
port: options.metricsPort ?? (options.port === 0 ? 0 : DEFAULT_METRICS_PORT),
|
|
371
|
-
|
|
372
|
+
hostname: binding.hostname,
|
|
372
373
|
});
|
|
373
374
|
started.push(async () => metrics.stop());
|
|
374
375
|
|
package/src/dev-sync.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { StartRolesOptions } from './dev-roles';
|
|
|
18
18
|
import { neighbouringPort, PORT_RANGE } from './flag-number';
|
|
19
19
|
import { portFree } from './port-probe';
|
|
20
20
|
import { syncAuthenticator } from './sync-authenticator';
|
|
21
|
+
import { DEV_BINDING } from './web-binding';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Beside its one thrower rather than in `errors.ts`, which is at 461 of the 500-line ceiling —
|
|
@@ -177,7 +178,12 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
|
|
|
177
178
|
await node.start();
|
|
178
179
|
const port = syncPortFor(options.port);
|
|
179
180
|
try {
|
|
180
|
-
|
|
181
|
+
// The SAME interface the web role binds, resolved from the same option and the same default.
|
|
182
|
+
// Without this the sync node took Bun's `0.0.0.0` while `x dev`'s web role took `localhost`,
|
|
183
|
+
// so the one socket that streams live database patches was the one socket on every
|
|
184
|
+
// interface — and `WebBinding`'s own docstring is about not serving a laptop's app to a café.
|
|
185
|
+
const binding = options.http ?? DEV_BINDING;
|
|
186
|
+
const listener = listenSyncNode(node, { port, hostname: binding.hostname });
|
|
181
187
|
return {
|
|
182
188
|
url: listener.url,
|
|
183
189
|
registry,
|
package/src/dev-watch.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Which writes under the app root are a source change, and which are noise. Split out of
|
|
2
|
+
// `cmd-dev.ts` because it is a rule with cases rather than four lines of glue, and because the
|
|
3
|
+
// answer needs a test of its own: a watcher that reloads on the wrong write is invisible — the
|
|
4
|
+
// dev server stays correct and merely does the most expensive thing it can do, repeatedly.
|
|
5
|
+
//
|
|
6
|
+
// What it was: `filename.includes('.x/') || filename.includes('node_modules')`. Measured against
|
|
7
|
+
// ai-maxxing, whose checkout carries `.git/`, `.personal/`, `.claude/worktrees/` (two FULL copies
|
|
8
|
+
// of the app) and `coverage/`, every write under any of them ran a whole `appManifest()` plus a
|
|
9
|
+
// `buildIslands()` over ten islands. `git status`, an agent's scratch file and a coverage run each
|
|
10
|
+
// cost a full rebuild, and each rebuild re-minted every island chunk.
|
|
11
|
+
//
|
|
12
|
+
// `includes` was also the wrong operator, not just the wrong list: a directory legitimately named
|
|
13
|
+
// `my-node_modules-notes/` was excluded from the dev loop for a substring, and `notes/.xyz/` for
|
|
14
|
+
// another. The match is on a PATH SEGMENT, so only the directory itself is ever ignored.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Directories whose writes are never an app source change.
|
|
18
|
+
*
|
|
19
|
+
* Every entry earns its line and none is a guess:
|
|
20
|
+
* - `.x` — the framework's own state directory: PGlite's data, the dev lock, the static export,
|
|
21
|
+
* `build-stats.json`. `x build` writes here, which made a build trigger reloads of the process
|
|
22
|
+
* that was running it.
|
|
23
|
+
* - `node_modules` — an install, not an edit. `x dev` does not reload for a dependency change
|
|
24
|
+
* because it cannot: the modules are already in this process's cache.
|
|
25
|
+
* - `.git` — the reason this list exists. `git status`, `git fetch` and every commit rewrite index
|
|
26
|
+
* and ref files continuously, and none of them is a source edit; a checkout of a branch IS one,
|
|
27
|
+
* but it also writes the source files themselves, which this list does not touch.
|
|
28
|
+
* - `.personal` — an app's uncommitted local state (ai-maxxing's fleet file, its inventory, its
|
|
29
|
+
* credentials). Written by scripts while the dev server runs.
|
|
30
|
+
* - `.claude` — agent scratch, session logs and worktrees. ai-maxxing keeps two entire copies of
|
|
31
|
+
* the app under `.claude/worktrees/`, so a second agent's edit rebuilt the first agent's islands.
|
|
32
|
+
* - `dist`, `coverage` — build and test output. Both are written by commands an author runs
|
|
33
|
+
* BESIDE `x dev`, which is exactly when a spurious rebuild costs the most.
|
|
34
|
+
*/
|
|
35
|
+
export const IGNORED_DIRECTORIES: readonly string[] = [
|
|
36
|
+
'.x',
|
|
37
|
+
'node_modules',
|
|
38
|
+
'.git',
|
|
39
|
+
'.personal',
|
|
40
|
+
'.claude',
|
|
41
|
+
'dist',
|
|
42
|
+
'coverage',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A path segment, never a substring. `filename` arrives from `node:fs`'s watcher root-relative and
|
|
47
|
+
* with the platform's separator, so both are normalised before the split — a rule that reads
|
|
48
|
+
* `foo/node_modules/bar` and not `foo\\node_modules\\bar` is a rule that does not exist on Windows.
|
|
49
|
+
*/
|
|
50
|
+
export function isIgnoredPath(filename: string): boolean {
|
|
51
|
+
const segments = filename.replaceAll('\\', '/').split('/');
|
|
52
|
+
return segments.some((segment) => IGNORED_DIRECTORIES.includes(segment));
|
|
53
|
+
}
|