@ultimat3/cli 24.0.0 → 25.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +66 -87
- package/src/role-sync.ts +65 -6
- package/src/role-wake.ts +2 -3
- 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 +19 -30
- 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 +36 -15
- 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/dev-child-watch.ts
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
// 100% CPU, measured at ~50 MB/s of heap growth). Found on a laptop, 2026-09-29: two children of
|
|
6
6
|
// `cmd-dev-restart.live.test.ts`, six hours old, ppid 1, cwd deleted, one at 7 GB.
|
|
7
7
|
//
|
|
8
|
+
// Both facts and the stop itself hold on Windows: the supervisor is asked for by pid
|
|
9
|
+
// (`process.kill(pid, 0)`), since a Windows child's ppid never changes, and a stop arrives as a
|
|
10
|
+
// `drain` message over IPC — `kill('SIGTERM')` there is TerminateProcess, with no drain at all.
|
|
11
|
+
//
|
|
8
12
|
// And a stop that does not finish is finished for it. A drain is bounded by core's deadline, but a
|
|
9
13
|
// loop starved by a spin like the one above reaches its timers late or never, so every stop this
|
|
10
14
|
// child begins — a signal, the watch below, a restart — carries a hard exit past that bound.
|
|
@@ -29,8 +33,8 @@ export interface DevChildWatchInput {
|
|
|
29
33
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
30
34
|
/** Called once, when the child decides to leave. */
|
|
31
35
|
readonly leave: (why: DevChildGone) => void;
|
|
32
|
-
/** Test seams: the defaults read the process and the disk. */
|
|
33
|
-
readonly
|
|
36
|
+
/** Test seams: the defaults read the process table and the disk. */
|
|
37
|
+
readonly supervisorAlive?: (pid: number) => boolean;
|
|
34
38
|
readonly rootExists?: (root: string) => boolean;
|
|
35
39
|
readonly intervalMs?: number;
|
|
36
40
|
}
|
|
@@ -43,21 +47,46 @@ export function supervisorPid(
|
|
|
43
47
|
return Number.isInteger(said) && said > 0 ? said : undefined;
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
/** `process.kill(pid, 0)`'s answer: delivered, or refused for permission — both mean it exists. */
|
|
51
|
+
export function pidAlive(
|
|
52
|
+
pid: number,
|
|
53
|
+
kill: (pid: number, signal: 0) => void = process.kill,
|
|
54
|
+
): boolean {
|
|
55
|
+
try {
|
|
56
|
+
kill(pid, 0);
|
|
57
|
+
return true;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return (error as { readonly code?: unknown } | null)?.code === 'EPERM';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether the supervisor at `pid` still runs this child. POSIX asks the ppid — immune to a reused
|
|
65
|
+
* pid, and a supervisor that died before the first tick is still caught, since the child is
|
|
66
|
+
* reparented. Windows never reparents (the ppid stays the dead supervisor's forever), so there the
|
|
67
|
+
* process table is asked directly.
|
|
68
|
+
*/
|
|
69
|
+
export function defaultSupervisorAlive(
|
|
70
|
+
platform: string = process.platform,
|
|
71
|
+
): (pid: number) => boolean {
|
|
72
|
+
return platform === 'win32' ? (pid) => pidAlive(pid) : (pid) => process.ppid === pid;
|
|
73
|
+
}
|
|
74
|
+
|
|
46
75
|
/**
|
|
47
|
-
* Polls both facts until one fails, then calls `leave` once and stops. Answers the stop.
|
|
48
|
-
*
|
|
49
|
-
* a
|
|
76
|
+
* Polls both facts until one fails, then calls `leave` once and stops. Answers the stop. Polled
|
|
77
|
+
* rather than a pipe from the supervisor: stdin is the terminal's (`inherit`), and IPC closing is
|
|
78
|
+
* not a fact Bun reports to the child.
|
|
50
79
|
*/
|
|
51
80
|
export function watchDevChild(input: DevChildWatchInput): () => void {
|
|
52
81
|
const expected = supervisorPid(input.env);
|
|
53
|
-
const
|
|
82
|
+
const alive = input.supervisorAlive ?? defaultSupervisorAlive();
|
|
54
83
|
const exists = input.rootExists ?? existsSync;
|
|
55
84
|
let left = false;
|
|
56
85
|
const timer = setInterval(
|
|
57
86
|
() => {
|
|
58
87
|
if (left) return;
|
|
59
88
|
const why: DevChildGone | undefined =
|
|
60
|
-
expected !== undefined &&
|
|
89
|
+
expected !== undefined && !alive(expected)
|
|
61
90
|
? 'supervisor'
|
|
62
91
|
: exists(input.root)
|
|
63
92
|
? undefined
|
|
@@ -116,10 +145,26 @@ export function hardExit(
|
|
|
116
145
|
};
|
|
117
146
|
}
|
|
118
147
|
|
|
148
|
+
/** What the supervisor sends for a stop: the signal it would have forwarded on POSIX. */
|
|
149
|
+
export interface DevDrainMessage {
|
|
150
|
+
readonly type: 'x-dev-drain';
|
|
151
|
+
readonly signal: 'SIGINT' | 'SIGTERM';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function isDevDrainMessage(message: unknown): message is DevDrainMessage {
|
|
155
|
+
if (typeof message !== 'object' || message === null) return false;
|
|
156
|
+
const { type, signal } = message as Record<string, unknown>;
|
|
157
|
+
return type === 'x-dev-drain' && (signal === 'SIGINT' || signal === 'SIGTERM');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The exit code a stop for `signal` answers: 128 + its number, as a shell reports it. */
|
|
161
|
+
export const signalExitCode = (signal: 'SIGINT' | 'SIGTERM'): number =>
|
|
162
|
+
signal === 'SIGINT' ? 130 : 143;
|
|
163
|
+
|
|
119
164
|
/**
|
|
120
|
-
* The child's watch, wired to core's drain: the supervisor's death or the root's,
|
|
121
|
-
* SIGTERM, each begin a stop that ends within
|
|
122
|
-
* marks why, so the hold's exit can answer a code.
|
|
165
|
+
* The child's watch, wired to core's drain: the supervisor's death or the root's, SIGINT or
|
|
166
|
+
* SIGTERM, and the supervisor's `drain` message each begin a stop that ends within
|
|
167
|
+
* `devHardExitMs` however the drain fares. `onGone` marks why, so the hold's exit can answer a code.
|
|
123
168
|
*/
|
|
124
169
|
export function startDevChildWatch(
|
|
125
170
|
root: string,
|
|
@@ -128,8 +173,14 @@ export function startDevChildWatch(
|
|
|
128
173
|
): { readonly stopping: (code: number) => void } {
|
|
129
174
|
const stopping = hardExit((code) => process.exit(code));
|
|
130
175
|
// Beside core's own handlers, never instead of them: the drain is theirs, the deadline is this.
|
|
131
|
-
process.on('SIGINT', () => stopping(
|
|
132
|
-
process.on('SIGTERM', () => stopping(
|
|
176
|
+
process.on('SIGINT', () => stopping(signalExitCode('SIGINT')));
|
|
177
|
+
process.on('SIGTERM', () => stopping(signalExitCode('SIGTERM')));
|
|
178
|
+
// The supervisor's stop, on every platform: the same drain a signal begins, by message.
|
|
179
|
+
process.on('message', (message: unknown) => {
|
|
180
|
+
if (!isDevDrainMessage(message)) return;
|
|
181
|
+
stopping(signalExitCode(message.signal));
|
|
182
|
+
void drain(message.signal);
|
|
183
|
+
});
|
|
133
184
|
watchDevChild({
|
|
134
185
|
root,
|
|
135
186
|
env,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { proveSameOrigin, renderFixShellArg, UltimateError } from '@ultimat3/core';
|
|
6
6
|
import type { UltimateRequest } from '@ultimat3/http';
|
|
7
|
-
import { csrfBlocked, HttpError,
|
|
7
|
+
import { csrfBlocked, HttpError, jsonResponse, selfOrigin } from '@ultimat3/http';
|
|
8
8
|
import { t } from '@ultimat3/schema';
|
|
9
9
|
import { isLoopbackHostname } from './loopback-host';
|
|
10
10
|
|
package/src/dev-dashboard.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
import { recentInvalidations } from '@ultimat3/cache';
|
|
25
25
|
import type { Role } from '@ultimat3/core';
|
|
26
26
|
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
27
|
-
import {
|
|
27
|
+
import { jsonResponse } from '@ultimat3/http';
|
|
28
28
|
import type { MemoryMailDriver } from '@ultimat3/mail';
|
|
29
29
|
import { isMemoryDriver } from '@ultimat3/mail';
|
|
30
30
|
import type { Manifest } from '@ultimat3/manifest';
|
|
@@ -38,6 +38,7 @@ import type { Finding } from './output';
|
|
|
38
38
|
import type { DevServices } from './runtime-bindings';
|
|
39
39
|
import type { RunningServices } from './runtime-services';
|
|
40
40
|
import { loopFacts } from './statement-loop';
|
|
41
|
+
import { styleBundle } from './style-bundle';
|
|
41
42
|
|
|
42
43
|
export interface DevStatus {
|
|
43
44
|
readonly url: string;
|
|
@@ -241,6 +242,9 @@ export function devDashboardRoutes(input: DevDashboardInput): readonly Route[] {
|
|
|
241
242
|
basePath: DEV_BASE_PATH,
|
|
242
243
|
panels,
|
|
243
244
|
sources: devSources(input),
|
|
245
|
+
// The drawn tabs render ui's components, whose rules ride the `app/` surface's bundle — the
|
|
246
|
+
// same URL an `app/` document links, asked per request because a rebuild moves the hash.
|
|
247
|
+
stylesheetHref: () => styleBundle().hrefFor('app'),
|
|
244
248
|
...(input.env === undefined ? {} : { env: input.env }),
|
|
245
249
|
});
|
|
246
250
|
|
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 {
|
|
17
|
+
import { linkSync, mkdirSync, readFileSync, renameSync, 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';
|
|
@@ -142,6 +142,26 @@ export class DevLockUnreadableError extends UltimateError {
|
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* The state directory refused the claim itself: it could not be created, the lock could not be
|
|
147
|
+
* staged in it, or the filesystem would not hard-link the staged file into place. None of that is
|
|
148
|
+
* another `x dev` — only `EEXIST` is — and escaping raw it reached the operator as
|
|
149
|
+
* `X_CLI_UNEXPECTED` with `fix: x doctor --json`, which says nothing about `.x/`.
|
|
150
|
+
*/
|
|
151
|
+
export class DevStateUnwritableError extends UltimateError {
|
|
152
|
+
constructor(input: { readonly stateDir: string; readonly error: unknown }) {
|
|
153
|
+
// Read the field, never cast and dereference: `unknown` is what a catch hands over.
|
|
154
|
+
const errno = stringField(input.error, 'code') ?? 'an unreported errno';
|
|
155
|
+
super({
|
|
156
|
+
code: 'X_DEV_STATE_UNWRITABLE',
|
|
157
|
+
cause: `${input.stateDir} could not be created, written, or hard-linked into (${errno}), so x dev cannot take its lock — a read-only checkout, a .x/ this user may not write, or a filesystem without hard links`,
|
|
158
|
+
fix: `ls -ld ${quoteArg(input.stateDir)} # make it writable by this user, on a filesystem that supports hard links, then re-run x dev`,
|
|
159
|
+
meta: { stateDir: input.stateDir, errno },
|
|
160
|
+
sourceError: input.error,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
/** Whatever is listening, as far as the OS will say. Both fields absent when it will not say. */
|
|
146
166
|
export interface PortHolder {
|
|
147
167
|
readonly pid?: number;
|
|
@@ -151,7 +171,8 @@ export interface PortHolder {
|
|
|
151
171
|
|
|
152
172
|
/**
|
|
153
173
|
* Who holds the port. `ss` first because it is present on every Linux box the framework targets and
|
|
154
|
-
* needs no elevation for your own processes; `lsof` is the macOS answer
|
|
174
|
+
* needs no elevation for your own processes; `lsof` is the macOS answer; `netstat -ano` plus
|
|
175
|
+
* `tasklist` the Windows one.
|
|
155
176
|
*
|
|
156
177
|
* Through `exec.ts`, like every other subprocess the CLI runs, so a test injects a fake `Runner`
|
|
157
178
|
* rather than racing a real socket.
|
|
@@ -159,7 +180,7 @@ export interface PortHolder {
|
|
|
159
180
|
* EVERY PROBE IS CAUGHT SEPARATELY. `exec` refuses a missing program with `X_CLI_UNEXPECTED`, and
|
|
160
181
|
* letting that escape would mean a box without `ss` gets the CLI's catch-all instead of
|
|
161
182
|
* `X_PORT_IN_USE` — the exact substitution this module exists to end. A missing `ss` must fall
|
|
162
|
-
* through to `lsof`, and a box with
|
|
183
|
+
* through to `lsof`, and a box with none still gets the refusal, just without a pid in it.
|
|
163
184
|
*/
|
|
164
185
|
export const portHolder = async (port: number, runner: Runner = exec): Promise<PortHolder> => {
|
|
165
186
|
const probe = async (command: readonly string[]): Promise<string> => {
|
|
@@ -185,9 +206,38 @@ export const portHolder = async (port: number, runner: Runner = exec): Promise<P
|
|
|
185
206
|
if (pid !== undefined && /^\d+$/.test(pid)) {
|
|
186
207
|
return command === undefined ? { pid: Number(pid) } : { command, pid: Number(pid) };
|
|
187
208
|
}
|
|
188
|
-
|
|
209
|
+
|
|
210
|
+
// Windows has neither: `netstat -ano` names the listening pid, `tasklist` its image.
|
|
211
|
+
const listener = netstatListener(await probe(['netstat', '-ano', '-p', 'TCP']), port);
|
|
212
|
+
if (listener === undefined) return {};
|
|
213
|
+
const image = tasklistImage(
|
|
214
|
+
await probe(['tasklist', '/FI', `PID eq ${String(listener)}`, '/FO', 'CSV', '/NH']),
|
|
215
|
+
);
|
|
216
|
+
return image === undefined ? { pid: listener } : { command: image, pid: listener };
|
|
189
217
|
};
|
|
190
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The pid `netstat -ano` reports LISTENING on `port`, any local address: `TCP 127.0.0.1:3000
|
|
221
|
+
* 0.0.0.0:0 LISTENING 4242`, and `[::1]:3000` for IPv6. The state column is the word Windows
|
|
222
|
+
* prints in English on every locale this was read on; an unrecognised line is skipped.
|
|
223
|
+
*/
|
|
224
|
+
export function netstatListener(text: string, port: number): number | undefined {
|
|
225
|
+
for (const line of text.split(/\r?\n/)) {
|
|
226
|
+
const [proto, local, , state, pid] = line.trim().split(/\s+/);
|
|
227
|
+
if (proto !== 'TCP' || state !== 'LISTENING' || pid === undefined || !/^\d+$/.test(pid)) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (local?.endsWith(`:${String(port)}`) === true) return Number(pid);
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The image name off `tasklist /FO CSV /NH`'s one row — `"bun.exe","4242",…` — or `undefined`. */
|
|
236
|
+
export function tasklistImage(text: string): string | undefined {
|
|
237
|
+
const name = /^"([^"]+)","\d+"/m.exec(text.trim())?.[1];
|
|
238
|
+
return name === undefined || name === '' ? undefined : name;
|
|
239
|
+
}
|
|
240
|
+
|
|
191
241
|
/**
|
|
192
242
|
* The port is bound by something that is not us. Named separately from the lock case because the
|
|
193
243
|
* remedy is different: a stranger's port is worked around with `--port`, while a second `x dev` on
|
|
@@ -241,33 +291,46 @@ export const isPortBound = (port: number, hostname: string): boolean => {
|
|
|
241
291
|
}
|
|
242
292
|
};
|
|
243
293
|
|
|
294
|
+
/** A sibling of the lock, unique to this process and call, where its bytes are staged. */
|
|
295
|
+
const stagingPath = (path: string): string =>
|
|
296
|
+
`${path}.${process.pid}.${crypto.randomUUID().slice(0, 8)}.tmp`;
|
|
297
|
+
|
|
298
|
+
const lockText = (lock: DevLock): string => `${JSON.stringify(lock, null, 2)}\n`;
|
|
299
|
+
|
|
244
300
|
/**
|
|
245
301
|
* Take the lock, or answer `false` because someone else holds it.
|
|
246
302
|
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
303
|
+
* Written whole to a staging file first, then `linkSync`ed into place: the link is the create AND
|
|
304
|
+
* the exclusivity in ONE syscall (`EEXIST` when the name is taken), so two boots racing this cannot
|
|
305
|
+
* both come back `true` — and the lock path never exists without its contents. It was `openSync(
|
|
306
|
+
* path, 'wx')` and THEN a write; a racing preflight that read the file between the two parsed an
|
|
307
|
+
* empty lock as stale, unlinked the live claim and took the slot itself.
|
|
251
308
|
*
|
|
252
|
-
* Only `EEXIST` is "someone else has it".
|
|
253
|
-
*
|
|
254
|
-
*
|
|
309
|
+
* Only `EEXIST` is "someone else has it". Any other refusal of the staging write or the link — a
|
|
310
|
+
* `.x/` this user may not write, a filesystem without hard links — is `X_DEV_STATE_UNWRITABLE`
|
|
311
|
+
* naming the directory and the errno, the original kept as its `sourceError`.
|
|
255
312
|
*/
|
|
256
|
-
function claimExclusive(path: string, lock: DevLock): boolean {
|
|
257
|
-
|
|
313
|
+
function claimExclusive(stateDir: string, path: string, lock: DevLock): boolean {
|
|
314
|
+
const staged = stagingPath(path);
|
|
258
315
|
try {
|
|
259
|
-
|
|
316
|
+
writeFileSync(staged, lockText(lock));
|
|
260
317
|
} catch (error) {
|
|
261
|
-
|
|
262
|
-
if (stringField(error, 'code') === 'EEXIST') return false;
|
|
263
|
-
throw error;
|
|
318
|
+
throw new DevStateUnwritableError({ stateDir, error });
|
|
264
319
|
}
|
|
265
320
|
try {
|
|
266
|
-
|
|
321
|
+
linkSync(staged, path);
|
|
322
|
+
return true;
|
|
323
|
+
} catch (error) {
|
|
324
|
+
// Same rule as `isProcessAlive` above: read the field, never cast and dereference.
|
|
325
|
+
if (stringField(error, 'code') === 'EEXIST') return false;
|
|
326
|
+
throw new DevStateUnwritableError({ stateDir, error });
|
|
267
327
|
} finally {
|
|
268
|
-
|
|
328
|
+
try {
|
|
329
|
+
unlinkSync(staged);
|
|
330
|
+
} catch {
|
|
331
|
+
// Already gone; the staging name is unique to this call, so nothing else can need it.
|
|
332
|
+
}
|
|
269
333
|
}
|
|
270
|
-
return true;
|
|
271
334
|
}
|
|
272
335
|
|
|
273
336
|
/** Whatever is on disk right now, or `null` if it is absent or half-written. */
|
|
@@ -321,7 +384,11 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
|
|
|
321
384
|
const release = (): void => clearLock(input.stateDir);
|
|
322
385
|
// The lock lives inside the state directory, which the database has not created yet — this runs
|
|
323
386
|
// before anything boots, which is the whole point of it.
|
|
324
|
-
|
|
387
|
+
try {
|
|
388
|
+
mkdirSync(input.stateDir, { recursive: true });
|
|
389
|
+
} catch (error) {
|
|
390
|
+
throw new DevStateUnwritableError({ stateDir: input.stateDir, error });
|
|
391
|
+
}
|
|
325
392
|
const mine: DevLock = {
|
|
326
393
|
pid: process.pid,
|
|
327
394
|
port: input.port,
|
|
@@ -332,7 +399,7 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
|
|
|
332
399
|
};
|
|
333
400
|
let clearedStale = false;
|
|
334
401
|
|
|
335
|
-
if (!claimExclusive(path, mine)) {
|
|
402
|
+
if (!claimExclusive(input.stateDir, path, mine)) {
|
|
336
403
|
const held = await readLock(path);
|
|
337
404
|
if (held !== null && alive(held.pid)) {
|
|
338
405
|
throw new DevAlreadyRunningError({
|
|
@@ -349,7 +416,7 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
|
|
|
349
416
|
// Already gone, or not ours to remove. The claim below is what decides.
|
|
350
417
|
}
|
|
351
418
|
clearedStale = true;
|
|
352
|
-
if (!claimExclusive(path, mine)) {
|
|
419
|
+
if (!claimExclusive(input.stateDir, path, mine)) {
|
|
353
420
|
// Lost the race for the slot we just cleared. `held` is the best identity available — a
|
|
354
421
|
// half-written file parses as `null` for microseconds — and refusing on a stale pid beats
|
|
355
422
|
// the alternative, which is two processes writing one single-writer data directory.
|
|
@@ -388,7 +455,12 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
|
|
|
388
455
|
* with the address the server actually bound, which is the one field the preflight could not know.
|
|
389
456
|
*/
|
|
390
457
|
export const writeLock = async (stateDir: string, lock: DevLock): Promise<void> => {
|
|
391
|
-
|
|
458
|
+
// Staged and renamed over the claim, never rewritten in place: a truncate-then-write leaves an
|
|
459
|
+
// empty lock for an instant, and a preflight reading it then would clear a live claim as stale.
|
|
460
|
+
const path = lockPath(stateDir);
|
|
461
|
+
const staged = stagingPath(path);
|
|
462
|
+
writeFileSync(staged, lockText(lock));
|
|
463
|
+
renameSync(staged, path);
|
|
392
464
|
};
|
|
393
465
|
|
|
394
466
|
/** Remove it. Safe to call twice — shutdown paths overlap, and a throw here would mask the real one. */
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The `x dev` reload: one save in, the manifest, the findings and the island bundles rebuilt as
|
|
2
|
+
// one generation, the ISR store emptied, and a save this process cannot serve handed to the
|
|
3
|
+
// supervisor as a restart. Coalesced, so a tick arriving mid-build is one trailing rebuild.
|
|
4
|
+
|
|
5
|
+
import type { Manifest } from '@ultimat3/manifest';
|
|
6
|
+
import type { IsrController } from '@ultimat3/render/server';
|
|
7
|
+
import { appManifest } from './app-manifest';
|
|
8
|
+
import type { StalePin } from './app-reload-graph';
|
|
9
|
+
import { takeStalePins } from './app-reload-graph';
|
|
10
|
+
import type { ReloadTrigger } from './dev-reload';
|
|
11
|
+
import { coalesceReloads } from './dev-reload';
|
|
12
|
+
import { restartFinding } from './dev-supervisor';
|
|
13
|
+
import type { IslandBundle } from './island-bundle';
|
|
14
|
+
import { buildIslands } from './island-bundle';
|
|
15
|
+
import type { Finding } from './output';
|
|
16
|
+
import { findingFrom } from './output';
|
|
17
|
+
|
|
18
|
+
export interface DevState {
|
|
19
|
+
manifest: Manifest;
|
|
20
|
+
reloads: number;
|
|
21
|
+
/** A save that will not build. Replaced on every attempt, so a fixed file clears it. */
|
|
22
|
+
reloadFinding: Finding | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Modules that would not import and primitives that would not register, as of the LAST scan —
|
|
25
|
+
* the boot's, then each rebuild's. A page saved with a syntax error is a finding here until the
|
|
26
|
+
* save that fixes it, and the boot's list alone would never show it.
|
|
27
|
+
*/
|
|
28
|
+
appFindings: readonly Finding[];
|
|
29
|
+
/**
|
|
30
|
+
* The client entries, rebuilt on the same tick as the manifest. An island is the one module this
|
|
31
|
+
* process never imports, so a fresh `Bun.build` is the whole of its reload. The route module
|
|
32
|
+
* beside it is re-imported by that same scan (`app-load.ts`) — the two are one generation, or
|
|
33
|
+
* a save serves a new island under an old page, which is what it did until 2026-09-07.
|
|
34
|
+
*/
|
|
35
|
+
islands: IslandBundle;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What a rebuild reports to, read off `StartDevOptions`. */
|
|
39
|
+
export interface DevRebuildOptions {
|
|
40
|
+
readonly root: string;
|
|
41
|
+
readonly onReload?: (file: string, durationMs: number) => void;
|
|
42
|
+
readonly onRestart?: (pins: readonly StalePin[]) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The watcher's `onChange`: rebuild `state` for the saved `file`, never rejecting. */
|
|
46
|
+
export function devRebuilder(
|
|
47
|
+
options: DevRebuildOptions,
|
|
48
|
+
state: DevState,
|
|
49
|
+
isr: IsrController,
|
|
50
|
+
): ReloadTrigger {
|
|
51
|
+
// One rebuild at a time, and the last save wins: a tick arriving mid-build coalesces into ONE
|
|
52
|
+
// trailing rebuild instead of racing the one in flight for `state.manifest` and `state.islands`.
|
|
53
|
+
return coalesceReloads(
|
|
54
|
+
async (file) => {
|
|
55
|
+
const started = performance.now();
|
|
56
|
+
const [{ manifest, findings }, islands] = await Promise.all([
|
|
57
|
+
appManifest(options.root),
|
|
58
|
+
buildIslands(options.root),
|
|
59
|
+
]);
|
|
60
|
+
state.manifest = manifest;
|
|
61
|
+
state.appFindings = findings;
|
|
62
|
+
state.islands = islands;
|
|
63
|
+
for (const path of isr.store().paths()) isr.store().delete(path);
|
|
64
|
+
state.reloads += 1;
|
|
65
|
+
// Read on EVERY rebuild, so a pin one save reached is never reported against the next save.
|
|
66
|
+
const pins = takeStalePins();
|
|
67
|
+
state.reloadFinding =
|
|
68
|
+
pins.length > 0 && options.onRestart === undefined
|
|
69
|
+
? restartFinding(options.root, pins)
|
|
70
|
+
: undefined;
|
|
71
|
+
if (pins.length > 0 && options.onRestart !== undefined) options.onRestart(pins);
|
|
72
|
+
else options.onReload?.(file, Math.round(performance.now() - started));
|
|
73
|
+
},
|
|
74
|
+
// Same rule as a module that will not import: a save the manifest cannot be rebuilt from is
|
|
75
|
+
// a finding on `/_x`, never an unhandled rejection that takes the dev server down.
|
|
76
|
+
(error: unknown, file: string) => {
|
|
77
|
+
state.reloadFinding = { ...findingFrom(error), at: file };
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// `x dev`'s restart, worker first (issue #677). A save that reaches a primitive-defining module
|
|
2
|
+
// restarts the child, and the drain that ends it used to be the FIRST thing it did: a job that ran
|
|
3
|
+
// past the drain budget was aborted `X_DRAINING` and requeued, so a non-idempotent one (a bank
|
|
4
|
+
// login) ran twice. The worker is retired first — `stop()` claims nothing and aborts nothing — and
|
|
5
|
+
// only then is the drain asked to release the port, the lock and the embedded database.
|
|
6
|
+
|
|
7
|
+
import { drain, logger, renderThrowable } from '@ultimat3/core';
|
|
8
|
+
import type { Worker } from '@ultimat3/jobs';
|
|
9
|
+
import { msg } from './messages';
|
|
10
|
+
import { writeErrorLine } from './write-line';
|
|
11
|
+
|
|
12
|
+
/** The two calls a retire makes, so a test hands in a worker without booting a queue. */
|
|
13
|
+
export type RetiringWorker = Pick<Worker, 'stats' | 'stop'>;
|
|
14
|
+
|
|
15
|
+
export interface RetireDeps {
|
|
16
|
+
/** The process drain the restart ends in. Default: core's `drain`. */
|
|
17
|
+
readonly drain: (reason: string) => Promise<void>;
|
|
18
|
+
/** Where the wait is said. Default: stderr — fd 1 may be `--json`'s one document. */
|
|
19
|
+
readonly say: (line: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_DEPS: RetireDeps = { drain, say: writeErrorLine };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Retire `worker`, then drain. Unbounded on purpose: in development a restart that waits for a
|
|
26
|
+
* long job is the cheaper failure than a side effect run twice, and the line it prints says how to
|
|
27
|
+
* stop waiting — Ctrl-C is a real drain, which binds the worker's teardown to the drain deadline
|
|
28
|
+
* and cuts off what is still running there. A retire that FAILS still drains: a restart left
|
|
29
|
+
* half-done is a child that stopped claiming and never exits.
|
|
30
|
+
*/
|
|
31
|
+
export async function retireThenDrain(
|
|
32
|
+
worker: RetiringWorker | null,
|
|
33
|
+
deps: RetireDeps = DEFAULT_DEPS,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
try {
|
|
36
|
+
if (worker !== null) {
|
|
37
|
+
const { inFlight } = await worker.stats();
|
|
38
|
+
if (inFlight > 0) deps.say(msg('cli.dev.restartWaiting', { count: inFlight }));
|
|
39
|
+
await worker.stop('x dev restart');
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
logger.warn('cli.dev.restart-retire-failed', { error: renderThrowable(error) });
|
|
43
|
+
} finally {
|
|
44
|
+
await deps.drain('restart');
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/dev-route-table.ts
CHANGED
|
@@ -4,21 +4,23 @@
|
|
|
4
4
|
|
|
5
5
|
import type { RealtimeConfig } from '@ultimat3/core';
|
|
6
6
|
import type { RateLimitStore, Route } from '@ultimat3/http';
|
|
7
|
-
import {
|
|
7
|
+
import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
8
|
+
import { describePages } from '@ultimat3/render';
|
|
8
9
|
import type { IsrController } from '@ultimat3/render/server';
|
|
10
|
+
import type { ImageTransformDriver } from '@ultimat3/seo';
|
|
9
11
|
import type { Storage } from '@ultimat3/storage';
|
|
10
12
|
import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
|
|
11
13
|
import { mountAppMcp } from './app-mcp';
|
|
12
14
|
import type { DevDashboardInput } from './dev-dashboard';
|
|
13
15
|
import { devDashboardRoutes } from './dev-dashboard';
|
|
14
|
-
import {
|
|
16
|
+
import { errorPageStyleBodies } from './error-page-csp';
|
|
15
17
|
import type { IslandBundle } from './island-bundle';
|
|
16
18
|
import { islandHarnessRoutes } from './island-harness-route';
|
|
17
19
|
import { islandRoutes } from './island-routes';
|
|
18
20
|
import { loadIslandStates } from './island-states-load';
|
|
19
21
|
import { loadNavigation, pageNavigation } from './page-navigation';
|
|
20
22
|
import type { PageSpeculation } from './page-speculation';
|
|
21
|
-
import { loadSpeculation, pageSpeculation } from './page-speculation';
|
|
23
|
+
import { loadSpeculation, otherLocaleSegments, pageSpeculation } from './page-speculation';
|
|
22
24
|
import { pageSync } from './page-sync';
|
|
23
25
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
24
26
|
import { adminMountRoutes } from './runtime-admin';
|
|
@@ -33,6 +35,7 @@ import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
|
33
35
|
import { serviceWorkerRoutes } from './sw-routes';
|
|
34
36
|
import type { ThemeBoot } from './theme-boot';
|
|
35
37
|
import { loadThemeMode, themeBoot } from './theme-boot';
|
|
38
|
+
import { loadThemeBrand } from './theme-brand';
|
|
36
39
|
|
|
37
40
|
export interface DevRouteTableInput {
|
|
38
41
|
readonly root: string;
|
|
@@ -53,6 +56,12 @@ export interface DevRouteTableInput {
|
|
|
53
56
|
* empty it: a stored render outlives the module that produced it.
|
|
54
57
|
*/
|
|
55
58
|
readonly isr?: IsrController | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* `apps/<app>/runtime.ts`'s `images` driver, handed to `/media` exactly as the container hands it
|
|
61
|
+
* (`serve-web.ts`): dropped here, dev answered every variant from the builtin pipeline and a
|
|
62
|
+
* CDN-backed transform was first exercised in production.
|
|
63
|
+
*/
|
|
64
|
+
readonly images?: ImageTransformDriver | undefined;
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
export interface DevRouteTable {
|
|
@@ -61,8 +70,11 @@ export interface DevRouteTable {
|
|
|
61
70
|
readonly theme: ThemeBoot;
|
|
62
71
|
/** The speculation rules, admitted the same way — `undefined` when no document carries any. */
|
|
63
72
|
readonly speculation: PageSpeculation | undefined;
|
|
64
|
-
/**
|
|
65
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Every inline `<style>` body these routes put in a document: the app's error pages' and its
|
|
75
|
+
* brand's (`theme-brand.ts`) — what `startRoles({ inlineStyles })` hashes into `style-src`.
|
|
76
|
+
*/
|
|
77
|
+
readonly inlineStyles: readonly string[];
|
|
66
78
|
/** Where the app's default MCP endpoint was mounted, or `null`. */
|
|
67
79
|
readonly mcpPath: string | null;
|
|
68
80
|
/** Every mounted MCP endpoint's path, default first — one per population the app serves. */
|
|
@@ -74,6 +86,8 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
74
86
|
// is mounted, and the 0kb baseline is not spent on a `<link>` to a file that does not exist.
|
|
75
87
|
const pwa = await loadPwaArtifacts(input.root);
|
|
76
88
|
const theme = themeBoot(await loadThemeMode(input.root));
|
|
89
|
+
// Read at boot, like the theme mode: a save to the theme module is applied on the next `x dev`.
|
|
90
|
+
const brand = await loadThemeBrand(input.root);
|
|
77
91
|
// `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
|
|
78
92
|
const site = await loadSiteSettings(input.root);
|
|
79
93
|
const origin = publicOrigin(input.env, site);
|
|
@@ -87,8 +101,12 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
87
101
|
const speculation = pageSpeculation({
|
|
88
102
|
config: await loadSpeculation(input.root),
|
|
89
103
|
client: declared.surfaces,
|
|
104
|
+
localeSegments: otherLocaleSegments(await appLocaleSet(input.root)),
|
|
90
105
|
});
|
|
91
|
-
const
|
|
106
|
+
const inlineStyles = [
|
|
107
|
+
...(await errorPageStyleBodies(input.root)),
|
|
108
|
+
...(brand === undefined ? [] : [brand.style]),
|
|
109
|
+
];
|
|
92
110
|
// Built once at boot and NOT rebuilt with the islands on a watcher tick: a service worker that
|
|
93
111
|
// changes under a page it controls is the update path, and one per keystroke exercises it per save.
|
|
94
112
|
const serviceWorker =
|
|
@@ -97,7 +115,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
97
115
|
: serviceWorkerArtifacts({
|
|
98
116
|
pwa,
|
|
99
117
|
buildId: input.buildId,
|
|
100
|
-
routes:
|
|
118
|
+
routes: describePages(),
|
|
101
119
|
islands: input.islands(),
|
|
102
120
|
styles: styleBundle(),
|
|
103
121
|
scripts: [
|
|
@@ -126,6 +144,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
126
144
|
...assetRoutes({
|
|
127
145
|
root: input.root,
|
|
128
146
|
storage: served,
|
|
147
|
+
...(input.images === undefined ? {} : { images: input.images }),
|
|
129
148
|
...(pwa === undefined ? {} : { pwa }),
|
|
130
149
|
}),
|
|
131
150
|
...storageRoutes({ storage: served }),
|
|
@@ -146,6 +165,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
146
165
|
...islandHarnessRoutes({
|
|
147
166
|
islands: () => input.islands(),
|
|
148
167
|
states: () => loadIslandStates(input.root),
|
|
168
|
+
devUrl: () => input.dashboard.status().url,
|
|
149
169
|
}),
|
|
150
170
|
...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
|
|
151
171
|
...sync.routes,
|
|
@@ -157,6 +177,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
157
177
|
...adminMountRoutes({
|
|
158
178
|
buildId: input.buildId,
|
|
159
179
|
themeHead: theme.head,
|
|
180
|
+
...(brand === undefined ? {} : { brandHead: brand.head }),
|
|
160
181
|
...(origin === undefined ? {} : { origin }),
|
|
161
182
|
}),
|
|
162
183
|
...appRoutes({
|
|
@@ -167,6 +188,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
167
188
|
persisted: sync.persisted,
|
|
168
189
|
...(navigation.head === undefined ? {} : { navigation: navigation.head }),
|
|
169
190
|
themeHead: theme.head,
|
|
191
|
+
...(brand === undefined ? {} : { brandHead: brand.head }),
|
|
170
192
|
...(speculation === undefined ? {} : { speculationHead: speculation.head }),
|
|
171
193
|
...(origin === undefined ? {} : { origin }),
|
|
172
194
|
...(pwa === undefined
|
|
@@ -179,7 +201,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
179
201
|
routes,
|
|
180
202
|
theme,
|
|
181
203
|
speculation,
|
|
182
|
-
|
|
204
|
+
inlineStyles,
|
|
183
205
|
mcpPath: mcpMount.path,
|
|
184
206
|
mcpPaths: mcpMount.paths,
|
|
185
207
|
};
|