@ultimat3/cli 24.0.0 → 25.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +66 -87
  130. package/src/role-sync.ts +65 -6
  131. package/src/role-wake.ts +2 -3
  132. package/src/root-env.ts +157 -28
  133. package/src/route-islands.ts +69 -0
  134. package/src/runtime-admin.ts +4 -1
  135. package/src/runtime-assets.ts +15 -2
  136. package/src/runtime-bindings.ts +23 -3
  137. package/src/runtime-cache.ts +8 -8
  138. package/src/runtime-hooks.ts +2 -1
  139. package/src/runtime-isr-outcome.ts +100 -0
  140. package/src/runtime-isr.ts +2 -2
  141. package/src/runtime-jobs.ts +78 -18
  142. package/src/runtime-live-feed.ts +1 -1
  143. package/src/runtime-mail.ts +26 -0
  144. package/src/runtime-mfa-warning.ts +2 -2
  145. package/src/runtime-overrides.ts +3 -3
  146. package/src/runtime-purge.ts +1 -1
  147. package/src/runtime-queue.ts +19 -30
  148. package/src/runtime-realtime.ts +5 -3
  149. package/src/runtime-render.ts +77 -82
  150. package/src/runtime-replica.ts +3 -8
  151. package/src/runtime-services.ts +36 -15
  152. package/src/runtime-storage-upload.ts +2 -2
  153. package/src/runtime-storage.ts +3 -3
  154. package/src/schema-diff.ts +1 -1
  155. package/src/secrets-rotation.ts +8 -9
  156. package/src/seo-routes.ts +2 -2
  157. package/src/serve-boot.ts +22 -11
  158. package/src/serve-drain.ts +3 -1
  159. package/src/serve-retire.ts +95 -0
  160. package/src/serve-web.ts +19 -8
  161. package/src/serve.ts +27 -10
  162. package/src/server-barrels.ts +6 -3
  163. package/src/shot-browser.ts +1 -1
  164. package/src/shot-locale.ts +6 -15
  165. package/src/shot-settle.ts +3 -3
  166. package/src/shot-theme.ts +1 -1
  167. package/src/shot-viewport.ts +152 -0
  168. package/src/signal-shred.ts +26 -12
  169. package/src/site-asset-routes.ts +2 -2
  170. package/src/site-seo.ts +6 -4
  171. package/src/source-files.ts +13 -3
  172. package/src/static-report.ts +5 -0
  173. package/src/style-routes.ts +2 -2
  174. package/src/sw-artifacts.ts +8 -8
  175. package/src/sw-precache-plan.ts +34 -4
  176. package/src/sync-authenticator.ts +3 -3
  177. package/src/sync-url.ts +1 -1
  178. package/src/templates/action.ts +4 -4
  179. package/src/templates/backfill.ts +4 -4
  180. package/src/templates/entity.ts +2 -2
  181. package/src/templates/github/ci.yml.ts +28 -18
  182. package/src/templates/github/image.yml.ts +92 -0
  183. package/src/templates/github/runner.ts +18 -0
  184. package/src/templates/guard-bare-error.ts +16 -2
  185. package/src/templates/guard-unzoned-date.ts +56 -6
  186. package/src/templates/index.ts +1 -1
  187. package/src/templates/job-body-test.ts +2 -2
  188. package/src/templates/job.ts +6 -6
  189. package/src/templates/query.ts +9 -8
  190. package/src/templates/resource-page.ts +9 -7
  191. package/src/templates/resource-service.ts +2 -2
  192. package/src/templates/route.ts +23 -8
  193. package/src/templates/scaffold-api.ts +2 -3
  194. package/src/templates/scaffold-app.ts +2 -1
  195. package/src/templates/scaffold-auth.ts +205 -70
  196. package/src/templates/scaffold-bin.ts +122 -0
  197. package/src/templates/scaffold-claude-commands.ts +11 -11
  198. package/src/templates/scaffold-container.ts +12 -6
  199. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  200. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  201. package/src/templates/scaffold-dashboard-example.ts +112 -172
  202. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  203. package/src/templates/scaffold-dashboard.ts +9 -5
  204. package/src/templates/scaffold-db-package.ts +2 -2
  205. package/src/templates/scaffold-docs.ts +20 -77
  206. package/src/templates/scaffold-domain-package.ts +0 -4
  207. package/src/templates/scaffold-entries.ts +4 -3
  208. package/src/templates/scaffold-env.ts +6 -3
  209. package/src/templates/scaffold-gitattributes.ts +59 -0
  210. package/src/templates/scaffold-helm-templates.ts +74 -18
  211. package/src/templates/scaffold-helm.ts +31 -4
  212. package/src/templates/scaffold-http.ts +1 -1
  213. package/src/templates/scaffold-i18n.ts +15 -2
  214. package/src/templates/scaffold-icon.ts +2 -2
  215. package/src/templates/scaffold-repo.ts +18 -15
  216. package/src/templates/scaffold-shell.ts +17 -2
  217. package/src/test-only-shipped.ts +193 -0
  218. package/src/test-select.ts +2 -1
  219. package/src/test-shards.ts +1 -4
  220. package/src/theme-boot.ts +2 -2
  221. package/src/theme-brand.ts +66 -0
  222. package/src/trusted-hops.ts +49 -0
  223. package/src/unscanned-admin.ts +2 -2
  224. package/src/verify-checks.ts +12 -2
  225. package/src/verify-coverage-run.ts +3 -2
  226. package/src/verify-deadline.ts +87 -10
  227. package/src/verify-merge.ts +48 -16
  228. package/src/verify-part-step.ts +10 -2
  229. package/src/verify-role-load-probe.ts +12 -2
  230. package/src/verify-role-load.ts +9 -4
  231. package/src/verify-run.ts +3 -0
  232. package/src/verify-step.ts +2 -0
  233. package/src/verify-test-run.ts +3 -2
  234. package/src/verify-tests.ts +0 -1
  235. package/src/worker-bundle.ts +2 -2
  236. package/src/workspace-checks.ts +53 -7
  237. package/src/workspace-graph.ts +3 -2
  238. package/src/write-line.ts +82 -7
  239. package/src/browser-launcher-fake-html.ts +0 -80
  240. package/src/browser-launcher-fake.ts +0 -168
  241. package/src/jobs-drain.ts +0 -230
  242. package/src/scaffold-typecheck.ts +0 -227
  243. package/src/thrown-by.ts +0 -24
