@ultimat3/cli 23.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 +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- 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 +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- 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 +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- 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 +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- 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-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- 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 +2 -6
- package/src/island-bundle.ts +57 -79
- 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 +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- 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 +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- 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-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- 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 +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- 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 -33
- 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-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -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 +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -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/naming.ts +28 -0
- 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 +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- 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-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- 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-client.ts +4 -2
- 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-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- 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 +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- 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/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
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';
|
|
@@ -39,17 +44,15 @@ export interface RouteStats {
|
|
|
39
44
|
* older build simply did not count it, and `checkBudgets` reads `jsBytes` either way.
|
|
40
45
|
*/
|
|
41
46
|
readonly frameworkJsBytes?: number;
|
|
42
|
-
/**
|
|
43
|
-
* **Written by nothing, `As of 2026-08`.** `apps/web/prerender.ts` is the only producer of this
|
|
44
|
-
* file and it emits static HTML — there is no browser in the build to observe a paint. So the
|
|
45
|
-
* comparison in `checkBudgets` below is reachable only for an app that writes its own stats, and
|
|
46
|
-
* `x new` no longer scaffolds an `lcp` budget for exactly that reason: a budget the build cannot
|
|
47
|
-
* weigh passes silently the moment a stats row exists, which is the false green this file's
|
|
48
|
-
* header is about. `RouteBudget.lcp` still accepts one — that key is `@ultimat3/render`'s.
|
|
49
|
-
*/
|
|
50
|
-
readonly lcpMs?: number;
|
|
51
47
|
/** Import chain that pulled the heaviest module into this route. */
|
|
52
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;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
export interface BuildStats {
|
|
@@ -67,8 +70,12 @@ export interface BuildStats {
|
|
|
67
70
|
* `examples/dummy` was charged 250 B for `/x-sw-register.js` by a file written before
|
|
68
71
|
* `FRAMEWORK_SCRIPTS` exempted it. `2`: that exemption and the page-boot decision (ledger #28).
|
|
69
72
|
* `3`: an island entry's imported chunks are charged, each once per document (shared chunks).
|
|
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).
|
|
70
77
|
*/
|
|
71
|
-
export const BUILD_STATS_RULES =
|
|
78
|
+
export const BUILD_STATS_RULES = 5;
|
|
72
79
|
|
|
73
80
|
const chainOf = (stats: RouteStats): string =>
|
|
74
81
|
stats.heaviestChain === undefined ? 'unknown import chain' : stats.heaviestChain.join(' -> ');
|
|
@@ -79,7 +86,7 @@ const jsBudgetOf = (route: RouteFact): number | null => parseByteBudget(route.bu
|
|
|
79
86
|
export type RouteFileOf = (url: string) => string | undefined;
|
|
80
87
|
|
|
81
88
|
const declaredFileOf: RouteFileOf = (url) =>
|
|
82
|
-
|
|
89
|
+
describePages().find((route) => route.path === url && route.mount === undefined)?.file;
|
|
83
90
|
|
|
84
91
|
const KB = 1024;
|
|
85
92
|
|
|
@@ -94,14 +101,6 @@ function exceededJsFix(url: string, file: string | undefined, bytes: number): st
|
|
|
94
101
|
return `edit ${where} — set budget: { js: '${String(kb)}kb' } with // measured: ${String(bytes)} B (x build --target static) — why: <the function it buys> directly above it; or x routes --json for the chain and move the heavy import behind hydrate: 'interaction'`;
|
|
95
102
|
}
|
|
96
103
|
|
|
97
|
-
/** Which budgets the route declared, for a cause line that names what went unmeasured. */
|
|
98
|
-
function declaredBudgets(js: number | null, lcp: number | undefined): string {
|
|
99
|
-
const labels: string[] = [];
|
|
100
|
-
if (js !== null) labels.push('JS');
|
|
101
|
-
if (lcp !== undefined) labels.push('LCP');
|
|
102
|
-
return labels.join(' and ');
|
|
103
|
-
}
|
|
104
|
-
|
|
105
104
|
/**
|
|
106
105
|
* Compare declared budgets against measured stats. A declared budget with no measurement is a
|
|
107
106
|
* finding, never a pass: a route that clears the gate without ever being weighed is exactly the
|
|
@@ -117,8 +116,13 @@ function declaredBudgets(js: number | null, lcp: number | undefined): string {
|
|
|
117
116
|
* second build's. Reporting the first as the second is what sends a reader to re-run a build that
|
|
118
117
|
* already did everything it was going to do.
|
|
119
118
|
*/
|
|
120
|
-
function unmeasuredFinding(
|
|
121
|
-
|
|
119
|
+
function unmeasuredFinding(
|
|
120
|
+
url: string,
|
|
121
|
+
declared: string,
|
|
122
|
+
stats: BuildStats | undefined,
|
|
123
|
+
unmeasured: readonly UnmeasuredRoute[],
|
|
124
|
+
): Finding {
|
|
125
|
+
if (stats?.stale === true) {
|
|
122
126
|
return {
|
|
123
127
|
code: 'X_BUDGET_UNMEASURED',
|
|
124
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`,
|
|
@@ -127,54 +131,19 @@ function unmeasuredFinding(url: string, declared: string, built: boolean, stale
|
|
|
127
131
|
at: url,
|
|
128
132
|
};
|
|
129
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
|
+
}
|
|
130
140
|
return {
|
|
131
141
|
code: 'X_BUDGET_UNMEASURED',
|
|
132
|
-
cause:
|
|
133
|
-
? `${url} declares a ${declared} budget and ${BUILD_STATS_FILE} has no row for it, so the build ran and could not weigh it`
|
|
134
|
-
: `${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`,
|
|
135
143
|
// `--target static` is load-bearing and `x build` alone was a fix that changes nothing: the
|
|
136
144
|
// flag defaults to `docker`, and only the static target runs `apps/web/prerender.ts`, which is
|
|
137
|
-
// the one caller of `writeBuildStats`.
|
|
138
|
-
|
|
139
|
-
fix: built
|
|
140
|
-
? `x build --target static --json # its "unmeasured" list says why ${url} could not be weighed`
|
|
141
|
-
: 'x build --target static --json && x verify --json',
|
|
142
|
-
docs: ERROR_DOCS_URL,
|
|
143
|
-
at: url,
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* The ONE code a failed measurement is reported under by its own name rather than as
|
|
149
|
-
* `X_BUDGET_UNMEASURED`. Its cause is complete — the island, the prop, its bytes, the cap — and
|
|
150
|
-
* its fix is an edit to the page, so the step's own "run x build and read the list" would put a
|
|
151
|
-
* second command between the author and a sentence the build had already composed. Every other
|
|
152
|
-
* render failure keeps the generic finding: a `TypeError` from a `load` that wanted a request is
|
|
153
|
-
* a reason to read the report, not an instruction.
|
|
154
|
-
*
|
|
155
|
-
* ONE code and not "any coded error", deliberately. `X_NO_CONTEXT`, `X_UNAUTHENTICATED` and
|
|
156
|
-
* `X_DB_UNAVAILABLE` from a measurement render are facts about the BUILD's environment, and
|
|
157
|
-
* reporting them under their own codes would tell the author to fix a database the gate never
|
|
158
|
-
* had. The list grows by a decision, per code, here.
|
|
159
|
-
*/
|
|
160
|
-
const REPORTED_BY_OWN_CODE: ReadonlySet<string> = new Set([
|
|
161
|
-
'X_ISLAND_PROPS_INVALID',
|
|
162
|
-
// A dynamic route that lists no `prerender()` path: the edit is in the route, not the build.
|
|
163
|
-
'X_BUDGET_PARAMS_UNDECLARED',
|
|
164
|
-
]);
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* The build's own finding for a route it could not weigh, when that failure is an instruction.
|
|
168
|
-
* Read off the static report's `unmeasured` list — the same list `X_BUDGET_UNMEASURED`'s `fix:`
|
|
169
|
-
* sends its reader to, now read by the step itself for the one code it can act on.
|
|
170
|
-
*/
|
|
171
|
-
function ownCodeFinding(url: string, unmeasured: readonly UnmeasuredRoute[]): Finding | undefined {
|
|
172
|
-
const entry = unmeasured.find((one) => one.path === url);
|
|
173
|
-
if (entry?.code === undefined || !REPORTED_BY_OWN_CODE.has(entry.code)) return undefined;
|
|
174
|
-
return {
|
|
175
|
-
code: entry.code,
|
|
176
|
-
cause: entry.cause ?? entry.reason,
|
|
177
|
-
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',
|
|
178
147
|
docs: ERROR_DOCS_URL,
|
|
179
148
|
at: url,
|
|
180
149
|
};
|
|
@@ -188,7 +157,7 @@ function ownCodeFinding(url: string, unmeasured: readonly UnmeasuredRoute[]): Fi
|
|
|
188
157
|
* `unmeasured` is the static report's list of routes the build rendered and could not weigh, with
|
|
189
158
|
* each failure's code when it had one. Optional because the report is written beside the stats
|
|
190
159
|
* and can be absent for the same reason; with it, a route whose measurement failed on a code in
|
|
191
|
-
* `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.
|
|
192
161
|
*
|
|
193
162
|
* `fileOf` names the file an `X_BUDGET_EXCEEDED` fix edits: the step runs after the app loaded,
|
|
194
163
|
* so render's route table already holds it. A test hands its own.
|
|
@@ -210,37 +179,21 @@ export function checkBudgets(
|
|
|
210
179
|
if (unweighable.has(route.url)) continue;
|
|
211
180
|
const measured = byPath.get(route.url);
|
|
212
181
|
const js = jsBudgetOf(route);
|
|
213
|
-
const lcp = route.budget?.lcp;
|
|
214
182
|
if (measured === undefined) {
|
|
215
|
-
if (js !== null
|
|
216
|
-
findings.push(
|
|
217
|
-
ownCodeFinding(route.url, unmeasured) ??
|
|
218
|
-
unmeasuredFinding(
|
|
219
|
-
route.url,
|
|
220
|
-
declaredBudgets(js, lcp),
|
|
221
|
-
stats !== undefined,
|
|
222
|
-
stats?.stale === true,
|
|
223
|
-
),
|
|
224
|
-
);
|
|
183
|
+
if (js !== null) {
|
|
184
|
+
findings.push(unmeasuredFinding(route.url, 'JS', stats, unmeasured));
|
|
225
185
|
}
|
|
226
186
|
continue;
|
|
227
187
|
}
|
|
228
188
|
if (js !== null && measured.jsBytes > js) {
|
|
189
|
+
const meta = chargedMeta(measured.jsBytes, measured.charged, measured.inlineJsBytes);
|
|
229
190
|
findings.push({
|
|
230
191
|
code: 'X_BUDGET_EXCEEDED',
|
|
231
|
-
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)}`,
|
|
232
193
|
fix: exceededJsFix(route.url, fileOf(route.url), measured.jsBytes),
|
|
233
194
|
docs: ERROR_DOCS_URL,
|
|
234
195
|
at: route.url,
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
if (lcp !== undefined && measured.lcpMs !== undefined && measured.lcpMs > lcp) {
|
|
238
|
-
findings.push({
|
|
239
|
-
code: 'X_BUDGET_EXCEEDED',
|
|
240
|
-
cause: `${route.url} LCP ${measured.lcpMs}ms over the ${lcp}ms budget`,
|
|
241
|
-
fix: `raise the budget in defineRoute, or switch render to 'isr' to serve it prebuilt`,
|
|
242
|
-
docs: ERROR_DOCS_URL,
|
|
243
|
-
at: route.url,
|
|
196
|
+
...(meta === undefined ? {} : { meta }),
|
|
244
197
|
});
|
|
245
198
|
}
|
|
246
199
|
}
|
|
@@ -255,59 +208,29 @@ export async function readBuildStats(root: string): Promise<BuildStats | undefin
|
|
|
255
208
|
return read.measuredBy === BUILD_STATS_RULES ? read : { routes: [], stale: true };
|
|
256
209
|
}
|
|
257
210
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
// test alone charged an SEO structured-data block as executable JavaScript all over again.
|
|
273
|
-
const [type = ''] = (TYPE_ATTR.exec(attrs)?.groups?.['type'] ?? '').split(';');
|
|
274
|
-
const declared = type.trim().toLowerCase();
|
|
275
|
-
// Speculation rules are JSON under a type that does not say so: read by the browser's
|
|
276
|
-
// prefetcher, never executed — the one script a 0kb page may carry for free.
|
|
277
|
-
return declared.endsWith('json') || declared === SPECULATION_RULES_TYPE;
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* An island's chunk is reached by `import()` from inside the hydration runtime, so it never appears
|
|
282
|
-
* as a `<script src>` — and a document weighed by script tags alone was charged for the runtime and
|
|
283
|
-
* never for the code that runtime boots. The entry attribute is that module URL, so it is read as
|
|
284
|
-
* exactly what it is: a file the browser will execute.
|
|
285
|
-
*/
|
|
286
|
-
const ENTRY_ATTR = /\sdata-x-entry="(?<url>[^"]*)"/g;
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Static imports and `import()` both. A chunk reached by `import()` was INLINED into its island
|
|
290
|
-
* before islands were split, so charging it keeps a route's number what it was; and it is fetched
|
|
291
|
-
* the moment the island needs it, which is before the page does what it was loaded for.
|
|
292
|
-
*/
|
|
293
|
-
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
|
+
}
|
|
294
225
|
|
|
295
|
-
/**
|
|
296
|
-
function
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
// A file that does not parse as JavaScript imports nothing this gate can name; its own bytes
|
|
302
|
-
// are already charged.
|
|
303
|
-
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;
|
|
304
232
|
}
|
|
305
|
-
|
|
306
|
-
return imports
|
|
307
|
-
.filter((one) => LOADS.has(one.kind) && /^\.{0,2}\//.test(one.path))
|
|
308
|
-
.map((one) => new URL(one.path, base))
|
|
309
|
-
.filter((target) => target.origin === base.origin)
|
|
310
|
-
.map((target) => target.pathname);
|
|
233
|
+
return false;
|
|
311
234
|
}
|
|
312
235
|
|
|
313
236
|
/** One executable module the document names, and what it weighs on disk. */
|
|
@@ -321,6 +244,8 @@ export interface MeasuredJs {
|
|
|
321
244
|
readonly jsBytes: number;
|
|
322
245
|
/** The framework's injected runtime, counted separately so it is reported and never charged. */
|
|
323
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;
|
|
324
249
|
/** Every APP `src=`/`data-x-entry=` module, so a finding can name the heaviest by file. */
|
|
325
250
|
readonly entries: readonly MeasuredEntry[];
|
|
326
251
|
}
|
|
@@ -372,9 +297,14 @@ export const FRAMEWORK_INLINE_SCRIPTS: ReadonlySet<string> = new Set(
|
|
|
372
297
|
* from the emitted HTML rather than from the declared graph, because the graph is what a route
|
|
373
298
|
* *says* it ships and this gate exists to catch the case where those two disagree.
|
|
374
299
|
*/
|
|
375
|
-
export async function measureDocumentJs(
|
|
300
|
+
export async function measureDocumentJs(
|
|
301
|
+
html: string,
|
|
302
|
+
out: string,
|
|
303
|
+
options: MeasureOptions = {},
|
|
304
|
+
): Promise<MeasuredJs> {
|
|
376
305
|
let jsBytes = 0;
|
|
377
306
|
let frameworkBytes = 0;
|
|
307
|
+
let inlineBytes = 0;
|
|
378
308
|
const entries: MeasuredEntry[] = [];
|
|
379
309
|
// Deduped ONCE, across both readers below, and the unit is the FETCH: a browser downloads a URL
|
|
380
310
|
// once however many times the document names it, so `budget.js` — a byte budget — counts it
|
|
@@ -390,13 +320,21 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
|
|
|
390
320
|
// is not in this set for the same reason: two identical inline bodies are two copies of the
|
|
391
321
|
// bytes in the document, so both are charged.
|
|
392
322
|
const fetched = new Set<string>();
|
|
393
|
-
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> => {
|
|
394
328
|
// Only a path inside the artifact can be weighed; a cross-origin script is not this build's.
|
|
395
|
-
|
|
329
|
+
const url = artifactPath(named);
|
|
330
|
+
if (url === undefined || fetched.has(url)) return;
|
|
396
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);
|
|
397
335
|
const file = Bun.file(join(out, url.slice(1)));
|
|
398
|
-
const exists = await file.exists();
|
|
399
|
-
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');
|
|
400
338
|
// Counted and set aside, not skipped: the bytes are real and a reader is owed the number.
|
|
401
339
|
// Kept out of `entries` as well as out of `jsBytes`, because `entries` is what a finding reads
|
|
402
340
|
// to name the heaviest import — and on a fresh scaffold every route's `heaviestChain` was
|
|
@@ -411,7 +349,9 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
|
|
|
411
349
|
// and each is a fetch the browser makes before the island is whole. Through `weigh`, so a chunk
|
|
412
350
|
// two islands share is charged once and a cycle ends at the first repeat.
|
|
413
351
|
if (exists) {
|
|
414
|
-
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
|
+
}
|
|
415
355
|
}
|
|
416
356
|
};
|
|
417
357
|
|
|
@@ -422,8 +362,12 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
|
|
|
422
362
|
if (src === undefined) {
|
|
423
363
|
const body = match.groups?.['body'] ?? '';
|
|
424
364
|
const bytes = Buffer.byteLength(body, 'utf8');
|
|
425
|
-
if (FRAMEWORK_INLINE_SCRIPTS.has(body))
|
|
426
|
-
|
|
365
|
+
if (FRAMEWORK_INLINE_SCRIPTS.has(body)) {
|
|
366
|
+
frameworkBytes += bytes;
|
|
367
|
+
} else {
|
|
368
|
+
jsBytes += bytes;
|
|
369
|
+
inlineBytes += bytes;
|
|
370
|
+
}
|
|
427
371
|
continue;
|
|
428
372
|
}
|
|
429
373
|
await weigh(src);
|
|
@@ -433,7 +377,26 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
|
|
|
433
377
|
if (url === undefined) continue;
|
|
434
378
|
await weigh(url);
|
|
435
379
|
}
|
|
436
|
-
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
|
+
};
|
|
437
400
|
}
|
|
438
401
|
|
|
439
402
|
/**
|
package/src/cdp-shot-driver.ts
CHANGED
|
@@ -91,7 +91,9 @@ export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
|
|
|
91
91
|
throw new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
|
|
92
92
|
}
|
|
93
93
|
const launched = await launch(options.executablePath, timeoutMs);
|
|
94
|
-
|
|
94
|
+
// Awaited by `close` below: `x shot` exits right after its last picture, and a reap still
|
|
95
|
+
// running then is a Chrome child left behind and a profile directory it re-creates.
|
|
96
|
+
return { connection: launched.connection, end: () => launched.close() };
|
|
95
97
|
};
|
|
96
98
|
|
|
97
99
|
return {
|
|
@@ -120,10 +122,22 @@ export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
|
|
|
120
122
|
detail: 'the browser opened a page and answered no target or session id',
|
|
121
123
|
});
|
|
122
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
|
+
}
|
|
123
136
|
// Watching BEFORE the domains are on, so the first request is already counted.
|
|
124
137
|
const watch = watchPage({
|
|
125
138
|
connection,
|
|
126
139
|
sessionId,
|
|
140
|
+
browserSessionId,
|
|
127
141
|
clock: init.clock,
|
|
128
142
|
allowHosts: init.rules.allowHosts,
|
|
129
143
|
});
|
|
@@ -134,6 +148,11 @@ export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
|
|
|
134
148
|
await on('Page.enable');
|
|
135
149
|
await on('Network.enable');
|
|
136
150
|
await on('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
|
|
151
|
+
await connection.send(
|
|
152
|
+
'Fetch.enable',
|
|
153
|
+
{ patterns: [{ urlPattern: '*', requestStage: 'Request' }] },
|
|
154
|
+
browserSessionId,
|
|
155
|
+
);
|
|
137
156
|
// Before the first navigation, like the metrics below: a header set after `goto` would
|
|
138
157
|
// photograph a document negotiated in the machine's own language.
|
|
139
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
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// `x auth`'s declaration, apart from its body: the parser, `x help` and the `errors` step
|
|
2
|
+
// read it without loading `cmd-auth.ts`, which `registry.ts` imports only when the command runs.
|
|
3
|
+
|
|
4
|
+
import type { CommandSpec } from './parse';
|
|
5
|
+
|
|
6
|
+
export const AUTH_SUBCOMMANDS = ['seal-mfa'] as const;
|
|
7
|
+
|
|
8
|
+
export const authSpec: CommandSpec = {
|
|
9
|
+
name: 'auth',
|
|
10
|
+
summary: 'one-shot maintenance of the auth tables: seal every plaintext second-factor secret',
|
|
11
|
+
usage: 'x auth seal-mfa [--json]',
|
|
12
|
+
requiresApp: true,
|
|
13
|
+
// No default: the one subcommand WRITES, and a bare `x auth` must not.
|
|
14
|
+
subcommands: [...AUTH_SUBCOMMANDS],
|
|
15
|
+
flags: [],
|
|
16
|
+
};
|
package/src/cmd-auth.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// `x auth seal-mfa` — seal every `x_users.mfa_secret` still stored in the clear, in place, under
|
|
2
|
+
// the app's master key. The one-shot an upgrade onto sealed second-factor secrets owes: readers
|
|
3
|
+
// refuse a plaintext value (`X_MFA_SECRET_UNSEALED`), and this is the command that refusal names.
|
|
4
|
+
// Idempotent — a sealed value is counted and left alone — so an interrupted run is finished by
|
|
5
|
+
// running it again.
|
|
6
|
+
|
|
7
|
+
import type { MfaSecretStore } from '@ultimat3/auth';
|
|
8
|
+
import { postgresAuthAdapter, sealMfaSecrets } from '@ultimat3/auth';
|
|
9
|
+
import { requireAppRoot } from './app-root';
|
|
10
|
+
import { authSpec } from './cmd-auth-spec';
|
|
11
|
+
import type { CliCommand, CommandContext } from './command';
|
|
12
|
+
import { ok } from './command';
|
|
13
|
+
import { msg } from './messages';
|
|
14
|
+
import type { CommandResult } from './output';
|
|
15
|
+
import { resolveServices } from './runtime-bindings';
|
|
16
|
+
import { startQueue } from './runtime-queue';
|
|
17
|
+
|
|
18
|
+
export interface OpenAuthStore extends MfaSecretStore {
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The app's own database, opened the way every other command opens it (`startQueue`: the
|
|
24
|
+
* framework's tables are applied first, so `x_users` exists) and ALWAYS released — a CLI that
|
|
25
|
+
* exits holding the PGlite lock breaks the next command run against this app.
|
|
26
|
+
*/
|
|
27
|
+
async function openAppAuthStore(root: string, ctx: CommandContext): Promise<OpenAuthStore> {
|
|
28
|
+
const queue = await startQueue(resolveServices(root, ctx.env), undefined, ctx.env);
|
|
29
|
+
return { adapter: postgresAuthAdapter(queue.db), close: () => queue.stop() };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `open` is the seam a test supplies a store through; the shipped command opens the app's. */
|
|
33
|
+
export const authCommandOver = (
|
|
34
|
+
open: (root: string, ctx: CommandContext) => Promise<OpenAuthStore>,
|
|
35
|
+
): CliCommand => ({
|
|
36
|
+
spec: authSpec,
|
|
37
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
38
|
+
const root = requireAppRoot('auth', ctx.cwd).dir;
|
|
39
|
+
const store = await open(root, ctx);
|
|
40
|
+
try {
|
|
41
|
+
// The key is read from the command's own environment and the app's `.secrets.key`: with
|
|
42
|
+
// neither, the first seal refuses (`X_SEAL_KEY_MISSING`) before any row is written.
|
|
43
|
+
const report = await sealMfaSecrets(store, { root, env: ctx.env });
|
|
44
|
+
return ok(
|
|
45
|
+
'auth',
|
|
46
|
+
msg('cli.auth.sealed', {
|
|
47
|
+
sealed: report.sealed,
|
|
48
|
+
already: report.alreadySealed,
|
|
49
|
+
skipped: report.skipped,
|
|
50
|
+
}),
|
|
51
|
+
{
|
|
52
|
+
data: {
|
|
53
|
+
sealed: report.sealed,
|
|
54
|
+
alreadySealed: report.alreadySealed,
|
|
55
|
+
skipped: report.skipped,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
} finally {
|
|
60
|
+
await store.close();
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const authCommand: CliCommand = authCommandOver(openAppAuthStore);
|
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',
|