@ultimat3/cli 24.0.0 → 25.0.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 +15 -8
- package/README.md +30 -5
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +3 -1
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +3 -3
- package/src/app-env.ts +21 -14
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +2 -2
- package/src/app-mcp.ts +25 -27
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +2 -8
- package/src/app-permissions-site.ts +31 -7
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +115 -121
- package/src/cdp-shot-driver.ts +17 -0
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth.ts +2 -2
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +89 -9
- package/src/cmd-db-backfill.ts +1 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +5 -2
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -357
- package/src/cmd-doctor.ts +16 -66
- package/src/cmd-env.ts +29 -14
- package/src/cmd-generate.ts +12 -2
- package/src/cmd-i18n.ts +11 -8
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +18 -137
- package/src/cmd-mcp.ts +25 -7
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +9 -0
- package/src/cmd-routes.ts +3 -3
- package/src/cmd-shot-matrix.ts +21 -4
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +35 -39
- package/src/cmd-test.ts +1 -1
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +19 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +1 -1
- package/src/dev-dashboard.ts +5 -1
- package/src/dev-lock.ts +96 -24
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +14 -7
- package/src/doctor-auth.ts +4 -4
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +2 -2
- package/src/drift.ts +29 -6
- package/src/error-codes.ts +6 -0
- package/src/error-page-csp.ts +14 -15
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema.ts +13 -2
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-kinds.ts +3 -17
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +50 -1
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +21 -8
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +54 -64
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +32 -16
- package/src/local-cli-handoff.ts +90 -0
- package/src/mcp-errors.ts +8 -1
- package/src/mcp-host.ts +6 -5
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +27 -16
- package/src/metrics-endpoint.ts +3 -2
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +18 -0
- package/src/page-speculation.ts +14 -11
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +63 -98
- package/src/pwa-artifacts.ts +8 -1
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/retry-memo.ts +1 -1
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +110 -31
- package/src/role-start.ts +64 -85
- package/src/role-sync.ts +65 -6
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +15 -2
- package/src/runtime-bindings.ts +23 -3
- package/src/runtime-cache.ts +8 -8
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +2 -2
- package/src/runtime-jobs.ts +78 -18
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +2 -2
- package/src/runtime-overrides.ts +3 -3
- package/src/runtime-purge.ts +1 -1
- package/src/runtime-queue.ts +12 -12
- package/src/runtime-realtime.ts +5 -3
- package/src/runtime-render.ts +77 -82
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +33 -13
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +3 -3
- package/src/schema-diff.ts +1 -1
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +2 -2
- package/src/serve-boot.ts +22 -11
- package/src/serve-drain.ts +3 -1
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +19 -8
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -15
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-seo.ts +6 -4
- package/src/source-files.ts +13 -3
- package/src/static-report.ts +5 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +1 -1
- package/src/templates/action.ts +4 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +23 -8
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +2 -1
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container.ts +12 -6
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-templates.ts +74 -18
- package/src/templates/scaffold-helm.ts +31 -4
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +18 -15
- package/src/templates/scaffold-shell.ts +17 -2
- package/src/test-only-shipped.ts +193 -0
- package/src/test-select.ts +2 -1
- package/src/test-shards.ts +1 -4
- package/src/theme-boot.ts +2 -2
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/verify-checks.ts +12 -2
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +87 -10
- package/src/verify-merge.ts +48 -16
- package/src/verify-part-step.ts +10 -2
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +3 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +0 -1
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/jobs-drain.ts +0 -230
- package/src/scaffold-typecheck.ts +0 -227
- 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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
105
|
-
|
|
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:
|
|
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`.
|
|
122
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
/**
|
|
265
|
-
function
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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()))
|
|
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))
|
|
395
|
-
|
|
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
|
/**
|
package/src/cdp-shot-driver.ts
CHANGED
|
@@ -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) {
|
package/src/cdp-shot-watch.ts
CHANGED
|
@@ -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
|
-
'
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 {
|
|
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:
|
|
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. */
|
package/src/cmd-build-spec.ts
CHANGED
|
@@ -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',
|