package/src/budgets.ts CHANGED
@@ -8,15 +8,20 @@
8
8
 
9
9
  import { existsSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
- import { ERROR_DOCS_URL } from '@ultimat3/core';
11
+ import { ERROR_DOCS_URL, formatBytes } from '@ultimat3/core';
12
12
  import type { Manifest, RouteFact } from '@ultimat3/manifest';
13
+ import { describePages, parseByteBudget, themeScriptBody } from '@ultimat3/render';
14
+ import type { ChargedFile } from './budgets-charged';
15
+ import { chargedClause, chargedMeta, heaviestFirst } from './budgets-charged';
13
16
  import {
14
- describeRoutes,
15
- formatBytes,
16
- parseByteBudget,
17
- SPECULATION_RULES_TYPE,
18
- themeScriptBody,
19
- } from '@ultimat3/render';
17
+ artifactPath,
18
+ carriesJson,
19
+ ENTRY_ATTR,
20
+ loadedBy,
21
+ SCRIPT_TAG,
22
+ SRC_ATTR,
23
+ } from './budgets-scan';
24
+ import { builtUnmeasuredFinding } from './budgets-unmeasured';
20
25
  import type { Finding } from './output';
21
26
  import type { UnmeasuredRoute } from './static-report';
22
27
  import { SW_REGISTER_PATH } from './sw-artifacts';
@@ -41,6 +46,13 @@ export interface RouteStats {
41
46
  readonly frameworkJsBytes?: number;
42
47
  /** Import chain that pulled the heaviest module into this route. */
43
48
  readonly heaviestChain?: readonly string[];
49
+ /**
50
+ * Every app file `jsBytes` charged, heaviest first, at the URL the browser fetches. Optional
51
+ * for `frameworkJsBytes`' reason: a row from an older build has none, and absent is not empty.
52
+ */
53
+ readonly charged?: readonly ChargedFile[];
54
+ /** The app's inline script bytes inside `jsBytes` — the part no file in `charged` accounts for. */
55
+ readonly inlineJsBytes?: number;
44
56
  }
45
57
 
