@ultimat3/cli 24.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 +15 -8
- package/README.md +30 -5
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +3 -1
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +3 -3
- package/src/app-env.ts +21 -14
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +2 -2
- package/src/app-mcp.ts +25 -27
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +2 -8
- package/src/app-permissions-site.ts +31 -7
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +115 -121
- package/src/cdp-shot-driver.ts +17 -0
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth.ts +2 -2
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +89 -9
- package/src/cmd-db-backfill.ts +1 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +5 -2
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -357
- package/src/cmd-doctor.ts +16 -66
- package/src/cmd-env.ts +29 -14
- package/src/cmd-generate.ts +12 -2
- package/src/cmd-i18n.ts +11 -8
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +18 -137
- package/src/cmd-mcp.ts +25 -7
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +9 -0
- package/src/cmd-routes.ts +3 -3
- package/src/cmd-shot-matrix.ts +21 -4
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +35 -39
- package/src/cmd-test.ts +1 -1
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +19 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +1 -1
- package/src/dev-dashboard.ts +5 -1
- package/src/dev-lock.ts +96 -24
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +14 -7
- package/src/doctor-auth.ts +4 -4
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +2 -2
- package/src/drift.ts +29 -6
- package/src/error-codes.ts +6 -0
- package/src/error-page-csp.ts +14 -15
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema.ts +13 -2
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-kinds.ts +3 -17
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +50 -1
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +21 -8
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +54 -64
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +32 -16
- package/src/local-cli-handoff.ts +90 -0
- package/src/mcp-errors.ts +8 -1
- package/src/mcp-host.ts +6 -5
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +27 -16
- package/src/metrics-endpoint.ts +3 -2
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +18 -0
- package/src/page-speculation.ts +14 -11
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +63 -98
- package/src/pwa-artifacts.ts +8 -1
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/retry-memo.ts +1 -1
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +110 -31
- package/src/role-start.ts +64 -85
- package/src/role-sync.ts +65 -6
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +15 -2
- package/src/runtime-bindings.ts +23 -3
- package/src/runtime-cache.ts +8 -8
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +2 -2
- package/src/runtime-jobs.ts +78 -18
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +2 -2
- package/src/runtime-overrides.ts +3 -3
- package/src/runtime-purge.ts +1 -1
- package/src/runtime-queue.ts +12 -12
- package/src/runtime-realtime.ts +5 -3
- package/src/runtime-render.ts +77 -82
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +33 -13
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +3 -3
- package/src/schema-diff.ts +1 -1
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +2 -2
- package/src/serve-boot.ts +22 -11
- package/src/serve-drain.ts +3 -1
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +19 -8
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -15
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-seo.ts +6 -4
- package/src/source-files.ts +13 -3
- package/src/static-report.ts +5 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +1 -1
- package/src/templates/action.ts +4 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +23 -8
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +2 -1
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container.ts +12 -6
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-templates.ts +74 -18
- package/src/templates/scaffold-helm.ts +31 -4
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +18 -15
- package/src/templates/scaffold-shell.ts +17 -2
- package/src/test-only-shipped.ts +193 -0
- package/src/test-select.ts +2 -1
- package/src/test-shards.ts +1 -4
- package/src/theme-boot.ts +2 -2
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/verify-checks.ts +12 -2
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +87 -10
- package/src/verify-merge.ts +48 -16
- package/src/verify-part-step.ts +10 -2
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +3 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +0 -1
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/jobs-drain.ts +0 -230
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
package/src/runtime-services.ts
CHANGED
|
@@ -16,14 +16,13 @@ import {
|
|
|
16
16
|
systemClock,
|
|
17
17
|
} from '@ultimat3/core';
|
|
18
18
|
import type { RateLimitStore } from '@ultimat3/http';
|
|
19
|
-
import { postgresRateLimitStore } from '@ultimat3/http';
|
|
19
|
+
import { adoptRateLimitStore, postgresRateLimitStore } from '@ultimat3/http';
|
|
20
20
|
import type { EventBus, JobDriver, OutboxStore } from '@ultimat3/jobs';
|
|
21
21
|
import type { MailDriver } from '@ultimat3/mail';
|
|
22
22
|
import {
|
|
23
23
|
isMemoryDriver,
|
|
24
24
|
isUnconfiguredDriver,
|
|
25
25
|
resetMailDriver,
|
|
26
|
-
selectMailDriver,
|
|
27
26
|
setMailDriver,
|
|
28
27
|
} from '@ultimat3/mail';
|
|
29
28
|
import type { Transport, TransportSelection } from '@ultimat3/realtime/server';
|
|
@@ -36,6 +35,7 @@ import { msg } from './messages';
|
|
|
36
35
|
import type { DevServices, Env } from './runtime-bindings';
|
|
37
36
|
import { cacheTiersOf, startCacheTiers } from './runtime-cache';
|
|
38
37
|
import { type WorkerConfig, workerConfigOf } from './runtime-jobs';
|
|
38
|
+
import { selectAppMailDriver } from './runtime-mail';
|
|
39
39
|
import { inboxRetentionOf } from './runtime-notify-retention';
|
|
40
40
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
41
41
|
import { installRetentionSweep } from './runtime-purge';
|
|
@@ -57,7 +57,8 @@ export interface RunningServices {
|
|
|
57
57
|
*/
|
|
58
58
|
readonly realtime: RealtimeConfig;
|
|
59
59
|
/**
|
|
60
|
-
* `app.config.ts`'s `jobs.queues` / `concurrency` / `visibilityTimeoutMs`, for the `worker` role
|
|
60
|
+
* `app.config.ts`'s `jobs.queues` / `concurrency` / `visibilityTimeoutMs`, for the `worker` role —
|
|
61
|
+
* the queues replaced by the boot env's `WORKER_QUEUES` when it is set (`runtime-jobs.ts`).
|
|
61
62
|
* Optional because a `RunningServices` can be hand-built, and a test runtime with none keeps the
|
|
62
63
|
* worker's own defaults — the answer it always got. `startServices` always sets it.
|
|
63
64
|
*/
|
|
@@ -87,7 +88,7 @@ export interface RunningServices {
|
|
|
87
88
|
* `roles.web.replicas: 3` and `x new` scaffolds two.
|
|
88
89
|
*
|
|
89
90
|
* OPTIONAL because a `RunningServices` can be hand-built: a test's fixture runtime has a stub
|
|
90
|
-
* client and no shared store, which is `
|
|
91
|
+
* client and no shared store, which is `httpServer`'s memory store and `scope: 'process'` —
|
|
91
92
|
* the honest answer for it, and the one `startWeb` derives.
|
|
92
93
|
*/
|
|
93
94
|
readonly rateLimitStore?: RateLimitStore;
|
|
@@ -301,6 +302,20 @@ async function release(steps: readonly (() => void | Promise<void>)[]): Promise<
|
|
|
301
302
|
return failures;
|
|
302
303
|
}
|
|
303
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Every STOP path's form of `release`: every step runs, newest first, and the FIRST failure is
|
|
307
|
+
* rethrown — it is the cause, the rest its consequences. The one helper for this process's stops
|
|
308
|
+
* (`startServices`, `startRoles`, `bootServing`, `x dev`): a chain of `await`s with no `try`
|
|
309
|
+
* skipped every step after one rejection, and the replicator rejects a refused close on purpose.
|
|
310
|
+
* Steps are listed in BOOT order, as an unwind list is.
|
|
311
|
+
*/
|
|
312
|
+
export async function releaseOrThrow(
|
|
313
|
+
steps: readonly (() => void | Promise<void>)[],
|
|
314
|
+
): Promise<void> {
|
|
315
|
+
const failures = await release(steps);
|
|
316
|
+
if (failures.length > 0) throw failures[0];
|
|
317
|
+
}
|
|
318
|
+
|
|
304
319
|
export async function startServices(
|
|
305
320
|
services: DevServices,
|
|
306
321
|
env: Env,
|
|
@@ -308,10 +323,14 @@ export async function startServices(
|
|
|
308
323
|
/** `'verify'` for a serving role on an external database — `runtime-queue.ts`'s `SchemaMode`. */
|
|
309
324
|
schema: SchemaMode = 'apply',
|
|
310
325
|
): Promise<RunningServices> {
|
|
326
|
+
// The app's config is loaded ONCE, here, first, and every section below is read out of that one
|
|
327
|
+
// validated object — mail's `retainMime` is the first reader, so it is loaded before mail.
|
|
328
|
+
const config = await loadAppConfig(services.root);
|
|
311
329
|
// Before the queue: selection is pure — it parses `SMTP_URL` and builds a transport, it does
|
|
312
330
|
// not dial. A typo'd credential must fail on the spot rather than after PGlite has started and
|
|
313
331
|
// been unwound again, and it must fail at boot rather than on the first mail nobody receives.
|
|
314
|
-
|
|
332
|
+
// `mail.retainMime` rides along (`runtime-mail.ts`), refused here the same way.
|
|
333
|
+
const selection = selectAppMailDriver(env, config);
|
|
315
334
|
// Same reason, same place: building a purge driver reads env and dials nothing, so a half-set
|
|
316
335
|
// `FASTLY_API_TOKEN` without its service id fails here rather than on the first stale page.
|
|
317
336
|
const cdn = selectPurgeDriver(env);
|
|
@@ -320,11 +339,9 @@ export async function startServices(
|
|
|
320
339
|
// service starts, rather than quietly keeping the in-process bus. Which transport, which KV
|
|
321
340
|
// bucket and which presence TTL is `@ultimat3/realtime`'s decision, and it is the same call a
|
|
322
341
|
// `ROLE=sync` container makes, so this process cannot resolve the bus differently from the
|
|
323
|
-
// container it stands in for.
|
|
324
|
-
// read out of that one validated object.
|
|
325
|
-
const config = await loadAppConfig(services.root);
|
|
342
|
+
// container it stands in for.
|
|
326
343
|
const realtime = realtimeConfigOf(config);
|
|
327
|
-
const workerConfig = workerConfigOf(config);
|
|
344
|
+
const workerConfig = workerConfigOf(config, env);
|
|
328
345
|
const bus: TransportSelection = selectTransport(env, realtime);
|
|
329
346
|
// `env`, not the ambient one: this function is HANDED the boot's environment and every other
|
|
330
347
|
// reader here already uses it, so a queue that asked `process.env` would decide the standby from
|
|
@@ -358,6 +375,10 @@ export async function startServices(
|
|
|
358
375
|
postgresAuthLimiter({ executor, clock: systemClock, policy }),
|
|
359
376
|
);
|
|
360
377
|
started.push(() => resetAuthLimiters());
|
|
378
|
+
// Where an action's or a query's declared `rateLimit:` is counted — on every surface, so a
|
|
379
|
+
// task or a one-off command spends from the fleet's table too, not one process' memory. Before
|
|
380
|
+
// `loadApp` for the auth limiters' reason; `startRoles` stacks a deployment's override over it.
|
|
381
|
+
started.push(adoptRateLimitStore(rateLimitStore));
|
|
361
382
|
// The hourly sweep over the framework tables this boot is responsible for. Every one of them
|
|
362
383
|
// ships a `purgeExpired()` that nothing called, so every row written was a row kept —
|
|
363
384
|
// `x_rate_limit` takes one upsert per request the web role serves, assets included.
|
|
@@ -407,8 +428,8 @@ export async function startServices(
|
|
|
407
428
|
started.push(() => resetMailDriver());
|
|
408
429
|
const purge = overrides?.purge ?? cdn.driver;
|
|
409
430
|
// Every tier this process reads through, plus the cross-instance invalidation hop, in one
|
|
410
|
-
// call. The CDN tier used to be the only one registered here — so `
|
|
411
|
-
// `
|
|
431
|
+
// call. The CDN tier used to be the only one registered here — so `redisTier`,
|
|
432
|
+
// `lruTier` and `memoTier` shipped with no caller at all and every replica
|
|
412
433
|
// recomputed every cached read. Released with `resetTiers()`, which drops the whole registry:
|
|
413
434
|
// this boot is the only thing that registers one, and a tier left behind would purge for a
|
|
414
435
|
// process that has stopped.
|
|
@@ -446,8 +467,7 @@ export async function startServices(
|
|
|
446
467
|
// the FIRST failure is rethrown because it is the cause and the rest are its consequences,
|
|
447
468
|
// and every step still runs, so a refused shutdown never leaks into the next boot.
|
|
448
469
|
async stop() {
|
|
449
|
-
|
|
450
|
-
if (failures.length > 0) throw failures[0];
|
|
470
|
+
await releaseOrThrow(started);
|
|
451
471
|
},
|
|
452
472
|
};
|
|
453
473
|
} catch (error) {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { finiteCount, readWithinLimit } from '@ultimat3/core';
|
|
7
7
|
import type { RequestContext, Route, UltimateRequest } from '@ultimat3/http';
|
|
8
|
-
import {
|
|
8
|
+
import { jsonResponse, NO_STORE } from '@ultimat3/http';
|
|
9
9
|
import type { Storage } from '@ultimat3/storage';
|
|
10
10
|
import {
|
|
11
11
|
acceptSignedUpload,
|
|
@@ -74,7 +74,7 @@ async function acceptUpload(
|
|
|
74
74
|
: { allowedContentTypes: [constraints.contentType] }),
|
|
75
75
|
}),
|
|
76
76
|
});
|
|
77
|
-
return
|
|
77
|
+
return jsonResponse({ key: stored.key }, { status: 201 });
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/**
|
package/src/runtime-storage.ts
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
// `localDriver` SIGNS `/_storage/<disk>/<key>`, so a local `'/_storage'` here is a second statement
|
|
8
8
|
// of one constant — and a signer and a reader that disagree serve 404 for every signed URL.
|
|
9
9
|
|
|
10
|
-
import { actorOf } from '@ultimat3/action';
|
|
11
10
|
import type { Actor } from '@ultimat3/core';
|
|
11
|
+
import { actorOf } from '@ultimat3/core';
|
|
12
12
|
import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
|
|
13
13
|
import { asCtx, unauthenticated } from '@ultimat3/http';
|
|
14
14
|
import type { KnownPermission } from '@ultimat3/policy';
|
|
15
|
-
import { can, codeOf, evaluate,
|
|
15
|
+
import { can, codeOf, evaluate, policyForbidden, reasonOf } from '@ultimat3/policy';
|
|
16
16
|
import type { Storage, StorageRead } from '@ultimat3/storage';
|
|
17
17
|
import {
|
|
18
18
|
assertSafeKey,
|
|
@@ -88,7 +88,7 @@ export function authorizeStorageRead(input: StorageReadInput, ctx: RequestContex
|
|
|
88
88
|
const reason = reasonOf(evaluation.decision) ?? 'denied';
|
|
89
89
|
throw codeOf(evaluation.decision) === 'X_UNAUTHENTICATED'
|
|
90
90
|
? unauthenticated(ctx.url.pathname)
|
|
91
|
-
:
|
|
91
|
+
: policyForbidden(policy.label, reason);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
package/src/schema-diff.ts
CHANGED
|
@@ -43,7 +43,7 @@ const RECORDED_ONLY = 'was recorded by the newest migration and no entity declar
|
|
|
43
43
|
/** The difference's name — what `x db gen "record …"` is told to write down. */
|
|
44
44
|
const REPLICA_IDENTITY_FULL = 'replica identity full';
|
|
45
45
|
const REPLICA_IDENTITY_UNRECORDED =
|
|
46
|
-
'needs REPLICA IDENTITY FULL — a channel with params
|
|
46
|
+
'needs REPLICA IDENTITY FULL — a channel with params routes deletes by its old row — and no ' +
|
|
47
47
|
'migration recorded it';
|
|
48
48
|
|
|
49
49
|
const columns = (names: readonly string[]): string => names.join(', ');
|
package/src/secrets-rotation.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
// A master-key rotation in an order a crash cannot turn into data loss. The new key is STAGED at
|
|
2
|
-
// `<key>.next` (0600) before anything is sealed with it, the committed file is sealed second, and
|
|
2
|
+
// `<key>.next` (owner-only: 0600, and an ACL on Windows) before anything is sealed with it, the committed file is sealed second, and
|
|
3
3
|
// the rename that makes the new key live is last. Interrupted anywhere, some key on disk opens the
|
|
4
4
|
// committed file — and `recoverRotation`, which every `x secrets` command runs first, finishes or
|
|
5
5
|
// abandons the move by asking the file which key it answers to.
|
|
6
6
|
|
|
7
|
-
import { existsSync, readFileSync,
|
|
7
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs'; // why: Bun has no sync exists/remove; the key is WRITTEN and RENAMED only by core's owner-only writers.
|
|
8
8
|
import type { MasterKeyRef, SecretValues } from '@ultimat3/core';
|
|
9
9
|
import {
|
|
10
10
|
generateMasterKey,
|
|
11
11
|
isUltimateError,
|
|
12
12
|
masterKeyPath,
|
|
13
|
+
promoteStagedMasterKey,
|
|
13
14
|
readSecretsFile,
|
|
14
|
-
SECRETS_KEY_MODE,
|
|
15
15
|
stagedMasterKeyPath,
|
|
16
|
+
stageMasterKeyFile,
|
|
16
17
|
writeSecretsFile,
|
|
17
18
|
} from '@ultimat3/core';
|
|
18
19
|
|
|
@@ -24,14 +25,12 @@ const fileKey = (root: string, hex: string): MasterKeyRef => ({
|
|
|
24
25
|
|
|
25
26
|
/** Stage, seal, then make live. Returns the key now in force. */
|
|
26
27
|
export async function rotateMasterKey(root: string, values: SecretValues): Promise<MasterKeyRef> {
|
|
27
|
-
const staged = stagedMasterKeyPath(root);
|
|
28
28
|
const hex = generateMasterKey();
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
writeFileSync(staged, `${hex}\n`, { encoding: 'utf-8', mode: SECRETS_KEY_MODE, flag: 'wx' });
|
|
29
|
+
// A fresh temp file renamed over any leftover: a staged key left 0644 is replaced, never reused.
|
|
30
|
+
stageMasterKeyFile(root, hex);
|
|
32
31
|
const next = fileKey(root, hex);
|
|
33
32
|
await writeSecretsFile(root, values, next);
|
|
34
|
-
|
|
33
|
+
promoteStagedMasterKey(root);
|
|
35
34
|
return next;
|
|
36
35
|
}
|
|
37
36
|
|
|
@@ -53,7 +52,7 @@ export async function recoverRotation(root: string, key: MasterKeyRef): Promise<
|
|
|
53
52
|
// Throws the staged key's own mismatch when neither opens it — the committed file's problem,
|
|
54
53
|
// which `X_SECRETS_KEY_MISMATCH`'s fix (restore it from git) answers.
|
|
55
54
|
await readSecretsFile(root, candidate);
|
|
56
|
-
|
|
55
|
+
promoteStagedMasterKey(root);
|
|
57
56
|
return candidate;
|
|
58
57
|
}
|
|
59
58
|
}
|
package/src/seo-routes.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { DEFAULT_ENVIRONMENT, type Environment, tryResolveEnvironment } from '@ultimat3/core';
|
|
6
6
|
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
7
|
-
import { applyCacheHeaders,
|
|
7
|
+
import { applyCacheHeaders, jsonResponse, NO_STORE } from '@ultimat3/http';
|
|
8
8
|
import { SITEMAP_PARTS_DIR } from '@ultimat3/seo';
|
|
9
9
|
import { quoteArg } from './shell-quote';
|
|
10
10
|
import { NO_SITE_SETTINGS, publicOrigin, type SiteSettings } from './site-config';
|
|
@@ -125,7 +125,7 @@ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
|
|
|
125
125
|
);
|
|
126
126
|
}
|
|
127
127
|
const index = new URL(SITEMAP_PATH, originOf(options, request)).href;
|
|
128
|
-
const missing =
|
|
128
|
+
const missing = jsonResponse(
|
|
129
129
|
{
|
|
130
130
|
ok: false,
|
|
131
131
|
error: {
|
package/src/serve-boot.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { warnIfIdempotencyProcessScoped } from './runtime-idempotency-scope';
|
|
|
16
16
|
import { warnUnsealedMfaSecrets } from './runtime-mfa-warning';
|
|
17
17
|
import { replicaOverrides } from './runtime-replica';
|
|
18
18
|
import type { RunningServices } from './runtime-services';
|
|
19
|
-
import { startServices } from './runtime-services';
|
|
19
|
+
import { releaseOrThrow, startServices } from './runtime-services';
|
|
20
20
|
import { loadDrainConfig, loadHealthConfig } from './serve-drain';
|
|
21
21
|
import { configureReporting, containerBinding, metricsPortFor, portFromEnv } from './serve-env';
|
|
22
22
|
import { adoptPrebuiltStyles, reportBootBuilds, watchBootBuilds } from './serve-prebuilt';
|
|
@@ -49,16 +49,22 @@ const LISTENING_ROLES: readonly Role[] = ['web', 'sync'];
|
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* `app.config.ts`'s `drain`, applied to the WHOLE process rather than only to the web server that
|
|
52
|
-
* `
|
|
52
|
+
* `httpServer` hands it to. A role nothing routes to gets no readiness grace at all: the grace
|
|
53
53
|
* holds `/readyz` at 503 with the listener open so the endpoints catch up, and a worker has neither
|
|
54
54
|
* — it only went on claiming jobs for those seconds, then aborted them at the drain (s1-con #7).
|
|
55
|
+
*
|
|
56
|
+
* The budget (`deadlineMs`) is every role's: it is the time a worker's running job has to finish
|
|
57
|
+
* on a deploy, so it is the one key here a non-listening role needs MOST.
|
|
55
58
|
*/
|
|
56
59
|
export function lifecycleForRole(
|
|
57
60
|
role: Role,
|
|
58
61
|
drain: Partial<DrainConfig> | undefined,
|
|
59
62
|
): LifecycleOptions {
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
const budget = drain?.deadlineMs === undefined ? {} : { deadlineMs: drain.deadlineMs };
|
|
64
|
+
if (!LISTENING_ROLES.includes(role)) return { readinessGraceMs: 0, ...budget };
|
|
65
|
+
return drain?.readinessGraceMs === undefined
|
|
66
|
+
? budget
|
|
67
|
+
: { readinessGraceMs: drain.readinessGraceMs, ...budget };
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
/**
|
|
@@ -181,13 +187,18 @@ async function bootRoles(boot: {
|
|
|
181
187
|
running,
|
|
182
188
|
runtime,
|
|
183
189
|
async stop() {
|
|
184
|
-
|
|
185
|
-
//
|
|
186
|
-
web
|
|
187
|
-
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
190
|
+
// Every step runs and the first failure is rethrown (`releaseOrThrow`): a refused role stop
|
|
191
|
+
// used to leave the services, the PGlite lock and the exporter behind it. Listed in boot
|
|
192
|
+
// order, released newest first — the roles; then the web surface, after the server drained
|
|
193
|
+
// (a bust landing mid-drain still marks the pages it is serving); then the services; LAST
|
|
194
|
+
// the exporter, so the drain's own spans and the final counter snapshot still have
|
|
195
|
+
// somewhere to go.
|
|
196
|
+
await releaseOrThrow([
|
|
197
|
+
stopOtlp,
|
|
198
|
+
() => runtime.stop(),
|
|
199
|
+
() => web?.release(),
|
|
200
|
+
() => running.stop(),
|
|
201
|
+
]);
|
|
191
202
|
},
|
|
192
203
|
};
|
|
193
204
|
}
|
package/src/serve-drain.ts
CHANGED
|
@@ -7,7 +7,9 @@ import { loadAppConfig } from './app-config-load';
|
|
|
7
7
|
/** The declared section, or `undefined` for a root with no config file — core keeps its default. */
|
|
8
8
|
export async function loadDrainConfig(root: string): Promise<Partial<DrainConfig> | undefined> {
|
|
9
9
|
const drain = (await loadAppConfig(root))?.drain;
|
|
10
|
-
return drain === undefined
|
|
10
|
+
return drain === undefined
|
|
11
|
+
? undefined
|
|
12
|
+
: { readinessGraceMs: drain.readinessGraceMs, deadlineMs: drain.deadlineMs };
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
/** `health.readiness`, or `undefined` for a root with no config file (core keeps its default). */
|
|
@@ -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,16 +4,18 @@
|
|
|
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 {
|
|
7
|
+
import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
8
|
+
|
|
9
|
+
import { describePages } from '@ultimat3/render';
|
|
8
10
|
import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
|
|
9
11
|
import { loadSignInPath } from './app-auth';
|
|
10
12
|
import { mountAppMcp } from './app-mcp';
|
|
11
|
-
import {
|
|
13
|
+
import { errorPageStyleBodies } from './error-page-csp';
|
|
12
14
|
import { islandRoutes } from './island-routes';
|
|
13
15
|
import type { LoadedIslands } from './island-store';
|
|
14
16
|
import { loadOrBuildIslands } from './island-store';
|
|
15
17
|
import { loadNavigation, pageNavigation } from './page-navigation';
|
|
16
|
-
import { loadSpeculation, pageSpeculation } from './page-speculation';
|
|
18
|
+
import { loadSpeculation, otherLocaleSegments, pageSpeculation } from './page-speculation';
|
|
17
19
|
import { pageSync } from './page-sync';
|
|
18
20
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
19
21
|
import { adminMountRoutes } from './runtime-admin';
|
|
@@ -30,13 +32,14 @@ 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;
|
|
@@ -60,6 +63,8 @@ export async function webSurface(
|
|
|
60
63
|
// prevent, and it is the one an operator cannot see without installing the app.
|
|
61
64
|
const pwa = await loadPwaArtifacts(options.root);
|
|
62
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);
|
|
63
68
|
// `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
|
|
64
69
|
const site = await loadSiteSettings(options.root);
|
|
65
70
|
const origin = publicOrigin(options.env, site);
|
|
@@ -73,8 +78,9 @@ export async function webSurface(
|
|
|
73
78
|
const speculation = pageSpeculation({
|
|
74
79
|
config: await loadSpeculation(options.root),
|
|
75
80
|
client: declared.surfaces,
|
|
81
|
+
localeSegments: otherLocaleSegments(await appLocaleSet(options.root)),
|
|
76
82
|
});
|
|
77
|
-
// 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
|
|
78
84
|
// the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
|
|
79
85
|
// route added here cannot be missing from the precache manifest.
|
|
80
86
|
const serviceWorker =
|
|
@@ -83,7 +89,7 @@ export async function webSurface(
|
|
|
83
89
|
: serviceWorkerArtifacts({
|
|
84
90
|
pwa,
|
|
85
91
|
buildId,
|
|
86
|
-
routes:
|
|
92
|
+
routes: describePages(),
|
|
87
93
|
islands,
|
|
88
94
|
styles: styleBundle(),
|
|
89
95
|
scripts: [
|
|
@@ -135,6 +141,7 @@ export async function webSurface(
|
|
|
135
141
|
...adminMountRoutes({
|
|
136
142
|
buildId: buildId,
|
|
137
143
|
themeHead: theme.head,
|
|
144
|
+
...(brand === undefined ? {} : { brandHead: brand.head }),
|
|
138
145
|
...(origin === undefined ? {} : { origin }),
|
|
139
146
|
}),
|
|
140
147
|
...appRoutes({
|
|
@@ -144,6 +151,7 @@ export async function webSurface(
|
|
|
144
151
|
persisted: sync.persisted,
|
|
145
152
|
...(navigation.head === undefined ? {} : { navigation: navigation.head }),
|
|
146
153
|
themeHead: theme.head,
|
|
154
|
+
...(brand === undefined ? {} : { brandHead: brand.head }),
|
|
147
155
|
...(speculation === undefined ? {} : { speculationHead: speculation.head }),
|
|
148
156
|
// A `static` page is the same bytes for every request of this process: rendered once.
|
|
149
157
|
memoStatic: true,
|
|
@@ -158,8 +166,11 @@ export async function webSurface(
|
|
|
158
166
|
routes,
|
|
159
167
|
inlineScripts: [theme.cspSource, ...(speculation === undefined ? [] : [speculation.cspSource])],
|
|
160
168
|
// The enforced policy this process sends must admit the app's own error pages' `<style>` and
|
|
161
|
-
// the
|
|
162
|
-
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
|
+
],
|
|
163
174
|
// Same declaration `x dev` reads. Without it a container answers a browser that opened a
|
|
164
175
|
// guarded page with the problem document, rendered as raw JSON in the viewport.
|
|
165
176
|
signInPath: await loadSignInPath(options.root),
|
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,23 +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 {
|
|
6
|
+
import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
7
7
|
import { BadFlagError } from './errors';
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
/** An app that declares nothing is `en` only — the framework's own default. */
|
|
15
|
-
export const FALLBACK_SHOT_LOCALES: ShotLocales = { locales: ['en'], defaultLocale: 'en' };
|
|
16
|
-
|
|
17
|
-
/** `app.config.ts`'s `locales` and `defaultLocale`, off the one loader (`app-config-load.ts`). */
|
|
18
|
-
export async function loadShotLocales(root: string): Promise<ShotLocales> {
|
|
19
|
-
const config = await loadAppConfig(root);
|
|
20
|
-
if (config === undefined) return FALLBACK_SHOT_LOCALES;
|
|
21
|
-
return { locales: config.locales, defaultLocale: config.defaultLocale };
|
|
22
|
-
}
|
|
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;
|
|
23
14
|
|
|
24
15
|
/** `--locale <l>`, refused by name when the app does not declare it — a typo costs no browser. */
|
|
25
16
|
export function readLocaleFlag(value: string | undefined, app: ShotLocales): string | undefined {
|