@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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// `ROLE=worker`'s retire, on SIGUSR2 (issue #669): stop claiming, finish every held job however
|
|
2
|
+
// long it takes, abort nothing — then the ordinary drain, so the process exits 0. It is what a
|
|
3
|
+
// Kubernetes `preStop` sends before the SIGTERM a rollout ends in, for a job whose side effect may
|
|
4
|
+
// happen at most once and may outlast `drain.deadlineMs`. Framework-owned: no port, no app code.
|
|
5
|
+
|
|
6
|
+
import { drain, logger, renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import type { Worker } from '@ultimat3/jobs';
|
|
8
|
+
|
|
9
|
+
/** The signal a worker retires on. Free in Bun and Node (SIGUSR1 is the inspector's). */
|
|
10
|
+
export const RETIRE_SIGNAL = 'SIGUSR2';
|
|
11
|
+
|
|
12
|
+
/** The two calls a retire makes on the worker. */
|
|
13
|
+
export type RetiringWorker = Pick<Worker, 'stats' | 'stop'>;
|
|
14
|
+
|
|
15
|
+
/** Where the signal is heard — `process` by default, a hand-raised source in a test. */
|
|
16
|
+
export interface RetireSignals {
|
|
17
|
+
on(signal: typeof RETIRE_SIGNAL, listener: () => void): void;
|
|
18
|
+
off(signal: typeof RETIRE_SIGNAL, listener: () => void): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WorkerRetireOptions {
|
|
22
|
+
/** Default `process.platform`. `win32` installs nothing: Windows has no SIGUSR2. */
|
|
23
|
+
readonly platform?: string;
|
|
24
|
+
readonly signals?: RetireSignals;
|
|
25
|
+
/** What the retire ends in. Default core's `drain` — the hold then releases and exits 0. */
|
|
26
|
+
readonly drain?: (signal: string) => Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WorkerRetire {
|
|
30
|
+
/** The worker the boot started (`null` when it started none) — a signal before this waits. */
|
|
31
|
+
adopt(worker: RetiringWorker | null): void;
|
|
32
|
+
/** Give the listener back: a boot that failed, or a hold that is over. */
|
|
33
|
+
disarm(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const NOTHING: WorkerRetire = Object.freeze({ adopt: () => undefined, disarm: () => undefined });
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Listen for `RETIRE_SIGNAL` NOW, before the boot: Bun's default for an unhandled SIGUSR2 ends the
|
|
40
|
+
* process, so a `preStop` that lands while the pod still boots must meet this listener, which waits
|
|
41
|
+
* for the worker and then retires it. One retire per process; a second signal joins it.
|
|
42
|
+
*
|
|
43
|
+
* Exits rather than holds, decided 2026-10-07: the `preStop` has no channel back but PID 1 itself,
|
|
44
|
+
* so "retired" has to be observable as "PID 1 is gone" — and a pod that has stopped claiming serves
|
|
45
|
+
* nothing it could keep holding for. A SIGTERM landing mid-retire is the ordinary drain: it binds
|
|
46
|
+
* the retire to `drain.deadlineMs` and cuts off at the margin (`packages/jobs/src/worker.ts`).
|
|
47
|
+
*/
|
|
48
|
+
export function armWorkerRetire(options: WorkerRetireOptions = {}): WorkerRetire {
|
|
49
|
+
if ((options.platform ?? process.platform) === 'win32') return NOTHING;
|
|
50
|
+
const signals: RetireSignals = options.signals ?? process;
|
|
51
|
+
const finish = options.drain ?? drain;
|
|
52
|
+
let adopt: (worker: RetiringWorker | null) => void = () => undefined;
|
|
53
|
+
const adopted = new Promise<RetiringWorker | null>((resolve) => {
|
|
54
|
+
adopt = resolve;
|
|
55
|
+
});
|
|
56
|
+
let retiring = false;
|
|
57
|
+
|
|
58
|
+
const retire = async (): Promise<void> => {
|
|
59
|
+
const worker = await adopted;
|
|
60
|
+
try {
|
|
61
|
+
if (worker !== null) {
|
|
62
|
+
const before = await worker.stats();
|
|
63
|
+
logger.info('jobs.worker.retiring', {
|
|
64
|
+
signal: RETIRE_SIGNAL,
|
|
65
|
+
workerId: before.workerId,
|
|
66
|
+
inFlight: before.inFlight,
|
|
67
|
+
});
|
|
68
|
+
await worker.stop(RETIRE_SIGNAL);
|
|
69
|
+
const after = await worker.stats();
|
|
70
|
+
logger.info('jobs.worker.retired', {
|
|
71
|
+
signal: RETIRE_SIGNAL,
|
|
72
|
+
workerId: after.workerId,
|
|
73
|
+
state: after.state,
|
|
74
|
+
inFlight: after.inFlight,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
logger.warn('jobs.worker.retire-failed', { error: renderThrowable(error) });
|
|
79
|
+
} finally {
|
|
80
|
+
await finish(RETIRE_SIGNAL);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const listener = (): void => {
|
|
85
|
+
if (retiring) return;
|
|
86
|
+
retiring = true;
|
|
87
|
+
// Never rejects: `retire` catches its own failure and always reaches the drain.
|
|
88
|
+
void retire();
|
|
89
|
+
};
|
|
90
|
+
signals.on(RETIRE_SIGNAL, listener);
|
|
91
|
+
return {
|
|
92
|
+
adopt: (worker) => adopt(worker),
|
|
93
|
+
disarm: () => signals.off(RETIRE_SIGNAL, listener),
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/serve-web.ts
CHANGED
|
@@ -4,21 +4,23 @@
|
|
|
4
4
|
// spared: render's server half, the PWA, SEO, MCP and island subsystems.
|
|
5
5
|
|
|
6
6
|
import type { Route } from '@ultimat3/http';
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
8
|
+
|
|
9
|
+
import { describePages } from '@ultimat3/render';
|
|
9
10
|
import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
|
|
10
11
|
import { loadSignInPath } from './app-auth';
|
|
11
12
|
import { mountAppMcp } from './app-mcp';
|
|
12
|
-
import {
|
|
13
|
+
import { errorPageStyleBodies } from './error-page-csp';
|
|
13
14
|
import { islandRoutes } from './island-routes';
|
|
14
15
|
import type { LoadedIslands } from './island-store';
|
|
15
16
|
import { loadOrBuildIslands } from './island-store';
|
|
16
17
|
import { loadNavigation, pageNavigation } from './page-navigation';
|
|
17
|
-
import { loadSpeculation, pageSpeculation } from './page-speculation';
|
|
18
|
+
import { loadSpeculation, otherLocaleSegments, pageSpeculation } from './page-speculation';
|
|
18
19
|
import { pageSync } from './page-sync';
|
|
19
20
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
20
21
|
import { adminMountRoutes } from './runtime-admin';
|
|
21
22
|
import { assetRoutes } from './runtime-assets';
|
|
23
|
+
import { attachedIsr } from './runtime-isr';
|
|
22
24
|
import { appRoutes } from './runtime-render';
|
|
23
25
|
import type { RunningServices } from './runtime-services';
|
|
24
26
|
import { servedStorage, storageRoutes } from './runtime-storage';
|
|
@@ -30,18 +32,21 @@ import { styleRoutes } from './style-routes';
|
|
|
30
32
|
import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
31
33
|
import { serviceWorkerRoutes } from './sw-routes';
|
|
32
34
|
import { loadThemeMode, themeBoot } from './theme-boot';
|
|
35
|
+
import { loadThemeBrand } from './theme-brand';
|
|
33
36
|
|
|
34
37
|
/** Everything `startRoles` is handed that only a process serving documents has. */
|
|
35
38
|
export interface WebSurface {
|
|
36
39
|
readonly routes: readonly Route[];
|
|
37
40
|
/** The theme boot and the speculation rules: the two inline bodies every document may carry. */
|
|
38
41
|
readonly inlineScripts: readonly string[];
|
|
39
|
-
/** The app's
|
|
42
|
+
/** The app's error pages' and its brand's `<style>` bodies, which the enforced policy admits. */
|
|
40
43
|
readonly inlineStyles: readonly string[];
|
|
41
44
|
/** Where a browser that opened a guarded page is sent — the declaration `x dev` reads. */
|
|
42
45
|
readonly signInPath: string | null;
|
|
43
46
|
/** Present when this boot built the islands itself: how many chunks, and why not the store. */
|
|
44
47
|
readonly islandsBuilt?: LoadedIslands['built'];
|
|
48
|
+
/** Detaches the ISR controller from `invalidateTags` — on the boot's stop list. */
|
|
49
|
+
readonly release: () => void;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
export async function webSurface(
|
|
@@ -58,6 +63,8 @@ export async function webSurface(
|
|
|
58
63
|
// prevent, and it is the one an operator cannot see without installing the app.
|
|
59
64
|
const pwa = await loadPwaArtifacts(options.root);
|
|
60
65
|
const theme = themeBoot(await loadThemeMode(options.root));
|
|
66
|
+
// The app's brand (`apps/web/shared/theme.ts`), or nothing: the same reader `x dev` calls.
|
|
67
|
+
const brand = await loadThemeBrand(options.root);
|
|
61
68
|
// `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
|
|
62
69
|
const site = await loadSiteSettings(options.root);
|
|
63
70
|
const origin = publicOrigin(options.env, site);
|
|
@@ -71,8 +78,9 @@ export async function webSurface(
|
|
|
71
78
|
const speculation = pageSpeculation({
|
|
72
79
|
config: await loadSpeculation(options.root),
|
|
73
80
|
client: declared.surfaces,
|
|
81
|
+
localeSegments: otherLocaleSegments(await appLocaleSet(options.root)),
|
|
74
82
|
});
|
|
75
|
-
// The worker, from the SAME route table this process is about to serve — `
|
|
83
|
+
// The worker, from the SAME route table this process is about to serve — `describePages()` is
|
|
76
84
|
// the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
|
|
77
85
|
// route added here cannot be missing from the precache manifest.
|
|
78
86
|
const serviceWorker =
|
|
@@ -81,7 +89,7 @@ export async function webSurface(
|
|
|
81
89
|
: serviceWorkerArtifacts({
|
|
82
90
|
pwa,
|
|
83
91
|
buildId,
|
|
84
|
-
routes:
|
|
92
|
+
routes: describePages(),
|
|
85
93
|
islands,
|
|
86
94
|
styles: styleBundle(),
|
|
87
95
|
scripts: [
|
|
@@ -93,6 +101,13 @@ export async function webSurface(
|
|
|
93
101
|
const mcpMount = await mountAppMcp(options.root);
|
|
94
102
|
// The app's own disks when it declared any (`defineStorage` in an app module), else this boot's.
|
|
95
103
|
const served = servedStorage(runtime.storage);
|
|
104
|
+
// Attached, so a tag bust marks this process's ISR pages stale. The store is the deployment's
|
|
105
|
+
// when it supplied one: the default is per process, so twelve replicas hold twelve of them and a
|
|
106
|
+
// purge regenerates one twelfth of the fleet while the other eleven keep the invalidated page.
|
|
107
|
+
const isr = attachedIsr({
|
|
108
|
+
buildId,
|
|
109
|
+
...(options.runtime?.isrStore === undefined ? {} : { store: options.runtime.isrStore }),
|
|
110
|
+
});
|
|
96
111
|
const routes: readonly Route[] = [
|
|
97
112
|
...apiRoutes(),
|
|
98
113
|
// The bearer doors onto a cut of the same routes (`defineApi({ http: { mounts } })`), counted
|
|
@@ -126,6 +141,7 @@ export async function webSurface(
|
|
|
126
141
|
...adminMountRoutes({
|
|
127
142
|
buildId: buildId,
|
|
128
143
|
themeHead: theme.head,
|
|
144
|
+
...(brand === undefined ? {} : { brandHead: brand.head }),
|
|
129
145
|
...(origin === undefined ? {} : { origin }),
|
|
130
146
|
}),
|
|
131
147
|
...appRoutes({
|
|
@@ -135,6 +151,7 @@ export async function webSurface(
|
|
|
135
151
|
persisted: sync.persisted,
|
|
136
152
|
...(navigation.head === undefined ? {} : { navigation: navigation.head }),
|
|
137
153
|
themeHead: theme.head,
|
|
154
|
+
...(brand === undefined ? {} : { brandHead: brand.head }),
|
|
138
155
|
...(speculation === undefined ? {} : { speculationHead: speculation.head }),
|
|
139
156
|
// A `static` page is the same bytes for every request of this process: rendered once.
|
|
140
157
|
memoStatic: true,
|
|
@@ -142,23 +159,22 @@ export async function webSurface(
|
|
|
142
159
|
...(pwa === undefined
|
|
143
160
|
? {}
|
|
144
161
|
: { pwaHead: (locale: string) => pwa.headFor(locale) + (serviceWorker?.head ?? '') }),
|
|
145
|
-
|
|
146
|
-
// store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
|
|
147
|
-
// the fleet while the other eleven keep serving the page it just invalidated.
|
|
148
|
-
...(options.runtime?.isrStore === undefined
|
|
149
|
-
? {}
|
|
150
|
-
: { isr: createIsrController({ buildId, store: options.runtime.isrStore }) }),
|
|
162
|
+
isr: isr.isr,
|
|
151
163
|
}),
|
|
152
164
|
];
|
|
153
165
|
return {
|
|
154
166
|
routes,
|
|
155
167
|
inlineScripts: [theme.cspSource, ...(speculation === undefined ? [] : [speculation.cspSource])],
|
|
156
168
|
// The enforced policy this process sends must admit the app's own error pages' `<style>` and
|
|
157
|
-
// the
|
|
158
|
-
inlineStyles:
|
|
169
|
+
// the brand's; `x dev` is report-only, so only here was a missing hash an unstyled page.
|
|
170
|
+
inlineStyles: [
|
|
171
|
+
...(await errorPageStyleBodies(options.root)),
|
|
172
|
+
...(brand === undefined ? [] : [brand.style]),
|
|
173
|
+
],
|
|
159
174
|
// Same declaration `x dev` reads. Without it a container answers a browser that opened a
|
|
160
175
|
// guarded page with the problem document, rendered as raw JSON in the viewport.
|
|
161
176
|
signInPath: await loadSignInPath(options.root),
|
|
162
177
|
...(islandsBuilt === undefined ? {} : { islandsBuilt }),
|
|
178
|
+
release: isr.release,
|
|
163
179
|
};
|
|
164
180
|
}
|
package/src/serve.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
// interface — every one by default, because a container is reached through a port mapping.
|
|
6
6
|
|
|
7
7
|
import { assertNoDevSecretsOutsideLocal, logger } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import { assertNoSchemaDrift, checkDrift, migrate } from '@ultimat3/db';
|
|
9
9
|
import { loadAppRuntime } from './app-runtime';
|
|
10
10
|
import { acceptCreatedTables } from './db-accept-created';
|
|
11
|
-
import {
|
|
11
|
+
import { type BootedHold, holdWhileBooting } from './hold';
|
|
12
12
|
import { startMetricsEndpoint } from './metrics-endpoint';
|
|
13
13
|
import { readMigrations } from './migrations';
|
|
14
14
|
import { resolveServices } from './runtime-bindings';
|
|
@@ -174,19 +174,36 @@ export async function runRole(input: ServeOptions): Promise<StartedApp> {
|
|
|
174
174
|
// returned. `x db migrate` calls the same `runMigrations` and renders every difference as a
|
|
175
175
|
// finding before exiting non-zero; a container that logged one and exited 0 would let the
|
|
176
176
|
// deploy roll on over a schema nobody can reconstruct, which is the failure drift exists for.
|
|
177
|
-
|
|
177
|
+
assertNoSchemaDrift(migrated.drift);
|
|
178
178
|
return migrated;
|
|
179
179
|
}
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
// The drain's handlers BEFORE the boot (`holdWhileBooting`): the worker claims inside
|
|
181
|
+
// `startRoles`, so a SIGTERM during boot has to be a drain of what started, never Bun's kill.
|
|
182
182
|
// `exit` because this is the one entry point with nothing above it: `bin.ts` ends in
|
|
183
183
|
// `process.exit(code)` and `apps/web/server.ts` — which is what awaits this — does not. One
|
|
184
184
|
// non-unref'd interval anywhere in the app then holds an event loop that has nothing left to do,
|
|
185
185
|
// until `terminationGracePeriodSeconds` runs out and the kubelet SIGKILLs a drained process.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
186
|
+
//
|
|
187
|
+
// A worker also retires on SIGUSR2 (`serve-retire.ts`) — armed before the boot for the same
|
|
188
|
+
// reason: Bun's default for an unhandled SIGUSR2 ends the process, and a `preStop` may land
|
|
189
|
+
// while the pod still boots. The retire ends in the same drain, so the same hold exits 0.
|
|
190
|
+
// Behind `await import()`, as the serving graph is: `ROLE=migrate` never loads it.
|
|
191
|
+
const retire = role === 'worker' ? (await import('./serve-retire')).armWorkerRetire() : undefined;
|
|
192
|
+
let booted: BootedHold<ServedApp>;
|
|
193
|
+
try {
|
|
194
|
+
booted = await holdWhileBooting('serve', () => serveApp({ ...options, role }), {
|
|
195
|
+
exit: (code) => {
|
|
196
|
+
retire?.disarm();
|
|
197
|
+
process.exit(code);
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
} catch (error) {
|
|
201
|
+
retire?.disarm();
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
const { app, hold } = booted;
|
|
205
|
+
retire?.adopt(app.running.worker);
|
|
206
|
+
logger.info('ultimate started', { role: app.role, url: app.url, buildId: app.buildId });
|
|
207
|
+
await hold();
|
|
191
208
|
return app;
|
|
192
209
|
}
|
package/src/server-barrels.ts
CHANGED
|
@@ -27,9 +27,12 @@ export interface BarrelImport {
|
|
|
27
27
|
readonly entry: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* `import` / `export … from` / `import()` of a VALUE from the barrel, and the side-effect
|
|
32
|
+
* `import '…'` that binds nothing yet evaluates the whole module. `import type` runs nothing.
|
|
33
|
+
*/
|
|
31
34
|
const IMPORTS =
|
|
32
|
-
/\b(?:import|export)\s+(?!type\s)[^;'"]*?\bfrom\s*(['"])([^'"]+)\1|\bimport\s*\(\s*(['"])([^'"]+)\3\s*\)/g;
|
|
35
|
+
/\b(?:import|export)\s+(?!type\s)[^;'"]*?\bfrom\s*(['"])([^'"]+)\1|\bimport\s*\(\s*(['"])([^'"]+)\3\s*\)|\bimport\s*(['"])([^'"]+)\5/g;
|
|
33
36
|
|
|
34
37
|
export function barrelImports(
|
|
35
38
|
source: string,
|
|
@@ -41,7 +44,7 @@ export function barrelImports(
|
|
|
41
44
|
const lineAt = lineIndex(code);
|
|
42
45
|
const found: BarrelImport[] = [];
|
|
43
46
|
for (const match of code.matchAll(IMPORTS)) {
|
|
44
|
-
const spec = match[2] ?? match[4] ?? '';
|
|
47
|
+
const spec = match[2] ?? match[4] ?? match[6] ?? '';
|
|
45
48
|
const entry = barrels.get(spec);
|
|
46
49
|
if (entry === undefined) continue;
|
|
47
50
|
// `import { type A, type B } from …` binds nothing at runtime either.
|
package/src/shot-browser.ts
CHANGED
|
@@ -96,7 +96,7 @@ export function shotBrowserChoice(input: ShotBrowserInput): ShotBrowserChoice {
|
|
|
96
96
|
flag: 'browser',
|
|
97
97
|
command: 'shot',
|
|
98
98
|
reason: `no executable at "${executablePath}"`,
|
|
99
|
-
fix: 'x shot / --browser /usr/bin/chromium',
|
|
99
|
+
fix: 'x shot / --browser /usr/bin/chromium # macOS: --browser "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; Windows: --browser "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"',
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
102
|
return { executablePath };
|
package/src/shot-locale.ts
CHANGED
|
@@ -3,41 +3,14 @@
|
|
|
3
3
|
// on the language of the machine's Chrome. A non-default locale is also a URL prefix (`/en/…`),
|
|
4
4
|
// because on a `site/` route the unprefixed path is always the default locale whatever the header.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
8
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
6
|
+
import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
9
7
|
import { BadFlagError } from './errors';
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
/** An app that declares nothing is `en` only — the framework's own default. */
|
|
17
|
-
export const FALLBACK_SHOT_LOCALES: ShotLocales = { locales: ['en'], defaultLocale: 'en' };
|
|
18
|
-
|
|
19
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
20
|
-
typeof value === 'object' && value !== null;
|
|
21
|
-
|
|
22
|
-
/** `app.config.ts`'s `locales` and `defaultLocale`, read the way `loadThemeMode` reads `theme`. */
|
|
23
|
-
export async function loadShotLocales(root: string): Promise<ShotLocales> {
|
|
24
|
-
const configPath = join(root, APP_CONFIG_FILE);
|
|
25
|
-
if (!(await Bun.file(configPath).exists())) return FALLBACK_SHOT_LOCALES;
|
|
26
|
-
const module = (await import(configPath)) as Record<string, unknown>;
|
|
27
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
28
|
-
if (!isRecord(config)) return FALLBACK_SHOT_LOCALES;
|
|
29
|
-
const declared = config['locales'];
|
|
30
|
-
const locales = Array.isArray(declared)
|
|
31
|
-
? declared.filter((locale): locale is string => typeof locale === 'string')
|
|
32
|
-
: [];
|
|
33
|
-
const fallback = config['defaultLocale'];
|
|
34
|
-
const defaultLocale =
|
|
35
|
-
typeof fallback === 'string' && fallback !== '' ? fallback : (locales[0] ?? 'en');
|
|
36
|
-
return {
|
|
37
|
-
locales: locales.length === 0 ? [defaultLocale] : locales,
|
|
38
|
-
defaultLocale,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
9
|
+
/**
|
|
10
|
+
* `@ultimat3/i18n/app-catalogs`'s `appLocaleSet(root)` — the one reader, and its one answer for an
|
|
11
|
+
* app that declares nothing (`UNDECLARED_LOCALES`, the framework default alone).
|
|
12
|
+
*/
|
|
13
|
+
export type ShotLocales = AppLocaleSet;
|
|
41
14
|
|
|
42
15
|
/** `--locale <l>`, refused by name when the app does not declare it — a typo costs no browser. */
|
|
43
16
|
export function readLocaleFlag(value: string | undefined, app: ShotLocales): string | undefined {
|
package/src/shot-settle.ts
CHANGED
|
@@ -32,9 +32,9 @@ export interface SettleOptions {
|
|
|
32
32
|
/**
|
|
33
33
|
* Read the probe until every booted island has settled, or the window runs out.
|
|
34
34
|
*
|
|
35
|
-
* `
|
|
36
|
-
* and `failed` land after it — so a single read at that instant
|
|
37
|
-
* that hydrates perfectly and the verdict was taken one tick before the outcome existed. Polling
|
|
35
|
+
* The default window, `IDLE_HYDRATE_TIMEOUT_MS`, is the deadline at which the hydration runtime
|
|
36
|
+
* CALLS `import()` — `mounted` and `failed` land after it — so a single read at that instant
|
|
37
|
+
* reports `mounted: 0` for a page that hydrates perfectly and the verdict was taken one tick before the outcome existed. Polling
|
|
38
38
|
* is the only shape that ends EARLY on a fast page and still bounds a slow one.
|
|
39
39
|
*
|
|
40
40
|
* A `null` answer never overwrites a real count: `null` means "not counted", and a probe that
|
package/src/shot-theme.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// (issue #489). A requested theme is therefore stored as the visitor's CHOICE, on the page's origin,
|
|
6
6
|
// before the boot reads it: the picture is then what a visitor who chose that theme sees.
|
|
7
7
|
|
|
8
|
-
import { THEME_STORAGE_KEY } from '@ultimat3/
|
|
8
|
+
import { THEME_STORAGE_KEY } from '@ultimat3/core';
|
|
9
9
|
import type { ShotColorScheme } from './browser-launcher-port';
|
|
10
10
|
import { BadFlagError } from './errors';
|
|
11
11
|
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// `x shot --viewport 390x844,1440x900`: the flag read into sizes, and one route photographed once
|
|
2
|
+
// per size into `<out>/<w>x<h>/` over ONE dev server. Every responsive picture used to need a
|
|
3
|
+
// hand-written browser script beside the tool that exists to make that unnecessary (#444).
|
|
4
|
+
|
|
5
|
+
// why: no Bun native joins a path.
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { renderCauseValue } from '@ultimat3/core';
|
|
8
|
+
import type { ShotRun } from './cmd-shot';
|
|
9
|
+
import { BadFlagError } from './errors';
|
|
10
|
+
import { msg } from './messages';
|
|
11
|
+
import type { CommandResult } from './output';
|
|
12
|
+
import type { ShotServer } from './shot-server';
|
|
13
|
+
import type { ShotArtifacts } from './shot-verdict';
|
|
14
|
+
import { shotLines, shotSummary, verdictJson } from './shot-verdict';
|
|
15
|
+
|
|
16
|
+
/** CSS pixels a page is laid out in. */
|
|
17
|
+
export interface ShotViewport {
|
|
18
|
+
readonly width: number;
|
|
19
|
+
readonly height: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The largest side a size may have: Chrome's maximum texture dimension, past which a capture is
|
|
24
|
+
* tiles the compositor never painted. One bound for both sides, so the refusal states one number.
|
|
25
|
+
*/
|
|
26
|
+
export const VIEWPORT_MAX = 16_384;
|
|
27
|
+
|
|
28
|
+
/** Digits only: `0x10`, `1e3` and `390.5` are not a size anybody typed meaning a size. */
|
|
29
|
+
const SIZE = /^(\d+)x(\d+)$/i;
|
|
30
|
+
|
|
31
|
+
const FIX = 'x shot / --viewport 390x844,1440x900 --json';
|
|
32
|
+
|
|
33
|
+
const refuse = (reason: string): never => {
|
|
34
|
+
throw new BadFlagError({ flag: 'viewport', command: 'shot', reason, fix: FIX });
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* An island state declares its own `viewport` in its `*.island.states.ts`, and the harness opens one
|
|
39
|
+
* browser per declared size — so a size asked for beside `--island`/`--all-islands` is a second
|
|
40
|
+
* answer to a question the states already answer.
|
|
41
|
+
*/
|
|
42
|
+
export function refuseViewportWithComponent(): never {
|
|
43
|
+
throw new BadFlagError({
|
|
44
|
+
flag: 'viewport',
|
|
45
|
+
command: 'shot',
|
|
46
|
+
reason: 'sizes a route capture; an island is photographed at the viewport each state declares',
|
|
47
|
+
fix: FIX,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A size is its own directory name, so two sizes can never write one picture. */
|
|
52
|
+
export const viewportDir = (viewport: ShotViewport): string =>
|
|
53
|
+
`${String(viewport.width)}x${String(viewport.height)}`;
|
|
54
|
+
|
|
55
|
+
function readSize(entry: string): ShotViewport {
|
|
56
|
+
const match = SIZE.exec(entry);
|
|
57
|
+
if (match === null) {
|
|
58
|
+
return refuse(`${renderCauseValue(entry)} is not <width>x<height>, e.g. 390x844`);
|
|
59
|
+
}
|
|
60
|
+
const width = Number(match[1]);
|
|
61
|
+
const height = Number(match[2]);
|
|
62
|
+
if (width < 1 || height < 1 || width > VIEWPORT_MAX || height > VIEWPORT_MAX) {
|
|
63
|
+
return refuse(`${renderCauseValue(entry)} is outside 1..${String(VIEWPORT_MAX)} on a side`);
|
|
64
|
+
}
|
|
65
|
+
return { width, height };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The flag, every entry judged before any is returned — a run that photographed the good half and
|
|
70
|
+
* refused afterwards would leave a reader with half a set and a failure. Absent is `undefined`:
|
|
71
|
+
* no override, the capture's own default size. Refused, never skipped: an empty entry and the same
|
|
72
|
+
* size twice are both a list the reader did not mean, and the second would overwrite the first.
|
|
73
|
+
*/
|
|
74
|
+
export function parseViewports(raw: string | undefined): readonly ShotViewport[] | undefined {
|
|
75
|
+
if (raw === undefined) return undefined;
|
|
76
|
+
const sizes = raw.split(',').map((entry) => readSize(entry.trim()));
|
|
77
|
+
const seen = new Set<string>();
|
|
78
|
+
for (const size of sizes) {
|
|
79
|
+
const dir = viewportDir(size);
|
|
80
|
+
if (seen.has(dir)) return refuse(`${dir} is listed twice`);
|
|
81
|
+
seen.add(dir);
|
|
82
|
+
}
|
|
83
|
+
return sizes;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ViewportRun {
|
|
87
|
+
readonly viewports: readonly ShotViewport[];
|
|
88
|
+
/** Absolute; each size lands in `<outDir>/<w>x<h>/`. */
|
|
89
|
+
readonly outDir: string;
|
|
90
|
+
readonly boot: () => Promise<ShotServer>;
|
|
91
|
+
/** `runShot`, handed in so this module never imports the command that imports it. */
|
|
92
|
+
readonly shoot: (run: ShotRun) => Promise<ShotArtifacts>;
|
|
93
|
+
/** Everything a size shares: the route, the driver, the waits, the locale, the theme. */
|
|
94
|
+
readonly base: Omit<ShotRun, 'outDir' | 'boot' | 'viewport'>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One server for every size, `runShotMatrix`'s rule: each capture gets a boot that never stops it,
|
|
99
|
+
* and the run stops it once after the last picture. Each size is an ordinary `runShot`, so its
|
|
100
|
+
* `verdict.json` means exactly what a single shot's does; `ok` only when every one is.
|
|
101
|
+
*/
|
|
102
|
+
export async function runShotViewports(run: ViewportRun): Promise<CommandResult> {
|
|
103
|
+
// `parseViewports` never answers an empty list; a hand-built one is refused before the boot.
|
|
104
|
+
if (run.viewports.length === 0) refuse('lists no size');
|
|
105
|
+
const server = await run.boot();
|
|
106
|
+
const shared: ShotServer = { ...server, stop: () => Promise.resolve() };
|
|
107
|
+
const shots: { readonly viewport: ShotViewport; readonly artifacts: ShotArtifacts }[] = [];
|
|
108
|
+
try {
|
|
109
|
+
for (const viewport of run.viewports) {
|
|
110
|
+
const artifacts = await run.shoot({
|
|
111
|
+
...run.base,
|
|
112
|
+
viewport,
|
|
113
|
+
outDir: join(run.outDir, viewportDir(viewport)),
|
|
114
|
+
boot: () => Promise.resolve(shared),
|
|
115
|
+
});
|
|
116
|
+
shots.push({ viewport, artifacts });
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
await server.stop().catch(() => undefined);
|
|
120
|
+
}
|
|
121
|
+
// The summary is the first failure's, else the first picture's — labelled by its size, so a
|
|
122
|
+
// reader of one line knows which of the sizes it speaks for.
|
|
123
|
+
const worst = shots.find((shot) => !shot.artifacts.verdict.ok) ?? shots[0];
|
|
124
|
+
return {
|
|
125
|
+
ok: shots.every((shot) => shot.artifacts.verdict.ok),
|
|
126
|
+
command: 'shot',
|
|
127
|
+
summary:
|
|
128
|
+
worst === undefined
|
|
129
|
+
? refuse('lists no size')
|
|
130
|
+
: msg('cli.shot.viewport.summary', {
|
|
131
|
+
size: viewportDir(worst.viewport),
|
|
132
|
+
summary: shotSummary(worst.artifacts.verdict),
|
|
133
|
+
}),
|
|
134
|
+
lines: shots.flatMap((shot) => [
|
|
135
|
+
msg('cli.shot.viewport.line', {
|
|
136
|
+
size: viewportDir(shot.viewport),
|
|
137
|
+
summary: shotSummary(shot.artifacts.verdict),
|
|
138
|
+
}),
|
|
139
|
+
...shotLines(shot.artifacts).map((line) => ` ${line}`),
|
|
140
|
+
]),
|
|
141
|
+
data: {
|
|
142
|
+
outDir: run.outDir,
|
|
143
|
+
shots: shots.map((shot) => ({
|
|
144
|
+
width: shot.viewport.width,
|
|
145
|
+
height: shot.viewport.height,
|
|
146
|
+
image: shot.artifacts.image,
|
|
147
|
+
verdictFile: shot.artifacts.verdictFile,
|
|
148
|
+
verdict: verdictJson(shot.artifacts.verdict),
|
|
149
|
+
})),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
package/src/signal-shred.ts
CHANGED
|
@@ -4,24 +4,38 @@
|
|
|
4
4
|
|
|
5
5
|
export type Reraise = (signal: NodeJS.Signals) => void;
|
|
6
6
|
|
|
7
|
+
/** Ctrl-C, a supervisor's stop, and a terminal closing under the editor. */
|
|
8
|
+
const SHRED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const;
|
|
9
|
+
|
|
7
10
|
const reraiseDefault: Reraise = (signal) => {
|
|
8
11
|
process.kill(process.pid, signal);
|
|
9
12
|
};
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
|
-
* Shred on SIGINT/SIGTERM, then re-raise the same signal
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* Shred on SIGINT/SIGTERM/SIGHUP, then re-raise the same signal with the listeners gone, so it
|
|
16
|
+
* meets the default action and terminates. Returns the undo for the normal path, which shreds in
|
|
17
|
+
* its own `finally`.
|
|
18
|
+
*
|
|
19
|
+
* The listeners stay installed UNTIL the shred is done, and a repeat meanwhile is ignored. With
|
|
20
|
+
* `once` the listener was gone the moment the first signal was dispatched, so a second one — a
|
|
21
|
+
* Ctrl-C the terminal delivers to the whole group and a wrapper forwards again — met the default
|
|
22
|
+
* action: a kernel-level kill mid-`rmSync`, the plaintext left in $TMPDIR (30 of 40 runs).
|
|
15
23
|
*/
|
|
16
24
|
export function shredOnSignal(shred: () => void, reraise: Reraise = reraiseDefault): () => void {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
};
|
|
21
|
-
process.once('SIGINT', handler);
|
|
22
|
-
process.once('SIGTERM', handler);
|
|
23
|
-
return () => {
|
|
24
|
-
process.off('SIGINT', handler);
|
|
25
|
-
process.off('SIGTERM', handler);
|
|
25
|
+
let shredding = false;
|
|
26
|
+
const undo = (): void => {
|
|
27
|
+
for (const signal of SHRED_SIGNALS) process.off(signal, handler);
|
|
26
28
|
};
|
|
29
|
+
function handler(signal: NodeJS.Signals): void {
|
|
30
|
+
if (shredding) return;
|
|
31
|
+
shredding = true;
|
|
32
|
+
try {
|
|
33
|
+
shred();
|
|
34
|
+
} finally {
|
|
35
|
+
undo();
|
|
36
|
+
reraise(signal);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const signal of SHRED_SIGNALS) process.on(signal, handler);
|
|
40
|
+
return undo;
|
|
27
41
|
}
|
package/src/site-asset-routes.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { isUltimateError } from '@ultimat3/core';
|
|
7
7
|
import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
|
|
8
|
-
import { applyCacheHeaders,
|
|
8
|
+
import { applyCacheHeaders, jsonResponse } from '@ultimat3/http';
|
|
9
9
|
import type { SiteAsset, SiteAssetTable } from './site-assets';
|
|
10
10
|
import { parseHashedAssetUrl, SITE_ASSET_BASE_PATH } from './site-assets';
|
|
11
11
|
|
|
@@ -13,7 +13,7 @@ import { parseHashedAssetUrl, SITE_ASSET_BASE_PATH } from './site-assets';
|
|
|
13
13
|
export const SITE_ASSET_CACHE: CacheHint = { mode: 'immutable' };
|
|
14
14
|
|
|
15
15
|
const notFound = (code: string, cause: string, fix: string): Response =>
|
|
16
|
-
|
|
16
|
+
jsonResponse({ ok: false, error: { code, cause, fix } }, { status: 404 });
|
|
17
17
|
|
|
18
18
|
/** One `bytes=` range, resolved against the file's size; `null` when it cannot be satisfied. */
|
|
19
19
|
export function byteRange(
|
package/src/site-assets.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { copyFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
9
9
|
import { dirname, join } from 'node:path'; // why: Bun ships no path API.
|
|
10
10
|
import type { AssetExtension, AssetPath } from '@ultimat3/render';
|
|
11
11
|
import { ASSET_DIR, AssetMissingError, assetPathProblem } from '@ultimat3/render';
|
|
12
|
+
import { contentHash } from '@ultimat3/render/server';
|
|
12
13
|
|
|
13
14
|
/** App-root-relative, beside `favicon.ico`: `apps/web/site/` is where an app's public files live. */
|
|
14
15
|
export const SITE_ASSETS_SOURCE = `apps/web/site/${ASSET_DIR}`;
|
|
@@ -37,7 +38,7 @@ export interface SiteAsset {
|
|
|
37
38
|
readonly path: AssetPath;
|
|
38
39
|
/** Absolute path on disk. */
|
|
39
40
|
readonly file: string;
|
|
40
|
-
/**
|
|
41
|
+
/** `@ultimat3/render`'s `contentHash` of the bytes, 8 hex characters — the one CSS and HTML get. */
|
|
41
42
|
readonly hash: string;
|
|
42
43
|
/** `/assets/brand/logo.<hash>.svg`. */
|
|
43
44
|
readonly url: string;
|
|
@@ -69,9 +70,6 @@ export function parseHashedAssetUrl(
|
|
|
69
70
|
return { path: path as AssetPath, hash: match[2] ?? '' };
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
const hashBytes = (bytes: Uint8Array): string =>
|
|
73
|
-
Bun.hash.xxHash32(bytes).toString(16).padStart(8, '0');
|
|
74
|
-
|
|
75
73
|
export interface SiteAssetTable {
|
|
76
74
|
/** The asset `asset()` named, hashed as the bytes are NOW. Throws `X_ASSET_MISSING`. */
|
|
77
75
|
resolve(path: AssetPath): SiteAsset;
|
|
@@ -110,7 +108,7 @@ export function createSiteAssetTable(root: string): SiteAssetTable {
|
|
|
110
108
|
const cached = cache.get(path);
|
|
111
109
|
if (cached !== undefined && cached.stamp === stamp) return cached.asset;
|
|
112
110
|
const bytes = readFileSync(file);
|
|
113
|
-
const hash =
|
|
111
|
+
const hash = contentHash(bytes);
|
|
114
112
|
// `assetPathProblem` already refused an unknown extension; the guard keeps `constructor` from
|
|
115
113
|
// ever answering an `Object` member if that check is loosened.
|
|
116
114
|
const extension = splitExtension(path).extension.toLowerCase();
|