@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/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,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The doors in front of `/_x` that a browser can knock on. `x dev` binds loopback, but a page the
|
|
2
|
+
// developer has open can still reach it: by DNS rebinding (a hostile name that resolves to
|
|
3
|
+
// 127.0.0.1, read back same-origin) and by a cross-site request to the SQL panel.
|
|
4
|
+
|
|
5
|
+
import { proveSameOrigin, renderFixShellArg, UltimateError } from '@ultimat3/core';
|
|
6
|
+
import type { UltimateRequest } from '@ultimat3/http';
|
|
7
|
+
import { csrfBlocked, HttpError, jsonResponse, selfOrigin } from '@ultimat3/http';
|
|
8
|
+
import { t } from '@ultimat3/schema';
|
|
9
|
+
import { isLoopbackHostname } from './loopback-host';
|
|
10
|
+
|
|
11
|
+
/** A `/_x` request named a Host that is not this machine. Never echoes the Host it was sent. */
|
|
12
|
+
export class DevHostRefusedError extends UltimateError {
|
|
13
|
+
constructor(input: { devUrl: string }) {
|
|
14
|
+
super({
|
|
15
|
+
code: 'X_DEV_HOST_REFUSED',
|
|
16
|
+
cause:
|
|
17
|
+
'a /_x request named a Host that is not localhost, a *.localhost name or a loopback address — the shape of a DNS-rebinding page reading the dev dashboard',
|
|
18
|
+
fix: `curl -sS -H 'accept: application/json' ${renderFixShellArg(`${input.devUrl}/_x`, 'http://localhost:3000/_x')}`,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The Host as sent, else the one the URL was built from — Bun builds it from the header. */
|
|
24
|
+
const hostnameOf = (request: UltimateRequest): string | undefined => {
|
|
25
|
+
const header = request.header('host') ?? request.url.host;
|
|
26
|
+
return URL.parse(`http://${header}`)?.hostname;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `421 Misdirected Request` for a Host that is not this machine, on every `/_x` route. The status
|
|
31
|
+
* says "not this server", which is the truth: a rebound name was never meant to reach it.
|
|
32
|
+
*/
|
|
33
|
+
export function hostRefusal(request: UltimateRequest, devUrl: string): Response | undefined {
|
|
34
|
+
const hostname = hostnameOf(request);
|
|
35
|
+
if (hostname !== undefined && isLoopbackHostname(hostname)) return undefined;
|
|
36
|
+
const error = new DevHostRefusedError({ devUrl });
|
|
37
|
+
return jsonResponse(
|
|
38
|
+
{ ok: false, error: { code: error.code, cause: error.cause, fix: error.fix } },
|
|
39
|
+
{ status: 421 },
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A statement runs on a POST that came from this origin — core's `proveSameOrigin`, the rule the
|
|
45
|
+
* app's own writes and the sync upgrade answer to, asked HERE rather than left to the pipeline's
|
|
46
|
+
* CSRF stage: an app with `csrf: { mode: 'off' }` must not switch the dev database's door off with
|
|
47
|
+
* it. Neither header present is `curl`, as `checkCsrf` reads it: no page can drive that client.
|
|
48
|
+
*/
|
|
49
|
+
export function assertSameOrigin(request: UltimateRequest): void {
|
|
50
|
+
const origin = request.header('origin');
|
|
51
|
+
const secFetchSite = request.header('sec-fetch-site');
|
|
52
|
+
if (origin === null && secFetchSite === null) return;
|
|
53
|
+
const verdict = proveSameOrigin({
|
|
54
|
+
selfOrigins: [selfOrigin(request.url, request.ctx.https)],
|
|
55
|
+
origin,
|
|
56
|
+
secFetchSite,
|
|
57
|
+
listed: () => false,
|
|
58
|
+
listName: 'no list — /_x admits its own origin only',
|
|
59
|
+
});
|
|
60
|
+
if (!verdict.ok) throw csrfBlocked(request.pathname, verdict.reason, request.ctx.ip);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The SQL panel's one input, from a form or a JSON body — `request.body` parses either. */
|
|
64
|
+
export const SQL_BODY = t.object({ sql: t.string });
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A statement on the query string is refused rather than run: a GET is what `<img src>` and a
|
|
68
|
+
* prefetch send cross-site with nobody's consent. The fix is the POST that does run it.
|
|
69
|
+
*/
|
|
70
|
+
export const sqlOnGet = (devUrl: string): HttpError =>
|
|
71
|
+
new HttpError({
|
|
72
|
+
code: 'X_METHOD_NOT_ALLOWED',
|
|
73
|
+
cause:
|
|
74
|
+
'/_x/db runs a statement on a same-origin POST only — a GET carrying ?sql= is what a cross-site page can send',
|
|
75
|
+
fix: `curl -sS -H 'accept: application/json' --data-urlencode 'sql=select 1' ${renderFixShellArg(`${devUrl}/_x/db`, 'http://localhost:3000/_x/db')}`,
|
|
76
|
+
});
|
package/src/dev-dashboard.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Mounting `@ultimat3/admin`'s `/_x` dashboard in the `x dev` process. The CLI contributes only
|
|
2
|
-
// what no registry holds —
|
|
3
|
-
//
|
|
2
|
+
// what no registry holds — the live dev database, the caught outbox, the committed manifest, and
|
|
3
|
+
// two panels of process facts — and projects the dashboard onto HTTP routes behind a Host check.
|
|
4
4
|
// A panel implemented here instead of in `admin` would be the second copy this seam exists to ban.
|
|
5
5
|
|
|
6
6
|
import type {
|
|
@@ -14,16 +14,23 @@ import type {
|
|
|
14
14
|
SqlResult,
|
|
15
15
|
StatementLoopFact,
|
|
16
16
|
} from '@ultimat3/admin/dev';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
DEV_BASE_PATH,
|
|
19
|
+
DEV_PANELS,
|
|
20
|
+
defaultDevSources,
|
|
21
|
+
devDashboard,
|
|
22
|
+
readOnlySql,
|
|
23
|
+
} from '@ultimat3/admin/dev';
|
|
18
24
|
import { recentInvalidations } from '@ultimat3/cache';
|
|
19
25
|
import type { Role } from '@ultimat3/core';
|
|
20
26
|
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
21
|
-
import {
|
|
27
|
+
import { jsonResponse } from '@ultimat3/http';
|
|
22
28
|
import type { MemoryMailDriver } from '@ultimat3/mail';
|
|
23
29
|
import { isMemoryDriver } from '@ultimat3/mail';
|
|
24
30
|
import type { Manifest } from '@ultimat3/manifest';
|
|
25
31
|
import { appManifest, readAppManifest } from './app-manifest';
|
|
26
32
|
import { appBoundaryFindings } from './boundary-findings';
|
|
33
|
+
import { assertSameOrigin, hostRefusal, SQL_BODY, sqlOnGet } from './dev-dashboard-guard';
|
|
27
34
|
import type { StatementLedger } from './dev-n-plus-one';
|
|
28
35
|
import { devPolicyMatrix } from './dev-policy';
|
|
29
36
|
import type { TraceRecorder } from './dev-traces';
|
|
@@ -31,6 +38,7 @@ import type { Finding } from './output';
|
|
|
31
38
|
import type { DevServices } from './runtime-bindings';
|
|
32
39
|
import type { RunningServices } from './runtime-services';
|
|
33
40
|
import { loopFacts } from './statement-loop';
|
|
41
|
+
import { styleBundle } from './style-bundle';
|
|
34
42
|
|
|
35
43
|
export interface DevStatus {
|
|
36
44
|
readonly url: string;
|
|
@@ -53,24 +61,6 @@ export interface DevDashboardInput {
|
|
|
53
61
|
readonly statements?: StatementLedger | undefined;
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
/**
|
|
57
|
-
* Read-only is already enforced by `assertReadOnly` inside `dbPanel`, before `runSql` is ever
|
|
58
|
-
* reached. A second gate here would be a second authz: two places to update when what the panel
|
|
59
|
-
* allows changes, and one of them would eventually disagree.
|
|
60
|
-
*/
|
|
61
|
-
async function runSql(input: DevDashboardInput, sql: string): Promise<SqlResult> {
|
|
62
|
-
const started = performance.now();
|
|
63
|
-
const rows = await input.runtime.db.query<Readonly<Record<string, unknown>>>({
|
|
64
|
-
text: sql,
|
|
65
|
-
values: [],
|
|
66
|
-
});
|
|
67
|
-
const elapsedMs = Math.round(performance.now() - started);
|
|
68
|
-
// Columns come from the first row because the driver returns objects, not a described result
|
|
69
|
-
// set; no rows means no columns to name, which the panel renders as an empty grid.
|
|
70
|
-
const columns = Object.keys(rows[0] ?? {});
|
|
71
|
-
return { columns, rows: rows.map((row) => columns.map((column) => row[column])), elapsedMs };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
64
|
/** `MailMessage.locale` is non-optional in `@ultimat3/mail`, so the panel never has to guess. */
|
|
75
65
|
function mailFacts(outbox: MemoryMailDriver): readonly MailFact[] {
|
|
76
66
|
return outbox.outbox().map((entry) => ({
|
|
@@ -137,9 +127,13 @@ export function devSources(input: DevDashboardInput): DevSources {
|
|
|
137
127
|
// the messages are at the provider, so the hook is omitted rather than answered with `[]` —
|
|
138
128
|
// an empty outbox claims nobody was mailed, which is a different and unearned answer.
|
|
139
129
|
const outbox = isMemoryDriver(input.runtime.mail) ? input.runtime.mail : undefined;
|
|
130
|
+
// `dbPanel`'s `assertReadOnly` is the parse guard; `readOnlySql` is the server saying no — its own
|
|
131
|
+
// `BEGIN READ ONLY` that always rolls back, a statement timeout and a row ceiling. The plain
|
|
132
|
+
// `db.query` it replaces ran whatever the guard let through with the app's own credentials.
|
|
133
|
+
const runSql = readOnlySql(input.runtime.db);
|
|
140
134
|
return defaultDevSources({
|
|
141
135
|
hooks: {
|
|
142
|
-
runSql: (sql: string): Promise<SqlResult> => runSql(
|
|
136
|
+
runSql: (sql: string): Promise<SqlResult> => runSql(sql),
|
|
143
137
|
...(outbox === undefined
|
|
144
138
|
? {}
|
|
145
139
|
: { mail: (): Promise<readonly MailFact[]> => Promise.resolve(mailFacts(outbox)) }),
|
|
@@ -222,8 +216,13 @@ const notClaimed = (path: string): Response =>
|
|
|
222
216
|
{ status: 404 },
|
|
223
217
|
);
|
|
224
218
|
|
|
225
|
-
const devRoute = (
|
|
226
|
-
|
|
219
|
+
const devRoute = (
|
|
220
|
+
path: string,
|
|
221
|
+
name: string,
|
|
222
|
+
handler: Route['handler'],
|
|
223
|
+
method: 'GET' | 'POST' = 'GET',
|
|
224
|
+
): Route => ({
|
|
225
|
+
method,
|
|
227
226
|
path,
|
|
228
227
|
// Public: /_x exists to be read without credentials by whatever drives the dev loop, and
|
|
229
228
|
// `devDashboard` refuses to construct at all outside development.
|
|
@@ -232,9 +231,10 @@ const devRoute = (path: string, name: string, handler: Route['handler']): Route
|
|
|
232
231
|
});
|
|
233
232
|
|
|
234
233
|
/**
|
|
235
|
-
* One route for the base path plus one per panel, because the router matches exact paths
|
|
236
|
-
*
|
|
237
|
-
*
|
|
234
|
+
* One route for the base path plus one per panel, because the router matches exact paths, and a
|
|
235
|
+
* `POST` for the SQL panel — the only `/_x` read that runs something. The dashboard is built once
|
|
236
|
+
* — its sources close over this process, and rebuilding per request would re-run `assertDevOnly`
|
|
237
|
+
* on every hit for no new answer. Every route answers this machine's Host only.
|
|
238
238
|
*/
|
|
239
239
|
export function devDashboardRoutes(input: DevDashboardInput): readonly Route[] {
|
|
240
240
|
const panels = devPanels(input);
|
|
@@ -242,16 +242,42 @@ export function devDashboardRoutes(input: DevDashboardInput): readonly Route[] {
|
|
|
242
242
|
basePath: DEV_BASE_PATH,
|
|
243
243
|
panels,
|
|
244
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'),
|
|
245
248
|
...(input.env === undefined ? {} : { env: input.env }),
|
|
246
249
|
});
|
|
247
250
|
|
|
248
|
-
const
|
|
249
|
-
|
|
251
|
+
const sqlPath = `${DEV_BASE_PATH}/db`;
|
|
252
|
+
const handler = async (request: UltimateRequest): Promise<Response> => {
|
|
253
|
+
const devUrl = input.status().url;
|
|
254
|
+
const refused = hostRefusal(request, devUrl);
|
|
255
|
+
if (refused !== undefined) return refused;
|
|
256
|
+
if (request.pathname === sqlPath && request.url.searchParams.has('sql')) throw sqlOnGet(devUrl);
|
|
257
|
+
return (await dashboard.handle(request.raw)) ?? notClaimed(request.pathname);
|
|
258
|
+
};
|
|
259
|
+
// The statement moves from the body onto the panel's own parameter, server side: `dbPanel`
|
|
260
|
+
// reads `sql` off the query, and this is the one caller allowed to put it there.
|
|
261
|
+
const runStatement = async (request: UltimateRequest): Promise<Response> => {
|
|
262
|
+
const refused = hostRefusal(request, input.status().url);
|
|
263
|
+
if (refused !== undefined) return refused;
|
|
264
|
+
assertSameOrigin(request);
|
|
265
|
+
const { sql } = await request.body(SQL_BODY);
|
|
266
|
+
const target = new URL(request.url);
|
|
267
|
+
target.searchParams.set('sql', sql);
|
|
268
|
+
// A GET carries no body, so the body's own headers stay behind; `accept` and the rest ride.
|
|
269
|
+
const headers = new Headers(request.headers);
|
|
270
|
+
headers.delete('content-type');
|
|
271
|
+
headers.delete('content-length');
|
|
272
|
+
const forwarded = new Request(target, { headers });
|
|
273
|
+
return (await dashboard.handle(forwarded)) ?? notClaimed(request.pathname);
|
|
274
|
+
};
|
|
250
275
|
|
|
251
276
|
return [
|
|
252
277
|
devRoute(DEV_BASE_PATH, 'dev._x', handler),
|
|
253
278
|
...panels.map((panel) =>
|
|
254
279
|
devRoute(`${DEV_BASE_PATH}/${panel.key}`, `dev._x.${panel.key}`, handler),
|
|
255
280
|
),
|
|
281
|
+
devRoute(sqlPath, 'dev._x.db.run', runStatement, 'POST'),
|
|
256
282
|
];
|
|
257
283
|
}
|
package/src/dev-lock.ts
CHANGED
|
@@ -14,10 +14,11 @@
|
|
|
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';
|
|
21
|
+
import { portPairAfter } from './flag-number';
|
|
21
22
|
import { quoteArg } from './shell-quote';
|
|
22
23
|
|
|
23
24
|
/** Where the running dev server records itself, inside the state directory it already owns. */
|
|
@@ -141,6 +142,26 @@ export class DevLockUnreadableError extends UltimateError {
|
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
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
|
+
|
|
144
165
|
/** Whatever is listening, as far as the OS will say. Both fields absent when it will not say. */
|
|
145
166
|
export interface PortHolder {
|
|
146
167
|
readonly pid?: number;
|
|
@@ -150,7 +171,8 @@ export interface PortHolder {
|
|
|
150
171
|
|
|
151
172
|
/**
|
|
152
173
|
* Who holds the port. `ss` first because it is present on every Linux box the framework targets and
|
|
153
|
-
* 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.
|
|
154
176
|
*
|
|
155
177
|
* Through `exec.ts`, like every other subprocess the CLI runs, so a test injects a fake `Runner`
|
|
156
178
|
* rather than racing a real socket.
|
|
@@ -158,7 +180,7 @@ export interface PortHolder {
|
|
|
158
180
|
* EVERY PROBE IS CAUGHT SEPARATELY. `exec` refuses a missing program with `X_CLI_UNEXPECTED`, and
|
|
159
181
|
* letting that escape would mean a box without `ss` gets the CLI's catch-all instead of
|
|
160
182
|
* `X_PORT_IN_USE` — the exact substitution this module exists to end. A missing `ss` must fall
|
|
161
|
-
* 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.
|
|
162
184
|
*/
|
|
163
185
|
export const portHolder = async (port: number, runner: Runner = exec): Promise<PortHolder> => {
|
|
164
186
|
const probe = async (command: readonly string[]): Promise<string> => {
|
|
@@ -184,9 +206,38 @@ export const portHolder = async (port: number, runner: Runner = exec): Promise<P
|
|
|
184
206
|
if (pid !== undefined && /^\d+$/.test(pid)) {
|
|
185
207
|
return command === undefined ? { pid: Number(pid) } : { command, pid: Number(pid) };
|
|
186
208
|
}
|
|
187
|
-
|
|
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 };
|
|
188
217
|
};
|
|
189
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
|
+
|
|
190
241
|
/**
|
|
191
242
|
* The port is bound by something that is not us. Named separately from the lock case because the
|
|
192
243
|
* remedy is different: a stranger's port is worked around with `--port`, while a second `x dev` on
|
|
@@ -220,12 +271,6 @@ export class DevPortInUseError extends UltimateError {
|
|
|
220
271
|
}
|
|
221
272
|
}
|
|
222
273
|
|
|
223
|
-
/**
|
|
224
|
-
* The next port to suggest. Never `port + 1` at the top of the range — 65536 is not a port, and a
|
|
225
|
-
* `fix:` that cannot run is the failure this whole module exists to end.
|
|
226
|
-
*/
|
|
227
|
-
export const suggestPort = (port: number): number => (port >= 65535 ? port - 1 : port + 1);
|
|
228
|
-
|
|
229
274
|
/**
|
|
230
275
|
* Is anything listening? A successful bind-then-close is the only answer that does not lie.
|
|
231
276
|
*
|
|
@@ -246,33 +291,46 @@ export const isPortBound = (port: number, hostname: string): boolean => {
|
|
|
246
291
|
}
|
|
247
292
|
};
|
|
248
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
|
+
|
|
249
300
|
/**
|
|
250
301
|
* Take the lock, or answer `false` because someone else holds it.
|
|
251
302
|
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
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.
|
|
256
308
|
*
|
|
257
|
-
* Only `EEXIST` is "someone else has it".
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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`.
|
|
260
312
|
*/
|
|
261
|
-
function claimExclusive(path: string, lock: DevLock): boolean {
|
|
262
|
-
|
|
313
|
+
function claimExclusive(stateDir: string, path: string, lock: DevLock): boolean {
|
|
314
|
+
const staged = stagingPath(path);
|
|
263
315
|
try {
|
|
264
|
-
|
|
316
|
+
writeFileSync(staged, lockText(lock));
|
|
265
317
|
} catch (error) {
|
|
266
|
-
|
|
267
|
-
if (stringField(error, 'code') === 'EEXIST') return false;
|
|
268
|
-
throw error;
|
|
318
|
+
throw new DevStateUnwritableError({ stateDir, error });
|
|
269
319
|
}
|
|
270
320
|
try {
|
|
271
|
-
|
|
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 });
|
|
272
327
|
} finally {
|
|
273
|
-
|
|
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
|
+
}
|
|
274
333
|
}
|
|
275
|
-
return true;
|
|
276
334
|
}
|
|
277
335
|
|
|
278
336
|
/** Whatever is on disk right now, or `null` if it is absent or half-written. */
|
|
@@ -326,7 +384,11 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
|
|
|
326
384
|
const release = (): void => clearLock(input.stateDir);
|
|
327
385
|
// The lock lives inside the state directory, which the database has not created yet — this runs
|
|
328
386
|
// before anything boots, which is the whole point of it.
|
|
329
|
-
|
|
387
|
+
try {
|
|
388
|
+
mkdirSync(input.stateDir, { recursive: true });
|
|
389
|
+
} catch (error) {
|
|
390
|
+
throw new DevStateUnwritableError({ stateDir: input.stateDir, error });
|
|
391
|
+
}
|
|
330
392
|
const mine: DevLock = {
|
|
331
393
|
pid: process.pid,
|
|
332
394
|
port: input.port,
|
|
@@ -337,7 +399,7 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
|
|
|
337
399
|
};
|
|
338
400
|
let clearedStale = false;
|
|
339
401
|
|
|
340
|
-
if (!claimExclusive(path, mine)) {
|
|
402
|
+
if (!claimExclusive(input.stateDir, path, mine)) {
|
|
341
403
|
const held = await readLock(path);
|
|
342
404
|
if (held !== null && alive(held.pid)) {
|
|
343
405
|
throw new DevAlreadyRunningError({
|
|
@@ -354,7 +416,7 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
|
|
|
354
416
|
// Already gone, or not ours to remove. The claim below is what decides.
|
|
355
417
|
}
|
|
356
418
|
clearedStale = true;
|
|
357
|
-
if (!claimExclusive(path, mine)) {
|
|
419
|
+
if (!claimExclusive(input.stateDir, path, mine)) {
|
|
358
420
|
// Lost the race for the slot we just cleared. `held` is the best identity available — a
|
|
359
421
|
// half-written file parses as `null` for microseconds — and refusing on a stale pid beats
|
|
360
422
|
// the alternative, which is two processes writing one single-writer data directory.
|
|
@@ -379,7 +441,9 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
|
|
|
379
441
|
release();
|
|
380
442
|
throw new DevPortInUseError({
|
|
381
443
|
port: input.port,
|
|
382
|
-
|
|
444
|
+
// `portPairAfter`, never `port + 1`: `x dev` binds a PAIR, so the holder is most often
|
|
445
|
+
// another `x dev` whose sync role already sits on `port + 1`.
|
|
446
|
+
suggestion: portPairAfter(input.port),
|
|
383
447
|
holder: await (input.holder ?? portHolder)(input.port),
|
|
384
448
|
});
|
|
385
449
|
}
|
|
@@ -391,7 +455,12 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
|
|
|
391
455
|
* with the address the server actually bound, which is the one field the preflight could not know.
|
|
392
456
|
*/
|
|
393
457
|
export const writeLock = async (stateDir: string, lock: DevLock): Promise<void> => {
|
|
394
|
-
|
|
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);
|
|
395
464
|
};
|
|
396
465
|
|
|
397
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
|
+
}
|