@ultimat3/cli 20.2.1 → 22.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 +84 -1582
- package/README.md +40 -2
- package/package.json +31 -31
- package/src/api-registration.ts +124 -0
- package/src/app-artifacts.ts +39 -0
- package/src/app-boundaries.ts +5 -10
- package/src/app-env.ts +2 -2
- package/src/app-load.ts +37 -5
- package/src/app-openapi.ts +27 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-root.ts +1 -1
- package/src/async-pages.ts +32 -0
- package/src/bin.ts +7 -1
- package/src/boundary-findings.ts +36 -0
- package/src/browser-launcher-fake-html.ts +80 -0
- package/src/browser-launcher-fake.ts +165 -0
- package/src/browser-launcher-port.ts +159 -0
- package/src/browser-launcher.ts +26 -133
- package/src/budgets.ts +56 -13
- package/src/build-errors.ts +54 -0
- package/src/cdp-shot-a11y.ts +77 -0
- package/src/cdp-shot-clock.ts +14 -0
- package/src/cdp-shot-driver.ts +150 -0
- package/src/cdp-shot-element.ts +147 -0
- package/src/cdp-shot-errors.ts +62 -0
- package/src/cdp-shot-keys.ts +152 -0
- package/src/cdp-shot-page.ts +230 -0
- package/src/cdp-shot-watch.ts +241 -0
- package/src/cmd-affected-spec.ts +24 -0
- package/src/cmd-affected.ts +3 -19
- package/src/cmd-build-spec.ts +16 -0
- package/src/cmd-build.ts +46 -20
- package/src/cmd-ci-spec.ts +24 -0
- package/src/cmd-ci.ts +17 -21
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db-spec.ts +99 -0
- package/src/cmd-db.ts +15 -94
- package/src/cmd-deploy-helm.ts +136 -0
- package/src/cmd-deploy-spec.ts +40 -0
- package/src/cmd-deploy.ts +117 -42
- package/src/cmd-dev-spec.ts +25 -0
- package/src/cmd-dev.ts +78 -122
- package/src/cmd-docs-spec.ts +16 -0
- package/src/cmd-docs.ts +2 -11
- package/src/cmd-doctor-spec.ts +21 -0
- package/src/cmd-doctor.ts +4 -18
- package/src/cmd-env-spec.ts +18 -0
- package/src/cmd-env.ts +4 -13
- package/src/cmd-errors-spec.ts +23 -0
- package/src/cmd-errors.ts +3 -17
- package/src/cmd-fix-spec.ts +18 -0
- package/src/cmd-fix.ts +4 -13
- package/src/cmd-generate-spec.ts +31 -0
- package/src/cmd-generate.ts +46 -42
- package/src/cmd-i18n-spec.ts +16 -0
- package/src/cmd-i18n.ts +7 -14
- package/src/cmd-jobs-spec.ts +56 -0
- package/src/cmd-jobs.ts +3 -50
- package/src/cmd-manifest-spec.ts +15 -0
- package/src/cmd-manifest.ts +19 -20
- package/src/cmd-mcp-spec.ts +21 -0
- package/src/cmd-mcp.ts +2 -15
- package/src/cmd-new-spec.ts +33 -0
- package/src/cmd-new.ts +38 -33
- package/src/cmd-policy-spec.ts +13 -0
- package/src/cmd-policy.ts +2 -9
- package/src/cmd-pr-spec.ts +39 -0
- package/src/cmd-pr.ts +5 -65
- package/src/cmd-registries-spec.ts +32 -0
- package/src/cmd-registries.ts +4 -25
- package/src/cmd-routes-spec.ts +12 -0
- package/src/cmd-routes.ts +2 -7
- package/src/cmd-secrets-spec.ts +19 -0
- package/src/cmd-secrets.ts +22 -34
- package/src/cmd-shot-island.ts +12 -13
- package/src/cmd-shot-spec.ts +53 -0
- package/src/cmd-shot.ts +11 -60
- package/src/cmd-tasks-spec.ts +21 -0
- package/src/cmd-tasks.ts +2 -16
- package/src/cmd-test-spec.ts +54 -0
- package/src/cmd-test.ts +4 -49
- package/src/cmd-verify-spec.ts +28 -0
- package/src/cmd-verify.ts +3 -23
- package/src/db-seed.ts +4 -2
- package/src/dev-dashboard.ts +4 -4
- package/src/dev-lock.ts +18 -2
- package/src/dev-port.ts +21 -0
- package/src/dev-route-table.ts +119 -0
- package/src/dispatch.ts +12 -5
- package/src/document-styles.ts +1 -1
- package/src/drift.ts +4 -18
- package/src/error-catalog.ts +16 -9
- package/src/error-codes.ts +33 -28
- package/src/error-pages.ts +19 -7
- package/src/error-unthrown.ts +130 -0
- package/src/errors.ts +22 -29
- package/src/favicon.ts +2 -2
- package/src/fix-imports.ts +1 -1
- package/src/fix-scan.ts +2 -9
- package/src/flag-reads.ts +1 -2
- package/src/foreign-text.ts +36 -0
- package/src/framework-schema.ts +5 -6
- package/src/generate-feature.ts +42 -0
- package/src/generate-files.ts +7 -3
- package/src/generate-grants.ts +83 -0
- package/src/generate-kinds.ts +56 -4
- package/src/i18n-index.ts +59 -10
- package/src/icon-assets.ts +1 -1
- package/src/image-prepare.ts +14 -0
- package/src/import-scan.ts +63 -0
- package/src/index.ts +67 -334
- package/src/invocation-flags.ts +26 -0
- package/src/island-bundle.ts +38 -11
- package/src/island-capture.ts +6 -6
- package/src/island-harness-script.ts +6 -2
- package/src/island-realtime.ts +98 -0
- package/src/island-shot.ts +5 -0
- package/src/island-store.ts +131 -0
- package/src/island-verdict.ts +2 -2
- package/src/job-registration.ts +42 -0
- package/src/jobs-driver.ts +2 -2
- package/src/live-routes.ts +82 -42
- package/src/load-findings.ts +51 -0
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +30 -26
- package/src/mcp-host.ts +27 -12
- package/src/mcp-ui-diff.ts +27 -0
- package/src/mcp-ui-inspect.ts +4 -4
- package/src/mcp-ui-interact.ts +21 -15
- package/src/mcp-ui.ts +19 -15
- package/src/measure-database.ts +73 -0
- package/src/measure-paths.ts +74 -0
- package/src/measure-scope.ts +74 -0
- package/src/messages.ts +1 -3
- package/src/metrics-endpoint.ts +1 -1
- package/src/otlp-export.ts +1 -1
- package/src/output.ts +6 -0
- package/src/page-sync.ts +54 -0
- package/src/permission-grants.ts +86 -0
- package/src/prerender-out.ts +25 -0
- package/src/prerender.ts +121 -102
- package/src/pwa-artifacts.ts +3 -3
- package/src/realtime-browser-probe-fixture.ts +2 -2
- package/src/reexport-manifest.ts +2 -1
- package/src/registry.ts +80 -56
- package/src/role-realtime.ts +36 -0
- package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
- package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
- package/src/role-start-types.ts +112 -0
- package/src/{dev-roles.ts → role-start.ts} +42 -115
- package/src/{dev-sync.ts → role-sync.ts} +23 -11
- package/src/root-env.ts +67 -0
- package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
- package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
- package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
- package/src/runtime-jobs.ts +87 -0
- package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
- package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
- package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
- package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
- package/src/runtime-realtime.ts +55 -0
- package/src/{dev-render.ts → runtime-render.ts} +119 -20
- package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
- package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
- package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
- package/src/scaffold-fixture.ts +28 -6
- package/src/scaffold-typecheck.ts +6 -3
- package/src/schema-drift.ts +7 -1
- package/src/script-csp.ts +5 -2
- package/src/secrets-rotation.ts +59 -0
- package/src/serve-boot.ts +192 -0
- package/src/serve-drain.ts +24 -0
- package/src/serve-entry.ts +6 -0
- package/src/serve-env.ts +116 -0
- package/src/serve-types.ts +55 -0
- package/src/serve.ts +44 -338
- package/src/shot-server.ts +2 -2
- package/src/shot-settle.ts +10 -1
- package/src/shot-theme.ts +3 -3
- package/src/shot-verdict.ts +16 -7
- package/src/signal-shred.ts +27 -0
- package/src/solid-loader.ts +26 -2
- package/src/static-report.ts +8 -1
- package/src/sw-artifacts.ts +13 -3
- package/src/sync-url.ts +31 -0
- package/src/templates/action.ts +30 -16
- package/src/templates/entity.ts +12 -7
- package/src/templates/index.ts +1 -1
- package/src/templates/job.ts +10 -7
- package/src/templates/policy.ts +20 -2
- package/src/templates/resource-create.ts +127 -0
- package/src/templates/resource-form-island.ts +76 -30
- package/src/templates/resource.ts +10 -4
- package/src/templates/route.ts +3 -0
- package/src/templates/scaffold-app.ts +4 -1
- package/src/templates/scaffold-auth.ts +3 -1
- package/src/templates/scaffold-container-compose.ts +184 -0
- package/src/templates/scaffold-container.ts +25 -145
- package/src/templates/scaffold-dashboard-example.ts +2 -2
- package/src/templates/scaffold-db-package.ts +16 -3
- package/src/templates/scaffold-demo-org.ts +41 -0
- package/src/templates/scaffold-entries.ts +1 -1
- package/src/templates/scaffold-env.ts +6 -0
- package/src/templates/scaffold-helm-templates.ts +66 -7
- package/src/templates/scaffold-helm.ts +27 -5
- package/src/templates/scaffold-i18n.ts +15 -10
- package/src/templates/scaffold-repo.ts +13 -10
- package/src/templates/scaffold-roles.ts +38 -10
- package/src/templates/slice-foundation.ts +1 -1
- package/src/templates/wrap.ts +4 -1
- package/src/test-passes.ts +2 -1
- package/src/test-workers.ts +26 -0
- package/src/ts-scan.ts +3 -6
- package/src/tsconfig-references.ts +1 -2
- package/src/verify-checks.ts +41 -39
- package/src/verify-e2e.ts +41 -0
- package/src/verify-run.ts +115 -50
- package/src/verify-step.ts +3 -3
- package/src/verify-tests.ts +22 -30
- package/src/verify-typecheck.ts +28 -0
- package/src/web-binding.ts +2 -2
- package/src/worker-bundle.ts +192 -0
- package/src/workspace-graph.ts +10 -33
- package/src/cdp-browser.ts +0 -100
- package/src/cdp-connection.ts +0 -211
- package/src/cdp-e2e-page.ts +0 -209
- package/src/cdp-errors.ts +0 -56
- package/src/cdp-launch.ts +0 -139
- package/src/e2e-dom-fixture.ts +0 -117
- package/src/e2e-driver.ts +0 -97
- package/src/e2e-errors.ts +0 -103
- package/src/e2e-evaluate.ts +0 -156
- package/src/e2e-locator.ts +0 -86
- package/src/e2e-page.ts +0 -150
- package/src/e2e-selection.ts +0 -182
- package/src/measurement-actor.ts +0 -26
- /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
package/src/cmd-verify.ts
CHANGED
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
|
|
10
10
|
import { nearestName } from '@ultimat3/core';
|
|
11
11
|
import { requireAppRoot } from './app-root';
|
|
12
|
+
import { verifySpec } from './cmd-verify-spec';
|
|
12
13
|
import type { CliCommand, CommandContext } from './command';
|
|
13
14
|
import { BadFlagError } from './errors';
|
|
14
15
|
import { readIntFlag } from './flag-number';
|
|
15
16
|
import type { CommandResult } from './output';
|
|
16
17
|
import type { ParsedArgs } from './parse';
|
|
17
18
|
import { flagString } from './parse';
|
|
18
|
-
import { WORKER_CEILING, WORKER_FLOOR
|
|
19
|
+
import { WORKER_CEILING, WORKER_FLOOR } from './test-workers';
|
|
19
20
|
import { VERIFY_STEPS } from './verify-checks';
|
|
20
21
|
import { runVerify } from './verify-run';
|
|
21
22
|
import type { VerifyStepName } from './verify-step';
|
|
@@ -28,28 +29,7 @@ export { VERIFY_STEPS } from './verify-checks';
|
|
|
28
29
|
export { runVerify } from './verify-run';
|
|
29
30
|
|
|
30
31
|
export const verifyCommand: CliCommand = {
|
|
31
|
-
spec:
|
|
32
|
-
name: 'verify',
|
|
33
|
-
summary: 'the gate: typecheck, lint, boundaries, all tests, drift, contract, budgets',
|
|
34
|
-
usage: 'x verify [--only <step>] [--workers N] [--json]',
|
|
35
|
-
requiresApp: true,
|
|
36
|
-
// Two flags, and only one of them narrows. `--workers` changes how wide the test steps
|
|
37
|
-
// spread, never which steps run. `--only` runs one step and says so in both renderers —
|
|
38
|
-
// never silently, which is the whole of what makes it safe to have.
|
|
39
|
-
flags: [
|
|
40
|
-
{
|
|
41
|
-
name: 'workers',
|
|
42
|
-
type: 'string',
|
|
43
|
-
summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
name: 'only',
|
|
47
|
-
type: 'string',
|
|
48
|
-
summary:
|
|
49
|
-
'run ONE step by name — an iteration loop, NOT A GATE RUN; the gate is this command with no flag',
|
|
50
|
-
},
|
|
51
|
-
],
|
|
52
|
-
},
|
|
32
|
+
spec: verifySpec,
|
|
53
33
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
54
34
|
const root = requireAppRoot('verify', ctx.cwd).dir;
|
|
55
35
|
// Both readers before the run: an unrunnable flag must be refused in milliseconds, not after
|
package/src/db-seed.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { BadFlagError } from './errors';
|
|
|
16
16
|
import type { Finding, JsonValue } from './output';
|
|
17
17
|
import { findingFrom } from './output';
|
|
18
18
|
import { hasPathSegment } from './path-segments';
|
|
19
|
+
import { isTest } from './source-files';
|
|
19
20
|
import { renderTable } from './table';
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -102,10 +103,11 @@ export async function discoverSeeds(root: string): Promise<SeedDiscovery> {
|
|
|
102
103
|
const seen = new Set<string>();
|
|
103
104
|
for (const pattern of SEED_GLOBS) {
|
|
104
105
|
for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
|
|
105
|
-
|
|
106
|
+
const file = relative(root, absolute).split(sep).join('/');
|
|
107
|
+
// Root-relative, as `loadApp` tests it: an absolute path under `my.test.app/` is no test.
|
|
108
|
+
if (hasPathSegment(absolute, 'node_modules') || isTest(file)) continue;
|
|
106
109
|
if (seen.has(absolute)) continue;
|
|
107
110
|
seen.add(absolute);
|
|
108
|
-
const file = relative(root, absolute).split(sep).join('/');
|
|
109
111
|
let module: Record<string, unknown>;
|
|
110
112
|
try {
|
|
111
113
|
module = (await import(absolute)) as Record<string, unknown>;
|
package/src/dev-dashboard.ts
CHANGED
|
@@ -22,14 +22,14 @@ import { json as jsonResponse } from '@ultimat3/http';
|
|
|
22
22
|
import type { MemoryMailDriver } from '@ultimat3/mail';
|
|
23
23
|
import { isMemoryDriver } from '@ultimat3/mail';
|
|
24
24
|
import type { Manifest } from '@ultimat3/manifest';
|
|
25
|
-
import { checkAppBoundaries } from './app-boundaries';
|
|
26
25
|
import { appManifest, readAppManifest } from './app-manifest';
|
|
26
|
+
import { appBoundaryFindings } from './boundary-findings';
|
|
27
27
|
import type { StatementLedger } from './dev-n-plus-one';
|
|
28
28
|
import { devPolicyMatrix } from './dev-policy';
|
|
29
|
-
import type { RunningServices } from './dev-runtime';
|
|
30
|
-
import type { DevServices } from './dev-services';
|
|
31
29
|
import type { TraceRecorder } from './dev-traces';
|
|
32
30
|
import type { Finding } from './output';
|
|
31
|
+
import type { DevServices } from './runtime-bindings';
|
|
32
|
+
import type { RunningServices } from './runtime-services';
|
|
33
33
|
import { loopFacts } from './statement-loop';
|
|
34
34
|
|
|
35
35
|
export interface DevStatus {
|
|
@@ -195,7 +195,7 @@ const boundariesPanel = (input: DevDashboardInput): DevPanel<BoundariesPanelData
|
|
|
195
195
|
titleKey: 'dev.panel.boundaries.title',
|
|
196
196
|
questionKey: 'dev.panel.boundaries.question',
|
|
197
197
|
async data(): Promise<BoundariesPanelData> {
|
|
198
|
-
return { findings: await
|
|
198
|
+
return { findings: await appBoundaryFindings(input.root) };
|
|
199
199
|
},
|
|
200
200
|
});
|
|
201
201
|
|
package/src/dev-lock.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// The lock file is what makes the second one nameable at all: nothing else in the process can tell
|
|
15
15
|
// "another dev server owns this directory" from "the database is broken".
|
|
16
16
|
|
|
17
|
-
import { closeSync, mkdirSync, openSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { stringField, UltimateError } from '@ultimat3/core';
|
|
20
20
|
import { exec, type Runner } from './exec';
|
|
@@ -75,11 +75,27 @@ export const isProcessAlive = (pid: number): boolean => {
|
|
|
75
75
|
}
|
|
76
76
|
};
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The lock a LIVE `x dev` holds on this state directory, or `undefined` — for a command that must
|
|
80
|
+
* not run beside one (`x db reset` deletes the data directory that process has open). A missing,
|
|
81
|
+
* unreadable or stale lock is no holder: only `preflight` may clear one.
|
|
82
|
+
*/
|
|
83
|
+
export const liveDevLock = (stateDir: string): DevLock | undefined => {
|
|
84
|
+
let raw: string;
|
|
85
|
+
try {
|
|
86
|
+
raw = readFileSync(lockPath(stateDir), 'utf-8');
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const lock = parseLock(raw);
|
|
91
|
+
return lock !== null && isProcessAlive(lock.pid) ? lock : undefined;
|
|
92
|
+
};
|
|
93
|
+
|
|
78
94
|
/**
|
|
79
95
|
* Refused before boot, so the failure names the process holding the directory.
|
|
80
96
|
*
|
|
81
97
|
* The lock is on the CHECKOUT, not on the database, and the cause says so. `x dev` is one process
|
|
82
|
-
* running every role (`
|
|
98
|
+
* running every role (`role-start.ts`), so a second one is unsupported whatever the services are.
|
|
83
99
|
* The embedded-Postgres sentence is appended only when the database actually IS embedded — with an
|
|
84
100
|
* external `DATABASE_URL` it would name a mechanism that is not in play, which is the same defect
|
|
85
101
|
* as the message this whole module replaced.
|
package/src/dev-port.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Which port `x dev` binds: `--port`, else `PORT` from the environment, else 3000. The flag used to
|
|
2
|
+
// declare `default: '3000'`, and a default is indistinguishable from a caller's value — so the
|
|
3
|
+
// scaffold's own `.env.development` `PORT=` was read by nothing and every app booted on 3000.
|
|
4
|
+
|
|
5
|
+
import { PORT_RANGE, readIntFlag } from './flag-number';
|
|
6
|
+
import type { ParsedArgs } from './parse';
|
|
7
|
+
import { portFromEnv } from './serve';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_DEV_PORT = 3000;
|
|
10
|
+
|
|
11
|
+
/** The `metricsPortFor` shape: an explicit value, then the env, then the constant. */
|
|
12
|
+
export const devPortFor = (
|
|
13
|
+
args: ParsedArgs,
|
|
14
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
15
|
+
): number =>
|
|
16
|
+
readIntFlag(args, {
|
|
17
|
+
name: 'port',
|
|
18
|
+
command: 'dev',
|
|
19
|
+
...PORT_RANGE,
|
|
20
|
+
example: `x dev --port ${DEFAULT_DEV_PORT}`,
|
|
21
|
+
}) ?? portFromEnv(env);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The route table `x dev` serves, in mount order: the dashboard, the API, the assets a document
|
|
2
|
+
// names, the island and sync-worker scripts, and the app's pages last. Split from `cmd-dev.ts` at its
|
|
3
|
+
// 500-line ceiling; `serve.ts` composes the production table from the same builders.
|
|
4
|
+
|
|
5
|
+
import type { Route } from '@ultimat3/http';
|
|
6
|
+
import { describeRoutes } from '@ultimat3/render';
|
|
7
|
+
import type { Storage } from '@ultimat3/storage';
|
|
8
|
+
import { apiRoutes } from './api-routes';
|
|
9
|
+
import { mountAppMcp } from './app-mcp';
|
|
10
|
+
import type { DevDashboardInput } from './dev-dashboard';
|
|
11
|
+
import { devDashboardRoutes } from './dev-dashboard';
|
|
12
|
+
import { errorPageStyleSources } from './error-page-csp';
|
|
13
|
+
import type { IslandBundle } from './island-bundle';
|
|
14
|
+
import { islandHarnessRoutes } from './island-harness-route';
|
|
15
|
+
import { islandRoutes } from './island-routes';
|
|
16
|
+
import { loadIslandStates } from './island-states-load';
|
|
17
|
+
import { pageSync } from './page-sync';
|
|
18
|
+
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
19
|
+
import { assetRoutes } from './runtime-assets';
|
|
20
|
+
import { appRoutes } from './runtime-render';
|
|
21
|
+
import { storageRoutes } from './runtime-storage';
|
|
22
|
+
import { styleBundle } from './style-bundle';
|
|
23
|
+
import { styleRoutes } from './style-routes';
|
|
24
|
+
import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
25
|
+
import { serviceWorkerRoutes } from './sw-routes';
|
|
26
|
+
import type { ThemeBoot } from './theme-boot';
|
|
27
|
+
import { loadThemeMode, themeBoot } from './theme-boot';
|
|
28
|
+
|
|
29
|
+
export interface DevRouteTableInput {
|
|
30
|
+
readonly root: string;
|
|
31
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
32
|
+
readonly buildId: string;
|
|
33
|
+
readonly storage: Storage;
|
|
34
|
+
readonly dashboard: DevDashboardInput;
|
|
35
|
+
/** A getter: the watcher tick rebuilds the islands, and a captured bundle would serve the first. */
|
|
36
|
+
readonly islands: () => IslandBundle;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DevRouteTable {
|
|
40
|
+
readonly routes: readonly Route[];
|
|
41
|
+
/** The theme boot, whose `cspSource` the web role admits. */
|
|
42
|
+
readonly theme: ThemeBoot;
|
|
43
|
+
/** The app's own error pages' inline styles, admitted the same way. */
|
|
44
|
+
readonly errorStyles: readonly string[];
|
|
45
|
+
/** Where the app's MCP endpoint was mounted, or `undefined`. */
|
|
46
|
+
readonly mcpPath: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function devRouteTable(input: DevRouteTableInput): Promise<DevRouteTable> {
|
|
50
|
+
// Resolved once, before the first route. `undefined` for an app that is not installable: nothing
|
|
51
|
+
// is mounted, and the 0kb baseline is not spent on a `<link>` to a file that does not exist.
|
|
52
|
+
const pwa = await loadPwaArtifacts(input.root);
|
|
53
|
+
const theme = themeBoot(await loadThemeMode(input.root));
|
|
54
|
+
// The same call `serve.ts` makes, so the two boots cannot serve different sync targets.
|
|
55
|
+
const sync = await pageSync(input.root, input.env, input.buildId);
|
|
56
|
+
const errorStyles = await errorPageStyleSources(input.root);
|
|
57
|
+
// Built once at boot and NOT rebuilt with the islands on a watcher tick: a service worker that
|
|
58
|
+
// changes under a page it controls is the update path, and one per keystroke exercises it per save.
|
|
59
|
+
const serviceWorker =
|
|
60
|
+
pwa === undefined
|
|
61
|
+
? undefined
|
|
62
|
+
: serviceWorkerArtifacts({
|
|
63
|
+
pwa,
|
|
64
|
+
buildId: input.buildId,
|
|
65
|
+
routes: describeRoutes(),
|
|
66
|
+
islands: input.islands(),
|
|
67
|
+
styles: styleBundle(),
|
|
68
|
+
scripts: sync.scripts,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
|
|
72
|
+
// call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
|
|
73
|
+
// one of them asked. Warned once here when `expose` is true and nothing can be mounted.
|
|
74
|
+
const mcpMount = await mountAppMcp(input.root);
|
|
75
|
+
const routes: readonly Route[] = [
|
|
76
|
+
...devDashboardRoutes(input.dashboard),
|
|
77
|
+
// The same API table the container serves: a read that answers here and 404s in production
|
|
78
|
+
// is exactly the drift one composition exists to prevent.
|
|
79
|
+
...apiRoutes(),
|
|
80
|
+
...mcpMount.routes,
|
|
81
|
+
// The image pipeline's only HTTP surface: the icons the web manifest declares, and the
|
|
82
|
+
// variants every `srcset` promises. Mounted before the app's own routes so a page route can
|
|
83
|
+
// never shadow `/icons` or `/media`.
|
|
84
|
+
...assetRoutes({
|
|
85
|
+
root: input.root,
|
|
86
|
+
storage: input.storage,
|
|
87
|
+
...(pwa === undefined ? {} : { pwa }),
|
|
88
|
+
}),
|
|
89
|
+
...storageRoutes({ storage: input.storage }),
|
|
90
|
+
// The chunks the documents below name. Mounted before the app's routes for the reason
|
|
91
|
+
// `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
|
|
92
|
+
...islandRoutes(() => input.islands()),
|
|
93
|
+
// And the stylesheet every one of those documents links. Read through the getter for the
|
|
94
|
+
// reason the islands are: a rebuilt island registers CSS, which mints a new URL, and a table
|
|
95
|
+
// captured at boot would answer 404 for the href the document now carries.
|
|
96
|
+
...styleRoutes(() => styleBundle()),
|
|
97
|
+
// `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
|
|
98
|
+
// lives here rather than in a second server because everything it needs is in THIS process:
|
|
99
|
+
// the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
|
|
100
|
+
// may have. The states are read per REQUEST — an author editing a state and re-running the
|
|
101
|
+
// command must not need a restart to see it.
|
|
102
|
+
...islandHarnessRoutes({
|
|
103
|
+
islands: () => input.islands(),
|
|
104
|
+
states: () => loadIslandStates(input.root),
|
|
105
|
+
}),
|
|
106
|
+
...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
|
|
107
|
+
...sync.routes,
|
|
108
|
+
...appRoutes({
|
|
109
|
+
buildId: input.buildId,
|
|
110
|
+
resolveIsland: (file) => input.islands().resolverFor(file),
|
|
111
|
+
sync: sync.head,
|
|
112
|
+
persisted: sync.persisted,
|
|
113
|
+
themeHead: theme.head,
|
|
114
|
+
...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
|
|
115
|
+
}),
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
return { routes, theme, errorStyles, mcpPath: mcpMount.path };
|
|
119
|
+
}
|
package/src/dispatch.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { exitCodeFor, findingFrom, render } from './output';
|
|
|
16
16
|
import type { ParsedArgs } from './parse';
|
|
17
17
|
import { parseArgs, wantsJson } from './parse';
|
|
18
18
|
import { commandFor, SPECS } from './registry';
|
|
19
|
+
import { rootEnvAdditions } from './root-env';
|
|
19
20
|
|
|
20
21
|
export interface DispatchOptions {
|
|
21
22
|
readonly argv: readonly string[];
|
|
@@ -120,14 +121,20 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
120
121
|
? { ...args, command: 'help', positionals: [args.command] }
|
|
121
122
|
: args;
|
|
122
123
|
|
|
124
|
+
const cwd = resolveCwd(
|
|
125
|
+
options.cwd,
|
|
126
|
+
typeof args.flags.get('cwd') === 'string' ? String(args.flags.get('cwd')) : undefined,
|
|
127
|
+
);
|
|
128
|
+
// The ROOT's `.env*`, not the cwd's: Bun loaded the cwd's at startup, and a command run from
|
|
129
|
+
// `apps/web` finds its root by walking up. Written into the process env too when that IS the env
|
|
130
|
+
// passed in, because a package reading `Bun.env` directly must see what the command sees.
|
|
131
|
+
const added = await rootEnvAdditions(cwd, options.env);
|
|
132
|
+
if (options.env === Bun.env) Object.assign(Bun.env, added);
|
|
123
133
|
const ctx: CommandContext = {
|
|
124
134
|
args: helpArgs,
|
|
125
|
-
cwd
|
|
126
|
-
options.cwd,
|
|
127
|
-
typeof args.flags.get('cwd') === 'string' ? String(args.flags.get('cwd')) : undefined,
|
|
128
|
-
),
|
|
135
|
+
cwd,
|
|
129
136
|
runner: options.runner ?? exec,
|
|
130
|
-
env: options.env,
|
|
137
|
+
env: options.env === Bun.env ? options.env : { ...added, ...options.env },
|
|
131
138
|
bunVersion: options.bunVersion,
|
|
132
139
|
...(options.invocation === undefined ? {} : { invocation: options.invocation }),
|
|
133
140
|
};
|
package/src/document-styles.ts
CHANGED
|
@@ -25,7 +25,7 @@ export const definesRootCustomProperties = (css: string): boolean => ROOT_CUSTOM
|
|
|
25
25
|
|
|
26
26
|
export interface SurfaceDocument {
|
|
27
27
|
readonly surface: Surface;
|
|
28
|
-
/** Exactly the CSS `
|
|
28
|
+
/** Exactly the CSS `runtime-render.ts` would inline into a document on this surface. */
|
|
29
29
|
readonly css: string;
|
|
30
30
|
}
|
|
31
31
|
|
package/src/drift.ts
CHANGED
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
-
|
|
14
|
+
// `canonicalJson` is core's, the one serializer every content hash in the framework uses. A private
|
|
15
|
+
// copy here wrote `"key":null` for an unset optional field where core drops the key, so the schema
|
|
16
|
+
// hash agreed with nothing else (plan 101 slice 18 h; 22.0.0 re-stamps every sidecar once).
|
|
17
|
+
import { canonicalJson, ERROR_DOCS_URL } from '@ultimat3/core';
|
|
15
18
|
import { describeEntities } from '@ultimat3/entity';
|
|
16
19
|
import { countDeclaredEntities } from './app-entities';
|
|
17
20
|
import { loadApp } from './app-load';
|
|
@@ -23,23 +26,6 @@ import type { Finding } from './output';
|
|
|
23
26
|
export const DB_PACKAGE = join('packages', 'db');
|
|
24
27
|
const SCHEMA_GLOB = 'packages/db/src/**/*.ts';
|
|
25
28
|
|
|
26
|
-
/**
|
|
27
|
-
* Canonical JSON: object keys sorted, arrays in their own order. The registry's description is a
|
|
28
|
-
* BUILD INPUT committed to disk as a hash, so a field reordered inside `describe()` upstream would
|
|
29
|
-
* otherwise move every app's hash and report drift over a framework upgrade nobody made.
|
|
30
|
-
*/
|
|
31
|
-
function canonicalJson(value: unknown): string {
|
|
32
|
-
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
33
|
-
if (typeof value === 'object' && value !== null) {
|
|
34
|
-
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
|
|
35
|
-
a < b ? -1 : a > b ? 1 : 0,
|
|
36
|
-
);
|
|
37
|
-
return `{${entries.map(([key, held]) => `${JSON.stringify(key)}:${canonicalJson(held)}`).join(',')}}`;
|
|
38
|
-
}
|
|
39
|
-
// `undefined` has no JSON form and an optional field left unset must hash as absent, not throw.
|
|
40
|
-
return JSON.stringify(value) ?? 'null';
|
|
41
|
-
}
|
|
42
|
-
|
|
43
29
|
/**
|
|
44
30
|
* What the app's entities declare, as the registry describes them — the half `SCHEMA_GLOB` cannot
|
|
45
31
|
* see. `x new` puts an entity at `apps/web/app/<feature>/entity.ts` and `packages/db/src/schema.ts`
|
package/src/error-catalog.ts
CHANGED
|
@@ -46,22 +46,29 @@ export const CATALOG_PACKAGES = [
|
|
|
46
46
|
] as const;
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* The
|
|
50
|
-
* JSX runtime an app has and a bare CLI process does not, so a hard dependency would
|
|
51
|
-
* uninstallable where the codes are merely absent today.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
49
|
+
* The packages the catalog may import WITHOUT `@ultimat3/cli` declaring them. `admin` and `ui`
|
|
50
|
+
* reach for a JSX runtime an app has and a bare CLI process does not, so a hard dependency would
|
|
51
|
+
* make the CLI uninstallable where the codes are merely absent today. `scraping` is an app's own
|
|
52
|
+
* choice since 22.0.0: `x shot` drives Chrome over raw CDP and imports nothing from it, so its codes
|
|
53
|
+
* answer where the app installed it and are reported `unavailable` where it did not. Every other
|
|
54
|
+
* entry above is a real runtime import and must be a declared dependency — `error-catalog.test.ts`
|
|
55
|
+
* holds the list to exactly that, because an undeclared one resolves through workspace symlinks
|
|
56
|
+
* here and through nothing in an installed app, where `x errors explain X_FLAG_EXPIRED` then
|
|
57
|
+
* refuses a code the wiki promises.
|
|
55
58
|
*/
|
|
56
|
-
export const CATALOG_OPTIONAL_HOSTS: readonly string[] = [
|
|
59
|
+
export const CATALOG_OPTIONAL_HOSTS: readonly string[] = [
|
|
60
|
+
'@ultimat3/admin',
|
|
61
|
+
'@ultimat3/scraping',
|
|
62
|
+
'@ultimat3/ui',
|
|
63
|
+
];
|
|
57
64
|
|
|
58
65
|
export interface ErrorCatalog {
|
|
59
66
|
/** Packages whose codes are now registered. */
|
|
60
67
|
readonly loaded: readonly string[];
|
|
61
68
|
/**
|
|
62
69
|
* Packages this process could not *resolve*, so their codes are absent from the answer. The one
|
|
63
|
-
* tolerated case is the optional host
|
|
64
|
-
*
|
|
70
|
+
* tolerated case is the optional host (`CATALOG_OPTIONAL_HOSTS`), which a bare CLI process may
|
|
71
|
+
* not have, and a list silently missing their codes is
|
|
65
72
|
* worse than one that says which packages are missing. A package that resolved and then threw is
|
|
66
73
|
* a defect, not a host gap, and goes to `failed`.
|
|
67
74
|
*/
|
package/src/error-codes.ts
CHANGED
|
@@ -30,6 +30,9 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
30
30
|
// `code: STALE` here is invisible to every reader of the code set, and silence there is
|
|
31
31
|
// permissive — the DRYer the author, the less the gate sees (#277).
|
|
32
32
|
'X_ERROR_CODE_UNRESOLVED',
|
|
33
|
+
// The fourth: registered, presented as live, and constructed by nothing — the state a code is left
|
|
34
|
+
// in when its last thrower moves on. The reference row has to say so in words.
|
|
35
|
+
'X_ERROR_CODE_UNTHROWN',
|
|
33
36
|
// Reported as `Finding`s rather than thrown, and unregistered until now because of it — so
|
|
34
37
|
// `x errors explain X_TYPECHECK_FAILED` refused a code `x verify` had just printed. A finding
|
|
35
38
|
// carries an `X_*` code to the same reader a throw does; the registry is what makes that code
|
|
@@ -175,29 +178,26 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
175
178
|
'X_SHOT_ISLAND_UNPHOTOGRAPHABLE',
|
|
176
179
|
'X_SHOT_ISLAND_UNSTUBBED_REQUEST',
|
|
177
180
|
'X_SHOT_ISLAND_MISSING',
|
|
178
|
-
// The
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
'
|
|
183
|
-
'
|
|
184
|
-
'X_E2E_EVALUATE_THREW',
|
|
185
|
-
'X_E2E_LOCATOR_EMPTY',
|
|
186
|
-
'X_E2E_LOCATOR_AMBIGUOUS',
|
|
187
|
-
'X_E2E_SERVICE_WORKER_ABSENT',
|
|
188
|
-
// The raw-CDP browser under that driver — `cdp-launch.ts`, `cdp-connection.ts`,
|
|
189
|
-
// `cdp-e2e-page.ts`, `cdp-browser.ts`. Four codes and not one, because the four repairs differ:
|
|
190
|
-
// install a browser, read the browser's own stderr, look at the page, raise a deadline.
|
|
191
|
-
'X_CDP_BROWSER_MISSING',
|
|
192
|
-
'X_CDP_LAUNCH_FAILED',
|
|
193
|
-
'X_CDP_CALL_FAILED',
|
|
194
|
-
'X_CDP_TIMEOUT',
|
|
181
|
+
// The raw-CDP shot page's own refusals (`cdp-shot-errors.ts` holds the classes): which host it
|
|
182
|
+
// may open, which element it was pointed at, and which key it was asked to press.
|
|
183
|
+
'X_SHOT_HOST_REFUSED',
|
|
184
|
+
'X_SHOT_ELEMENT_MISSING',
|
|
185
|
+
'X_SHOT_ELEMENT_UNREADY',
|
|
186
|
+
'X_SHOT_KEY_INVALID',
|
|
195
187
|
'X_GH_UNAVAILABLE',
|
|
196
188
|
'X_GH_NOT_AUTHENTICATED',
|
|
197
189
|
'X_GH_COMMAND_FAILED',
|
|
198
190
|
'X_GH_RESPONSE_INVALID',
|
|
199
191
|
'X_PR_NOT_FOUND',
|
|
200
192
|
'X_CI_RUN_NOT_FOUND',
|
|
193
|
+
'X_APP_NAME_EMPTY',
|
|
194
|
+
'X_APP_EMPTY',
|
|
195
|
+
'X_BOUNDARY_SURFACE_IMPORT',
|
|
196
|
+
'X_PERMISSION_UNGRANTED',
|
|
197
|
+
'X_JOB_UNREGISTERED',
|
|
198
|
+
'X_FEATURE_UNKNOWN',
|
|
199
|
+
'X_ROUTE_ASYNC_PAGE',
|
|
200
|
+
'X_BUDGET_PARAMS_UNDECLARED',
|
|
201
201
|
] as const;
|
|
202
202
|
|
|
203
203
|
/**
|
|
@@ -262,6 +262,8 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
262
262
|
X_ERROR_CODE_UNDOCUMENTED: 'a shipped error code has no row in the error reference',
|
|
263
263
|
X_ERROR_CODE_UNREGISTERED: 'the error reference documents a code no package registers',
|
|
264
264
|
X_ERROR_CODE_UNRESOLVED: 'an error code is written as a name this repository cannot resolve',
|
|
265
|
+
X_ERROR_CODE_UNTHROWN:
|
|
266
|
+
'a registered error code is constructed by nothing and its reference row does not say so',
|
|
265
267
|
X_FRAMEWORK_SCHEMA_FAILED: 'a framework table could not be created at boot',
|
|
266
268
|
X_STORAGE_UNWRITABLE: 'the storage disk this process needs cannot be written to',
|
|
267
269
|
X_STORAGE_SECRET_DEV: 'upload grants would be signed with the shipped development key',
|
|
@@ -310,7 +312,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
310
312
|
X_SECRETS_EDIT_FAILED: 'the editor exited non-zero, so nothing was resealed',
|
|
311
313
|
X_WORKSPACE_DEP_UNDECLARED: 'a workspace imports another workspace it does not declare',
|
|
312
314
|
X_PACKAGE_DUPLICATED: 'two copies of one registry-holding framework package are installed',
|
|
313
|
-
X_SHOT_BROWSER_MISSING: 'x shot
|
|
315
|
+
X_SHOT_BROWSER_MISSING: '`x shot` has no browser to launch',
|
|
314
316
|
X_UI_SHOT_ROUTE_UNKNOWN: 'a ui.* tool was asked for a path no route answers',
|
|
315
317
|
X_UI_SHOT_ROUTE_UNBUDGETED: 'a ui.* tool refused a route that declares no budget.js',
|
|
316
318
|
X_UI_INTERACT_STEPS_INVALID: 'a ui.interact step list is over its bounds or malformed',
|
|
@@ -324,22 +326,25 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
324
326
|
X_SHOT_ISLAND_UNPHOTOGRAPHABLE: 'the island never reached a state worth photographing',
|
|
325
327
|
X_SHOT_ISLAND_UNSTUBBED_REQUEST: 'the island requested something no state stub answers',
|
|
326
328
|
X_SHOT_ISLAND_MISSING: 'a declared island picture is not on disk',
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
X_E2E_LOCATOR_AMBIGUOUS: 'an e2e locator matched more than one element and was asked to click',
|
|
332
|
-
X_E2E_SERVICE_WORKER_ABSENT: 'no service worker took control of the page within the budget',
|
|
333
|
-
X_CDP_BROWSER_MISSING: 'no Chrome or Chromium is installed for the e2e driver to launch',
|
|
334
|
-
X_CDP_LAUNCH_FAILED: 'the browser started and never announced a DevTools endpoint',
|
|
335
|
-
X_CDP_CALL_FAILED: 'the browser refused a DevTools call',
|
|
336
|
-
X_CDP_TIMEOUT: 'a DevTools call did not answer inside its deadline',
|
|
329
|
+
X_SHOT_HOST_REFUSED: "x shot was asked to open a host outside the run's allow list",
|
|
330
|
+
X_SHOT_ELEMENT_MISSING: 'no element matched the selector before the deadline',
|
|
331
|
+
X_SHOT_ELEMENT_UNREADY: 'the element matched and never became ready to act on',
|
|
332
|
+
X_SHOT_KEY_INVALID: 'a key chord the browser cannot press',
|
|
337
333
|
X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
|
|
338
334
|
X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
|
|
339
335
|
X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
|
|
340
336
|
X_GH_RESPONSE_INVALID: "gh's output is not the shape the command reads",
|
|
341
337
|
X_PR_NOT_FOUND: 'no pull request for this checkout',
|
|
342
338
|
X_CI_RUN_NOT_FOUND: 'no workflow run for this branch',
|
|
339
|
+
X_APP_NAME_EMPTY: 'the app name has no letters or digits',
|
|
340
|
+
X_APP_EMPTY: 'an app with an app.config.ts registered no primitive',
|
|
341
|
+
X_BOUNDARY_SURFACE_IMPORT: 'a surface imports one SURFACE_SPECS does not allow',
|
|
342
|
+
X_PERMISSION_UNGRANTED: 'an action, query or route requires a permission no role grants',
|
|
343
|
+
X_JOB_UNREGISTERED: 'a job reached the manifest under its positional anonymous name',
|
|
344
|
+
X_FEATURE_UNKNOWN: 'x g --feature names a slice that does not exist',
|
|
345
|
+
X_ROUTE_ASYNC_PAGE: 'a route exports an async Page',
|
|
346
|
+
X_BUDGET_PARAMS_UNDECLARED:
|
|
347
|
+
'a dynamic route with a budget declares no prerender() paths to weigh it by',
|
|
343
348
|
};
|
|
344
349
|
|
|
345
350
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
package/src/error-pages.ts
CHANGED
|
@@ -34,9 +34,8 @@ const isStatus = (status: number): boolean =>
|
|
|
34
34
|
/**
|
|
35
35
|
* The app's page for one status, or `undefined`.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* answering the framework's page until the server was restarted.
|
|
37
|
+
* One read. How often it is asked is `errorPageHook`'s decision: per request under `x dev`, for
|
|
38
|
+
* `favicon.ts`'s reason, and once per status in a container.
|
|
40
39
|
*/
|
|
41
40
|
export async function errorPageOverride(root: string, status: number): Promise<string | undefined> {
|
|
42
41
|
if (!isStatus(status)) return undefined;
|
|
@@ -48,11 +47,24 @@ export async function errorPageOverride(root: string, status: number): Promise<s
|
|
|
48
47
|
* `ServerHooks.errorPage`, bound to one app root. Installed by `startWeb` so `x dev` and the
|
|
49
48
|
* container cannot answer a browser differently — the rule `assetRoutes` already holds for
|
|
50
49
|
* `/favicon.ico`.
|
|
50
|
+
*
|
|
51
|
+
* `perRequest` is `x dev`'s: an author drops a file into a running process. A container's image
|
|
52
|
+
* cannot change under it, so there each status is read ONCE and kept — per request, a burst of 500s
|
|
53
|
+
* during an outage paid a file read each, on the path that was already failing (plan 101, 12 g).
|
|
51
54
|
*/
|
|
52
|
-
export const errorPageHook =
|
|
53
|
-
(
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
export const errorPageHook = (root: string, options: { readonly perRequest: boolean }) => {
|
|
56
|
+
if (options.perRequest) {
|
|
57
|
+
return (status: number): Promise<string | undefined> => errorPageOverride(root, status);
|
|
58
|
+
}
|
|
59
|
+
const read = new Map<number, Promise<string | undefined>>();
|
|
60
|
+
return (status: number): Promise<string | undefined> => {
|
|
61
|
+
const known = read.get(status);
|
|
62
|
+
if (known !== undefined) return known;
|
|
63
|
+
const once = errorPageOverride(root, status);
|
|
64
|
+
read.set(status, once);
|
|
65
|
+
return once;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
56
68
|
|
|
57
69
|
/**
|
|
58
70
|
* The document that goes into a static export: the app's file if it has one, the framework's page
|