46
58
  export interface BuildStats {
@@ -59,8 +71,11 @@ export interface BuildStats {
59
71
  * `FRAMEWORK_SCRIPTS` exempted it. `2`: that exemption and the page-boot decision (ledger #28).
60
72
  * `3`: an island entry's imported chunks are charged, each once per document (shared chunks).
61
73
  * `4`: the exempt theme script's bytes moved — it also stamps `data-theme-default` (plan 101, 09).
74
+ * `5`: a URL is weighed at the path a browser resolves it to (`\` is `/`, `//host` is another
75
+ * origin), so two spellings of one fetch are charged once; and an app/ page is weighed as SERVED
76
+ * — its page boot charged, the runtime chunk that boot makes unfetched not (plan 101 sweep 9).
62
77
  */
63
- export const BUILD_STATS_RULES = 4;
78
+ export const BUILD_STATS_RULES = 5;
64
79
 
65
80
  const chainOf = (stats: RouteStats): string =>
66
81
  stats.heaviestChain === undefined ? 'unknown import chain' : stats.heaviestChain.join(' -> ');
@@ -71,7 +86,7 @@ const jsBudgetOf = (route: RouteFact): number | null => parseByteBudget(route.bu
71
86
  export type RouteFileOf = (url: string) => string | undefined;
72
87
 
73
88
  const declaredFileOf: RouteFileOf = (url) =>
74
- describeRoutes().find((route) => route.path === url && route.mount === undefined)?.file;
89
+ describePages().find((route) => route.path === url && route.mount === undefined)?.file;
75
90
 
76
91
  const KB = 1024;
77
92
 
@@ -101,8 +116,13 @@ function exceededJsFix(url: string, file: string | undefined, bytes: number): st
101
116
  * second build's. Reporting the first as the second is what sends a reader to re-run a build that
102
117
  * already did everything it was going to do.
103
118
  */
104
- function unmeasuredFinding(url: string, declared: string, built: boolean, stale = false): Finding {
105
- if (stale) {
119
+ function unmeasuredFinding(
120
+ url: string,
121
+ declared: string,
122
+ stats: BuildStats | undefined,
123
+ unmeasured: readonly UnmeasuredRoute[],
124
+ ): Finding {
125
+ if (stats?.stale === true) {
106
126
  return {
107
127
  code: 'X_BUDGET_UNMEASURED',
108
128
  cause: `${url} declares a ${declared} budget and ${BUILD_STATS_FILE} was written by an earlier measurement rule than this gate's (v${String(BUILD_STATS_RULES)}), so its numbers are not this gate's to read`,
@@ -111,54 +131,19 @@ function unmeasuredFinding(url: string, declared: string, built: boolean, stale
111
131
  at: url,
112
132
  };
113
133
  }
134
+ // A build ran: the report's account of this route decides the instruction
135
+ // (`budgets-unmeasured.ts`) — its own code, an actor edit, or the report.
136
+ if (stats !== undefined) {
137
+ const entry = unmeasured.find((one) => one.path === url);
138
+ return builtUnmeasuredFinding(url, declared, BUILD_STATS_FILE, entry);
139
+ }
114
140
  return {
115
141
  code: 'X_BUDGET_UNMEASURED',
116
- cause: built
117
- ? `${url} declares a ${declared} budget and ${BUILD_STATS_FILE} has no row for it, so the build ran and could not weigh it`
118
- : `${url} declares a ${declared} budget and no build has written ${BUILD_STATS_FILE} in this repo`,
142
+ cause: `${url} declares a ${declared} budget and no build has written ${BUILD_STATS_FILE} in this repo`,
119
143
  // `--target static` is load-bearing and `x build` alone was a fix that changes nothing: the
120
144
  // flag defaults to `docker`, and only the static target runs `apps/web/prerender.ts`, which is
121
- // the one caller of `writeBuildStats`. When a build already ran, the second half is where the
122
- // answer is — the report names every route it could not weigh, and why.
123
- fix: built
124
- ? `x build --target static --json # its "unmeasured" list says why ${url} could not be weighed`
125
- : 'x build --target static --json && x verify --json',
126
- docs: ERROR_DOCS_URL,
127
- at: url,
128
- };
129
- }
130
-
131
- /**
132
- * The ONE code a failed measurement is reported under by its own name rather than as
133
- * `X_BUDGET_UNMEASURED`. Its cause is complete — the island, the prop, its bytes, the cap — and
134
- * its fix is an edit to the page, so the step's own "run x build and read the list" would put a
135
- * second command between the author and a sentence the build had already composed. Every other
136
- * render failure keeps the generic finding: a `TypeError` from a `load` that wanted a request is
137
- * a reason to read the report, not an instruction.
138
- *
139
- * ONE code and not "any coded error", deliberately. `X_NO_CONTEXT`, `X_UNAUTHENTICATED` and
140
- * `X_DB_UNAVAILABLE` from a measurement render are facts about the BUILD's environment, and
141
- * reporting them under their own codes would tell the author to fix a database the gate never
142
- * had. The list grows by a decision, per code, here.
143
- */
144
- const REPORTED_BY_OWN_CODE: ReadonlySet<string> = new Set([
145
- 'X_ISLAND_PROPS_INVALID',
146
- // A dynamic route that lists no `prerender()` path: the edit is in the route, not the build.
147
- 'X_BUDGET_PARAMS_UNDECLARED',
148
- ]);
149
-
150
- /**
151
- * The build's own finding for a route it could not weigh, when that failure is an instruction.
152
- * Read off the static report's `unmeasured` list — the same list `X_BUDGET_UNMEASURED`'s `fix:`
153
- * sends its reader to, now read by the step itself for the one code it can act on.
154
- */
155
- function ownCodeFinding(url: string, unmeasured: readonly UnmeasuredRoute[]): Finding | undefined {
156
- const entry = unmeasured.find((one) => one.path === url);
157
- if (entry?.code === undefined || !REPORTED_BY_OWN_CODE.has(entry.code)) return undefined;
158
- return {
159
- code: entry.code,
160
- cause: entry.cause ?? entry.reason,
161
- fix: entry.fix ?? `x build --target static --json # its "unmeasured" list has ${url}`,
145
+ // the one caller of `writeBuildStats`.
146
+ fix: 'x build --target static --json && x verify --json',
162
147
  docs: ERROR_DOCS_URL,
163
148
  at: url,
164
149
  };
@@ -172,7 +157,7 @@ function ownCodeFinding(url: string, unmeasured: readonly UnmeasuredRoute[]): Fi
172
157
  * `unmeasured` is the static report's list of routes the build rendered and could not weigh, with
173
158
  * each failure's code when it had one. Optional because the report is written beside the stats
174
159
  * and can be absent for the same reason; with it, a route whose measurement failed on a code in
175
- * `REPORTED_BY_OWN_CODE` is reported under that code, with the build's own cause and fix.
160
+ * `REPORTED_BY_OWN_CODE` (`budgets-unmeasured.ts`) is reported under that code, with the build's own cause and fix.
176
161
  *
177
162
  * `fileOf` names the file an `X_BUDGET_EXCEEDED` fix edits: the step runs after the app loaded,
178
163
  * so render's route table already holds it. A test hands its own.
@@ -196,20 +181,19 @@ export function checkBudgets(
196
181
  const js = jsBudgetOf(route);
197
182
  if (measured === undefined) {
198
183
  if (js !== null) {
199
- findings.push(
200
- ownCodeFinding(route.url, unmeasured) ??
201
- unmeasuredFinding(route.url, 'JS', stats !== undefined, stats?.stale === true),
202
- );
184
+ findings.push(unmeasuredFinding(route.url, 'JS', stats, unmeasured));
203
185
  }
204
186
  continue;
205
187
  }
206
188
  if (js !== null && measured.jsBytes > js) {
189
+ const meta = chargedMeta(measured.jsBytes, measured.charged, measured.inlineJsBytes);
207
190
  findings.push({
208
191
  code: 'X_BUDGET_EXCEEDED',
209
- cause: `${route.url} ships ${formatBytes(measured.jsBytes)} of JS (minified, uncompressed) over a ${formatBytes(js)} budget via ${chainOf(measured)}`,
192
+ cause: `${route.url} ships ${formatBytes(measured.jsBytes)} of JS (minified, uncompressed) over a ${formatBytes(js)} budget via ${chainOf(measured)}${chargedClause(measured.charged, measured.inlineJsBytes)}`,
210
193
  fix: exceededJsFix(route.url, fileOf(route.url), measured.jsBytes),
211
194
  docs: ERROR_DOCS_URL,
212
195
  at: route.url,
196
+ ...(meta === undefined ? {} : { meta }),
213
197
  });
214
198
  }
215
199
  }
@@ -224,59 +208,29 @@ export async function readBuildStats(root: string): Promise<BuildStats | undefin
224
208
  return read.measuredBy === BUILD_STATS_RULES ? read : { routes: [], stale: true };
225
209
  }
226
210
 
227
- const SCRIPT_TAG = /<script(?<attrs>[^>]*)>(?<body>[\s\S]*?)<\/script>/g;
228
- const SRC_ATTR = /\ssrc="(?<src>[^"]*)"/;
229
- const TYPE_ATTR = /\stype="(?<type>[^"]*)"/;
230
-
231
- /**
232
- * `application/ld+json`, `application/json`, any `…+json`: the body is data, not code — the rule
233
- * `@ultimat3/render`'s `head.ts` already states, restated because its `carriesJson` reads a
234
- * `HeadTag` and is not exported, and this side has an attribute string off the emitted document.
235
- * Without it a page shipping only `meta.ld` structured data and island props measured 8kb of JS
236
- * and failed a 2kb budget with a `fix:` naming an import chain that does not exist.
237
- */
238
- const carriesJson = (attrs: string): boolean => {
239
- // Everything from the first `;` is a MIME PARAMETER and not the type: a real document writes
240
- // `type="application/ld+json; charset=utf-8"`, which does not END with `json`, so the suffix
241
- // test alone charged an SEO structured-data block as executable JavaScript all over again.
242
- const [type = ''] = (TYPE_ATTR.exec(attrs)?.groups?.['type'] ?? '').split(';');
243
- const declared = type.trim().toLowerCase();
244
- // Speculation rules are JSON under a type that does not say so: read by the browser's
245
- // prefetcher, never executed — the one script a 0kb page may carry for free.
246
- return declared.endsWith('json') || declared === SPECULATION_RULES_TYPE;
247
- };
248
-
249
- /**
250
- * An island's chunk is reached by `import()` from inside the hydration runtime, so it never appears
251
- * as a `<script src>` — and a document weighed by script tags alone was charged for the runtime and
252
- * never for the code that runtime boots. The entry attribute is that module URL, so it is read as
253
- * exactly what it is: a file the browser will execute.
254
- */
255
- const ENTRY_ATTR = /\sdata-x-entry="(?<url>[^"]*)"/g;
256
-
257
- /**
258
- * Static imports and `import()` both. A chunk reached by `import()` was INLINED into its island
259
- * before islands were split, so charging it keeps a route's number what it was; and it is fetched
260
- * the moment the island needs it, which is before the page does what it was loaded for.
261
- */
262
- const LOADS: ReadonlySet<string> = new Set(['import-statement', 'dynamic-import']);
211
+ /** What `measureDocumentJs` needs beyond the document and the artifact — both for served pages. */
212
+ export interface MeasureOptions {
213
+ /**
214
+ * Scripts a document names that the PROCESS serves and the static artifact does not carry, by
215
+ * URL: the page boot (`/_x/page-boot/<id>.js`). Charged like any `<script src>` — ledger #28.
216
+ */
217
+ readonly served?: ReadonlyMap<string, string>;
218
+ /**
219
+ * The page boot's path prefix and the runtime chunks it supplies (#505): on a document carrying
220
+ * the boot an island awaits the runtime the boot installs and never imports the chunk, so the
221
+ * chunk is not charged there. A document without the boot is charged for it as before.
222
+ */
223
+ readonly boot?: { readonly prefix: string; readonly supplies: ReadonlySet<string> };
224
+ }
263
225
 
264
- /** Same-origin paths `code`, served at `url`, makes the browser load. Cross-origin and bare are not this build's. */
265
- function loadedBy(url: string, code: string): readonly string[] {
266
- let imports: readonly { readonly path: string; readonly kind: string }[];
267
- try {
268
- imports = new Bun.Transpiler({ loader: 'js' }).scanImports(code);
269
- } catch {
270
- // A file that does not parse as JavaScript imports nothing this gate can name; its own bytes
271
- // are already charged.
272
- return [];
226
+ /** Whether a `<script src>` in `html` is the page boot `boot.prefix` names. */
227
+ function carriesBoot(html: string, boot: MeasureOptions['boot']): boolean {
228
+ if (boot === undefined) return false;
229
+ for (const match of html.matchAll(SCRIPT_TAG)) {
230
+ const src = SRC_ATTR.exec(match.groups?.['attrs'] ?? '')?.groups?.['src'];
231
+ if (src !== undefined && artifactPath(src)?.startsWith(boot.prefix) === true) return true;
273
232
  }
274
- const base = new URL(url, 'https://artifact.invalid');
275
- return imports
276
- .filter((one) => LOADS.has(one.kind) && /^\.{0,2}\//.test(one.path))
277
- .map((one) => new URL(one.path, base))
278
- .filter((target) => target.origin === base.origin)
279
- .map((target) => target.pathname);
233
+ return false;
280
234
  }
281
235
 
282
236
  /** One executable module the document names, and what it weighs on disk. */
@@ -290,6 +244,8 @@ export interface MeasuredJs {
290
244
  readonly jsBytes: number;
291
245
  /** The framework's injected runtime, counted separately so it is reported and never charged. */
292
246
  readonly frameworkBytes: number;
247
+ /** The app's inline script bytes — the part of `jsBytes` that is not a file in `entries`. */
248
+ readonly inlineBytes: number;
293
249
  /** Every APP `src=`/`data-x-entry=` module, so a finding can name the heaviest by file. */
294
250
  readonly entries: readonly MeasuredEntry[];
295
251
  }
@@ -341,9 +297,14 @@ export const FRAMEWORK_INLINE_SCRIPTS: ReadonlySet<string> = new Set(
341
297
  * from the emitted HTML rather than from the declared graph, because the graph is what a route
342
298
  * *says* it ships and this gate exists to catch the case where those two disagree.
343
299
  */
344
- export async function measureDocumentJs(html: string, out: string): Promise<MeasuredJs> {
300
+ export async function measureDocumentJs(
301
+ html: string,
302
+ out: string,
303
+ options: MeasureOptions = {},
304
+ ): Promise<MeasuredJs> {
345
305
  let jsBytes = 0;
346
306
  let frameworkBytes = 0;
307
+ let inlineBytes = 0;
347
308
  const entries: MeasuredEntry[] = [];
348
309
  // Deduped ONCE, across both readers below, and the unit is the FETCH: a browser downloads a URL
349
310
  // once however many times the document names it, so `budget.js` — a byte budget — counts it
@@ -359,13 +320,21 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
359
320
  // is not in this set for the same reason: two identical inline bodies are two copies of the
360
321
  // bytes in the document, so both are charged.
361
322
  const fetched = new Set<string>();
362
- const weigh = async (url: string): Promise<void> => {
323
+ const served = options.served ?? new Map<string, string>();
324
+ // The runtime chunks this document's page boot makes unfetched: `awaitPageRuntime`'s rule, read
325
+ // the way it reads it — a `<script src>` under the boot's path is in the document.
326
+ const bootSupplied = carriesBoot(html, options.boot) ? (options.boot?.supplies ?? null) : null;
327
+ const weigh = async (named: string): Promise<void> => {
363
328
  // Only a path inside the artifact can be weighed; a cross-origin script is not this build's.
364
- if (!url.startsWith('/') || fetched.has(url)) return;
329
+ const url = artifactPath(named);
330
+ if (url === undefined || fetched.has(url)) return;
365
331
  fetched.add(url);
332
+ // A script the process serves and the artifact does not carry (the page boot) is weighed from
333
+ // the bytes the build holds; everything else from the file the artifact will serve.
334
+ const held = served.get(url);
366
335
  const file = Bun.file(join(out, url.slice(1)));
367
- const exists = await file.exists();
368
- const bytes = exists ? file.size : 0;
336
+ const exists = held !== undefined || (await file.exists());
337
+ const bytes = held === undefined ? (exists ? file.size : 0) : Buffer.byteLength(held, 'utf8');
369
338
  // Counted and set aside, not skipped: the bytes are real and a reader is owed the number.
370
339
  // Kept out of `entries` as well as out of `jsBytes`, because `entries` is what a finding reads
371
340
  // to name the heaviest import — and on a fresh scaffold every route's `heaviestChain` was
@@ -380,7 +349,9 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
380
349
  // and each is a fetch the browser makes before the island is whole. Through `weigh`, so a chunk
381
350
  // two islands share is charged once and a cycle ends at the first repeat.
382
351
  if (exists) {
383
- for (const next of loadedBy(url, await file.text())) await weigh(next);
352
+ for (const next of loadedBy(url, held ?? (await file.text()))) {
353
+ if (bootSupplied?.has(next) !== true) await weigh(next);
354
+ }
384
355
  }
385
356
  };
386
357
 
@@ -391,8 +362,12 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
391
362
  if (src === undefined) {
392
363
  const body = match.groups?.['body'] ?? '';
393
364
  const bytes = Buffer.byteLength(body, 'utf8');
394
- if (FRAMEWORK_INLINE_SCRIPTS.has(body)) frameworkBytes += bytes;
395
- else jsBytes += bytes;
365
+ if (FRAMEWORK_INLINE_SCRIPTS.has(body)) {
366
+ frameworkBytes += bytes;
367
+ } else {
368
+ jsBytes += bytes;
369
+ inlineBytes += bytes;
370
+ }
396
371
  continue;
397
372
  }
398
373
  await weigh(src);
@@ -402,7 +377,26 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
402
377
  if (url === undefined) continue;
403
378
  await weigh(url);
404
379
  }
405
- return { jsBytes, frameworkBytes, entries };
380
+ return { jsBytes, frameworkBytes, inlineBytes, entries };
381
+ }
382
+
383
+ /**
384
+ * The stats row for one measured document — the ONE place a row is assembled, so the static and
385
+ * the weigh-only branch of the build cannot record different facts about the same kind of page.
386
+ */
387
+ export function routeStatsRow(
388
+ path: string,
389
+ measured: MeasuredJs,
390
+ chain: readonly string[] | undefined,
391
+ ): RouteStats {
392
+ return {
393
+ path,
394
+ jsBytes: measured.jsBytes,
395
+ frameworkJsBytes: measured.frameworkBytes,
396
+ ...(chain === undefined ? {} : { heaviestChain: chain }),
397
+ charged: heaviestFirst(measured.entries),
398
+ inlineJsBytes: measured.inlineBytes,
399
+ };
406
400
  }
407
401
 
408
402
  /**
@@ -122,10 +122,22 @@ export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
122
122
  detail: 'the browser opened a page and answered no target or session id',
123
123
  });
124
124
  }
125
+ // The BROWSER's own session, for the allow list on every target the page opens — a popup
126
+ // is a new target the page session's `Fetch` never pauses. A browser that refuses the
127
+ // attach refuses the session: a rule that would hold on one tab only is not a rule.
128
+ const browserAttached = await connection.send('Target.attachToBrowserTarget');
129
+ const browserSessionId = field(browserAttached.result, 'sessionId');
130
+ if (browserSessionId === undefined) {
131
+ throw new CdpCallFailedError({
132
+ method: 'Target.attachToBrowserTarget',
133
+ detail: 'the browser answered no session id, so a popup could not be screened',
134
+ });
135
+ }
125
136
  // Watching BEFORE the domains are on, so the first request is already counted.
126
137
  const watch = watchPage({
127
138
  connection,
128
139
  sessionId,
140
+ browserSessionId,
129
141
  clock: init.clock,
130
142
  allowHosts: init.rules.allowHosts,
131
143
  });
@@ -136,6 +148,11 @@ export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
136
148
  await on('Page.enable');
137
149
  await on('Network.enable');
138
150
  await on('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
151
+ await connection.send(
152
+ 'Fetch.enable',
153
+ { patterns: [{ urlPattern: '*', requestStage: 'Request' }] },
154
+ browserSessionId,
155
+ );
139
156
  // Before the first navigation, like the metrics below: a header set after `goto` would
140
157
  // photograph a document negotiated in the machine's own language.
141
158
  if (init.headers !== undefined && Object.keys(init.headers).length > 0) {
@@ -2,6 +2,7 @@
2
2
  // tail with a count of what the bound threw away. And the allow list, enforced in the browser:
3
3
  // every request is paused (`Fetch.requestPaused`) and refused unless its host is allowed, so an
4
4
  // injected `<img src="http://169.254.169.254/…">` never leaves — it is recorded as `refused`.
5
+ // Paused at the BROWSER too, so a popup the page opens is decided by the same `hostDecision`.
5
6
  import { hostDecision } from '@ultimat3/core';
6
7
  import type { CdpConnection } from '@ultimat3/testing';
7
8
  import type {
@@ -123,14 +124,22 @@ export interface PageWatch {
123
124
  * Subscribe BEFORE the domains are enabled (the caller's order), so the first request of the first
124
125
  * navigation is already counted. Only events on `sessionId` are read: a remote browser carries
125
126
  * other people's pages.
127
+ *
128
+ * `browserSessionId` is the browser target's session, whose `Fetch` pauses what page-level
129
+ * interception never sees: a `target=_blank` link or a `window.open` is a NEW target, and on the
130
+ * page session alone it reached an off-list host unrefused. Its pauses are decided by the same
131
+ * `hostDecision` and answered on the session they arrived on. A page request is decided at the
132
+ * page first — an allowed one is continued again here, a refused one never arrives — so each
133
+ * refusal is one ring entry.
126
134
  */
127
135
  export function watchPage(input: {
128
136
  readonly connection: CdpConnection;
129
137
  readonly sessionId: string;
138
+ readonly browserSessionId?: string | undefined;
130
139
  readonly clock: ShotClock;
131
140
  readonly allowHosts: readonly string[];
132
141
  }): PageWatch {
133
- const { connection, sessionId } = input;
142
+ const { connection, sessionId, browserSessionId } = input;
134
143
  const lines = new Tail<ConsoleLine>();
135
144
  const errors = new Tail<PageError>();
136
145
  const requests = new Tail<NetworkEntry>();
@@ -191,38 +200,33 @@ export function watchPage(input: {
191
200
  requests.update(id, { ...held, status });
192
201
  }),
193
202
  ),
194
- connection.on(
195
- 'Fetch.requestPaused',
196
- mine((params) => {
197
- const pausedId = text(params['requestId']) ?? '';
198
- const request = record(params['request']);
199
- const url = text(request?.['url']) ?? '';
200
- if (hostDecision(url, input.allowHosts).allowed) {
201
- void connection
202
- .send('Fetch.continueRequest', { requestId: pausedId }, sessionId)
203
- .catch(() => undefined);
204
- return;
205
- }
206
- const id = text(params['networkId']) ?? pausedId;
207
- const held = requests.get(id);
208
- const refused: NetworkEntry = {
209
- method: text(request?.['method']) ?? held?.method ?? 'GET',
210
- url,
211
- resourceType: resourceType(params['resourceType']),
212
- at: held?.at ?? now(),
213
- refused: 'host',
214
- };
215
- if (held === undefined) requests.put(refused, id);
216
- else requests.update(id, refused);
203
+ connection.on('Fetch.requestPaused', (params, on) => {
204
+ // The page's session or the browser's: anything else is somebody else's interception.
205
+ if (on === undefined || (on !== sessionId && on !== browserSessionId)) return;
206
+ const pausedId = text(params['requestId']) ?? '';
207
+ const request = record(params['request']);
208
+ const url = text(request?.['url']) ?? '';
209
+ if (hostDecision(url, input.allowHosts).allowed) {
217
210
  void connection
218
- .send(
219
- 'Fetch.failRequest',
220
- { requestId: pausedId, errorReason: 'BlockedByClient' },
221
- sessionId,
222
- )
211
+ .send('Fetch.continueRequest', { requestId: pausedId }, on)
223
212
  .catch(() => undefined);
224
- }),
225
- ),
213
+ return;
214
+ }
215
+ const id = text(params['networkId']) ?? pausedId;
216
+ const held = requests.get(id);
217
+ const refused: NetworkEntry = {
218
+ method: text(request?.['method']) ?? held?.method ?? 'GET',
219
+ url,
220
+ resourceType: resourceType(params['resourceType']),
221
+ at: held?.at ?? now(),
222
+ refused: 'host',
223
+ };
224
+ if (held === undefined) requests.put(refused, id);
225
+ else requests.update(id, refused);
226
+ void connection
227
+ .send('Fetch.failRequest', { requestId: pausedId, errorReason: 'BlockedByClient' }, on)
228
+ .catch(() => undefined);
229
+ }),
226
230
  ];
227
231
 
228
232
  let stopped = false;
package/src/ci-log.ts CHANGED
Binary file
package/src/cmd-auth.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // running it again.
6
6
 
7
7
  import type { MfaSecretStore } from '@ultimat3/auth';
8
- import { BuiltinAdapter, sealMfaSecrets } from '@ultimat3/auth';
8
+ import { postgresAuthAdapter, sealMfaSecrets } from '@ultimat3/auth';
9
9
  import { requireAppRoot } from './app-root';
10
10
  import { authSpec } from './cmd-auth-spec';
11
11
  import type { CliCommand, CommandContext } from './command';
@@ -26,7 +26,7 @@ export interface OpenAuthStore extends MfaSecretStore {
26
26
  */
27
27
  async function openAppAuthStore(root: string, ctx: CommandContext): Promise<OpenAuthStore> {
28
28
  const queue = await startQueue(resolveServices(root, ctx.env), undefined, ctx.env);
29
- return { adapter: new BuiltinAdapter(queue.db), close: () => queue.stop() };
29
+ return { adapter: postgresAuthAdapter(queue.db), close: () => queue.stop() };
30
30
  }
31
31
 
32
32
  /** `open` is the seam a test supplies a store through; the shipped command opens the app's. */
@@ -8,7 +8,7 @@ export const buildSpec: CommandSpec = {
8
8
  summary:
9
9
  'build a container image, a single binary, a prerendered static site, or the store an image boots from',
10
10
  usage:
11
- 'x build --target docker|binary|static|prebuilt [--tag name] [--out path] [--no-preflight] [--json]',
11
+ 'x build --target docker|binary|static|prebuilt [--tag name] [--out path] [--platform bun-target] [--no-preflight] [--json]',
12
12
  requiresApp: true,
13
13
  flags: [
14
14
  {
@@ -20,6 +20,12 @@ export const buildSpec: CommandSpec = {
20
20
  },
21
21
  { name: 'tag', type: 'string', summary: 'image tag (docker target)' },
22
22
  { name: 'out', type: 'string', summary: 'output path (binary and static targets)' },
23
+ {
24
+ name: 'platform',
25
+ type: 'string',
26
+ summary:
27
+ "binary target only: bun-linux-x64 | bun-linux-arm64 | bun-windows-x64 | bun-darwin-arm64 — Bun's --target; a Windows build is written as <out>.exe",
28
+ },
23
29
  {
24
30
  name: 'preflight',
25
31
  type: 'boolean',