@ultimat3/cli 25.2.0 → 26.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +28 -28
- package/src/app-env.ts +23 -8
- package/src/budgets.ts +2 -1
- package/src/cmd-db-branch.ts +15 -11
- package/src/cmd-db-spec.ts +2 -2
- package/src/cmd-deploy-drain.ts +6 -1
- package/src/cmd-docs.ts +1 -1
- package/src/cmd-env.ts +4 -4
- package/src/cmd-jobs-spec.ts +11 -7
- package/src/cmd-jobs.ts +10 -10
- package/src/cmd-planned.ts +3 -3
- package/src/cmd-registries-spec.ts +6 -6
- package/src/cmd-registries.ts +6 -6
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-vapid-spec.ts +17 -0
- package/src/cmd-vapid.ts +135 -0
- package/src/db-branch.ts +6 -6
- package/src/db-finding.ts +1 -1
- package/src/dev-restart-retire.ts +3 -1
- package/src/dev-route-table.ts +4 -0
- package/src/document-styles.ts +2 -4
- package/src/error-contract.ts +1 -1
- package/src/errors.ts +4 -4
- package/src/fix-command.ts +1 -1
- package/src/framework-env.ts +92 -15
- package/src/framework-schema.ts +12 -0
- package/src/jobs-report.ts +3 -3
- package/src/jobs-table.ts +1 -1
- package/src/live-routes.ts +0 -1
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +2 -2
- package/src/messages.ts +11 -3
- package/src/page-navigation.ts +6 -1
- package/src/parse.ts +23 -2
- package/src/pwa-artifacts.ts +2 -2
- package/src/registry.ts +2 -0
- package/src/role-replicator.ts +1 -1
- package/src/runtime-jobs.ts +1 -1
- package/src/runtime-push.ts +80 -0
- package/src/runtime-services.ts +7 -0
- package/src/serve-boot.ts +8 -2
- package/src/serve-drain.ts +5 -1
- package/src/serve-retire.ts +7 -3
- package/src/serve-web.ts +4 -0
- package/src/site-seo.ts +1 -4
- package/src/sw-artifacts.ts +30 -13
- package/src/sync-url.ts +9 -0
- package/src/templates/scaffold-claude-agents.ts +4 -4
- package/src/templates/scaffold-claude-commands.ts +2 -2
- package/src/templates/scaffold-claude.ts +1 -1
- package/src/templates/scaffold-helm-templates.ts +6 -2
- package/src/templates/scaffold-helm.ts +4 -1
- package/src/worker-bundle.ts +21 -8
package/src/parse.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface CommandSpec {
|
|
|
44
44
|
* than a misspelt one. Declared per command, never inferred, because only some commands can say
|
|
45
45
|
* it truthfully: `x errors X_PERMISSION_UNKNOWN` can only be a code, and `x jobs 4f2a` is
|
|
46
46
|
* genuinely ambiguous with `show`, so an unconditional fallback would turn `x jobs <id>` into a
|
|
47
|
-
* silent `x jobs
|
|
47
|
+
* silent `x jobs list` that ignores the id.
|
|
48
48
|
*
|
|
49
49
|
* `x errors X_PERMISSION_UNKNOWN --json` answered `X_CLI_UNKNOWN_COMMAND … fix: x help`, and
|
|
50
50
|
* `x help` prints `errors an X_* code, explained` — which reads as exactly the form that was
|
|
@@ -304,6 +304,27 @@ function splitInline(raw: string): [string, string | undefined] {
|
|
|
304
304
|
return [raw.slice(0, eq), raw.slice(eq + 1)];
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
/**
|
|
308
|
+
* The verbs 26.0.0 retired (#709): one verb each across every `x` registry — `list`, `show <one>`,
|
|
309
|
+
* `delete` — and no alias, because two spellings of one verb is the ambiguity axiom 1 forbids. A
|
|
310
|
+
* retired verb is REFUSED, never accepted; this table only lets the refusal name its replacement.
|
|
311
|
+
*/
|
|
312
|
+
export const RETIRED_VERBS = Object.freeze<Record<string, string>>({
|
|
313
|
+
ls: 'list',
|
|
314
|
+
rm: 'delete',
|
|
315
|
+
describe: 'show',
|
|
316
|
+
drop: 'delete',
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
/** The verb that replaced `word`, when `word` was retired and its replacement is in `allowed`. */
|
|
320
|
+
export function retiredVerbReplacement(
|
|
321
|
+
word: string,
|
|
322
|
+
allowed: readonly string[],
|
|
323
|
+
): string | undefined {
|
|
324
|
+
const replacement = Object.hasOwn(RETIRED_VERBS, word) ? RETIRED_VERBS[word] : undefined;
|
|
325
|
+
return replacement !== undefined && allowed.includes(replacement) ? replacement : undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
307
328
|
/** Which subcommand ran, and whether the caller's first positional is what named it. */
|
|
308
329
|
interface ResolvedSubcommand {
|
|
309
330
|
readonly name: string | undefined;
|
|
@@ -323,7 +344,7 @@ function readSubcommand(spec: CommandSpec, positionals: readonly string[]): Reso
|
|
|
323
344
|
throw new MissingSubcommandError({ command: spec.name, known: allowed });
|
|
324
345
|
}
|
|
325
346
|
if (allowed.includes(token)) return { name: token, consumed: true };
|
|
326
|
-
const suggestion = nearestName(token, allowed);
|
|
347
|
+
const suggestion = retiredVerbReplacement(token, allowed) ?? nearestName(token, allowed);
|
|
327
348
|
// The declared fallback, and only past the near-miss guard: a word within `nearestName`'s edit
|
|
328
349
|
// budget of a real subcommand is a typo, and reading it as the default subcommand's argument
|
|
329
350
|
// would answer a question nobody asked.
|
package/src/pwa-artifacts.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// why: Bun exposes no path-join primitive, and the export writes each icon under an out directory.
|
|
19
19
|
import { join } from 'node:path';
|
|
20
20
|
import type { PwaColors, PwaOfflineConfig } from '@ultimat3/core';
|
|
21
|
-
import { escapeHtml, localeSegment } from '@ultimat3/core';
|
|
21
|
+
import { escapeHtml, localeSegment, pushWired } from '@ultimat3/core';
|
|
22
22
|
import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
|
|
23
23
|
import { applyCacheHeaders } from '@ultimat3/http';
|
|
24
24
|
import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
@@ -117,7 +117,7 @@ async function loadInstallable(root: string): Promise<InstallableApp | undefined
|
|
|
117
117
|
locales: appLocales(await appLocaleSet(root)),
|
|
118
118
|
offline: pwa.offline,
|
|
119
119
|
backgroundSync: pwa.backgroundSync,
|
|
120
|
-
push: pwa
|
|
120
|
+
push: pushWired(pwa),
|
|
121
121
|
};
|
|
122
122
|
}
|
|
123
123
|
|
package/src/registry.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { secretsSpec } from './cmd-secrets-spec';
|
|
|
37
37
|
import { shotSpec } from './cmd-shot-spec';
|
|
38
38
|
import { tasksSpec } from './cmd-tasks-spec';
|
|
39
39
|
import { testSpec } from './cmd-test-spec';
|
|
40
|
+
import { vapidSpec } from './cmd-vapid-spec';
|
|
40
41
|
import { verifySpec } from './cmd-verify-spec';
|
|
41
42
|
import type { CliCommand } from './command';
|
|
42
43
|
import type { CommandSpec } from './parse';
|
|
@@ -87,6 +88,7 @@ export const LAZY_COMMANDS: readonly LazyCommand[] = [
|
|
|
87
88
|
lazy(envSpec, async () => (await import('./cmd-env')).envCommand),
|
|
88
89
|
lazy(secretsSpec, async () => (await import('./cmd-secrets')).secretsCommand),
|
|
89
90
|
lazy(authSpec, async () => (await import('./cmd-auth')).authCommand),
|
|
91
|
+
lazy(vapidSpec, async () => (await import('./cmd-vapid')).vapidCommand),
|
|
90
92
|
lazy(manifestSpec, async () => (await import('./cmd-manifest')).manifestCommand),
|
|
91
93
|
lazy(routesSpec, async () => (await import('./cmd-routes')).routesCommand),
|
|
92
94
|
lazy(actionsSpec, async () => (await import('./cmd-registries')).actionsCommand),
|
package/src/role-replicator.ts
CHANGED
|
@@ -73,7 +73,7 @@ const embeddedRefusal = (dev: boolean): BadFlagError | ConfigInvalidError =>
|
|
|
73
73
|
* Both readers of this list are catalog readers. `PgReplicationStream` keeps a change only when
|
|
74
74
|
* `#entities.has(relation.name)`, and a pgoutput Relation message names the table; `warnPartialIdentity`
|
|
75
75
|
* matches the same list against `pg_class.relname`. An entity NAME is the framework's own registry
|
|
76
|
-
* key — what a cache tag, a policy and `x entities
|
|
76
|
+
* key — what a cache tag, a policy and `x entities show` are keyed by — and `entity('user',
|
|
77
77
|
* { table: 'users' })` makes the two different strings. Passing the name meant a renamed table
|
|
78
78
|
* matched nothing on either side: every change SKIPPED, and a replica-identity warning that could
|
|
79
79
|
* never fire. Latent wherever the two agree, which is every entity in `examples/dummy`.
|
package/src/runtime-jobs.ts
CHANGED
|
@@ -93,7 +93,7 @@ function reportUnserved(served: readonly string[], registered: readonly string[]
|
|
|
93
93
|
logger.warn('jobs.worker.queue-unserved', {
|
|
94
94
|
queues,
|
|
95
95
|
served,
|
|
96
|
-
fix: `x jobs
|
|
96
|
+
fix: `x jobs list --json — every queue listed with no worker needs one: WORKER_QUEUES=<queue>,<queue> on another worker Deployment, or add it to this one's`,
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
99
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Web Push at boot: `pwa.push` read off the one config, the VAPID pair resolved from the boot's
|
|
2
|
+
// environment (refused before any service starts), and the runtime installed over the boot's own
|
|
3
|
+
// executor — so `pushSubscribe()`, `pushUnsubscribe()` and `webPush()` work in every role with no
|
|
4
|
+
// line of app wiring, and a deploy with no keys never boots.
|
|
5
|
+
//
|
|
6
|
+
// `@ultimat3/pwa` behind `await import()`: an app with push off — most of them — loads none of it
|
|
7
|
+
// into a worker or a migrate pod (`serve-graph.test.ts`), exactly as the nats client is opened.
|
|
8
|
+
|
|
9
|
+
import type { AppConfig, PgExecutor } from '@ultimat3/core';
|
|
10
|
+
import { logger, PWA_PUSH_FIX, pushWired } from '@ultimat3/core';
|
|
11
|
+
import type { ResolvedVapidKeys } from '@ultimat3/pwa';
|
|
12
|
+
import type { Env } from './runtime-bindings';
|
|
13
|
+
|
|
14
|
+
/** `pwa.vapid.subject` when this app's boot owes a push runtime, else `undefined`. */
|
|
15
|
+
export function pushSubjectOf(config: AppConfig | undefined): string | undefined {
|
|
16
|
+
const pwa = config?.pwa;
|
|
17
|
+
if (pwa === undefined || !pushWired(pwa)) return undefined;
|
|
18
|
+
// `pushWired` checked the block is there; `defineConfig` checked its subject is one a push
|
|
19
|
+
// service takes (`pwaPushIssues`).
|
|
20
|
+
return pwa.vapid?.subject;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PushSelection {
|
|
24
|
+
readonly subject: string;
|
|
25
|
+
readonly resolved: ResolvedVapidKeys;
|
|
26
|
+
/** `pwa.vapid.pushHosts`: push services beyond the built-in list. */
|
|
27
|
+
readonly pushHosts: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pure apart from the key check — reads env, dials nothing — so it runs BEFORE the queue, beside
|
|
32
|
+
* the mail driver's selection: a deploy missing `ULTIMATE_VAPID_PRIVATE_KEY` fails on the spot
|
|
33
|
+
* with `X_PWA_VAPID_KEY_MISSING`, not after PGlite started, and never on the first push nobody got.
|
|
34
|
+
*/
|
|
35
|
+
export async function selectWebPush(
|
|
36
|
+
config: AppConfig | undefined,
|
|
37
|
+
env: Env,
|
|
38
|
+
): Promise<PushSelection | undefined> {
|
|
39
|
+
const subject = pushSubjectOf(config);
|
|
40
|
+
if (subject === undefined) {
|
|
41
|
+
const pwa = config?.pwa;
|
|
42
|
+
// Accepted for 26.0.0's sake, and never silent: the app asked for push and gets none.
|
|
43
|
+
if (pwa?.enabled === true && pwa.push) {
|
|
44
|
+
logger.warn('pwa.push unwired', {
|
|
45
|
+
cause:
|
|
46
|
+
'pwa.push is true and pwa.vapid is unset, so no push runtime, handler or key is wired',
|
|
47
|
+
fix: PWA_PUSH_FIX,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const { resolveVapidKeys } = await import('@ultimat3/pwa');
|
|
53
|
+
return {
|
|
54
|
+
subject,
|
|
55
|
+
resolved: await resolveVapidKeys(env),
|
|
56
|
+
pushHosts: config?.pwa.vapid?.pushHosts ?? [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Install the runtime over the boot's executor; answers its release. */
|
|
61
|
+
export async function installAppWebPush(
|
|
62
|
+
selection: PushSelection,
|
|
63
|
+
executor: PgExecutor,
|
|
64
|
+
): Promise<() => void> {
|
|
65
|
+
const { installWebPush, postgresPushSubscriptionStore } = await import('@ultimat3/pwa');
|
|
66
|
+
if (selection.resolved.source === 'development') {
|
|
67
|
+
// Said once per boot: a reader seeing pushes arrive locally must know they are signed with a
|
|
68
|
+
// pair printed in the framework's source, which no deploy accepts.
|
|
69
|
+
logger.info('pwa.push development keys', {
|
|
70
|
+
cause: 'ULTIMATE_VAPID_PUBLIC_KEY and ULTIMATE_VAPID_PRIVATE_KEY are unset',
|
|
71
|
+
fix: 'x vapid create # before the first deploy; a deployed boot refuses the development pair',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return installWebPush({
|
|
75
|
+
store: postgresPushSubscriptionStore({ executor }),
|
|
76
|
+
keys: selection.resolved.keys,
|
|
77
|
+
subject: selection.subject,
|
|
78
|
+
pushHosts: selection.pushHosts,
|
|
79
|
+
});
|
|
80
|
+
}
|
package/src/runtime-services.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { selectAppMailDriver } from './runtime-mail';
|
|
|
40
40
|
import { inboxRetentionOf } from './runtime-notify-retention';
|
|
41
41
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
42
42
|
import { installRetentionSweep } from './runtime-purge';
|
|
43
|
+
import { installAppWebPush, selectWebPush } from './runtime-push';
|
|
43
44
|
import type { DevDbClient, SchemaMode } from './runtime-queue';
|
|
44
45
|
import { startQueue } from './runtime-queue';
|
|
45
46
|
import { realtimeConfigOf } from './runtime-realtime';
|
|
@@ -342,6 +343,9 @@ export async function startServices(
|
|
|
342
343
|
// `ROLE=sync` container makes, so this process cannot resolve the bus differently from the
|
|
343
344
|
// container it stands in for.
|
|
344
345
|
const realtime = realtimeConfigOf(config);
|
|
346
|
+
// Fourth: `pwa.push` and its VAPID pair (`runtime-push.ts`). Resolving the pair reads env and
|
|
347
|
+
// signs one probe — a deploy with no keys, or two halves of different pairs, refuses here.
|
|
348
|
+
const push = await selectWebPush(config, env);
|
|
345
349
|
const workerConfig = workerConfigOf(config, env);
|
|
346
350
|
const bus: TransportSelection = selectTransport(env, realtime);
|
|
347
351
|
// `env`, not the ambient one: this function is HANDED the boot's environment and every other
|
|
@@ -419,6 +423,9 @@ export async function startServices(
|
|
|
419
423
|
const connectable = transport;
|
|
420
424
|
started.push(registerReadinessCheck('transport', () => connectable.connected));
|
|
421
425
|
}
|
|
426
|
+
// Before `loadApp` for the auth limiters' reason: `pushSubscribe()` is declared when the app's
|
|
427
|
+
// modules import, and reads this runtime per call.
|
|
428
|
+
if (push !== undefined) started.push(await installAppWebPush(push, executor));
|
|
422
429
|
const storage = startStorage(services, env, overrides?.storage);
|
|
423
430
|
// With no credential this is the memory driver: caught, not sent, so the `/_x` mail panel can
|
|
424
431
|
// show what a template renders in every locale without a mailbox or a message escaping to a
|
package/src/serve-boot.ts
CHANGED
|
@@ -54,13 +54,19 @@ const LISTENING_ROLES: readonly Role[] = ['web', 'sync'];
|
|
|
54
54
|
* — it only went on claiming jobs for those seconds, then aborted them at the drain (s1-con #7).
|
|
55
55
|
*
|
|
56
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.
|
|
57
|
+
* on a deploy, so it is the one key here a non-listening role needs MOST. The worker's alone is
|
|
58
|
+
* `workerDeadlineMs` when declared — the hour `deadlineMs` is capped at is too short for a job
|
|
59
|
+
* that must not run twice, and a web role has no business waiting two hours on a request.
|
|
58
60
|
*/
|
|
59
61
|
export function lifecycleForRole(
|
|
60
62
|
role: Role,
|
|
61
63
|
drain: Partial<DrainConfig> | undefined,
|
|
62
64
|
): LifecycleOptions {
|
|
63
|
-
const
|
|
65
|
+
const declared =
|
|
66
|
+
role === 'worker' && drain?.workerDeadlineMs !== undefined
|
|
67
|
+
? drain.workerDeadlineMs
|
|
68
|
+
: drain?.deadlineMs;
|
|
69
|
+
const budget = declared === undefined ? {} : { deadlineMs: declared };
|
|
64
70
|
if (!LISTENING_ROLES.includes(role)) return { readinessGraceMs: 0, ...budget };
|
|
65
71
|
return drain?.readinessGraceMs === undefined
|
|
66
72
|
? budget
|
package/src/serve-drain.ts
CHANGED
|
@@ -9,7 +9,11 @@ export async function loadDrainConfig(root: string): Promise<Partial<DrainConfig
|
|
|
9
9
|
const drain = (await loadAppConfig(root))?.drain;
|
|
10
10
|
return drain === undefined
|
|
11
11
|
? undefined
|
|
12
|
-
: {
|
|
12
|
+
: {
|
|
13
|
+
readinessGraceMs: drain.readinessGraceMs,
|
|
14
|
+
deadlineMs: drain.deadlineMs,
|
|
15
|
+
workerDeadlineMs: drain.workerDeadlineMs,
|
|
16
|
+
};
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
/** `health.readiness`, or `undefined` for a root with no config file (core keeps its default). */
|
package/src/serve-retire.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// `ROLE=worker`'s retire, on SIGUSR2 (issue #669): stop claiming, finish every held job however
|
|
2
2
|
// long it takes, abort nothing — then the ordinary drain, so the process exits 0. It is what a
|
|
3
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
|
|
4
|
+
// happen at most once and may outlast the drain budget. Framework-owned: no port, no app code.
|
|
5
5
|
|
|
6
|
-
import { drain, logger, renderThrowable } from '@ultimat3/core';
|
|
6
|
+
import { drain, logger, markRetiring, renderThrowable } from '@ultimat3/core';
|
|
7
7
|
import type { Worker } from '@ultimat3/jobs';
|
|
8
8
|
|
|
9
9
|
/** The signal a worker retires on. Free in Bun and Node (SIGUSR1 is the inspector's). */
|
|
@@ -43,7 +43,8 @@ const NOTHING: WorkerRetire = Object.freeze({ adopt: () => undefined, disarm: ()
|
|
|
43
43
|
* Exits rather than holds, decided 2026-10-07: the `preStop` has no channel back but PID 1 itself,
|
|
44
44
|
* so "retired" has to be observable as "PID 1 is gone" — and a pod that has stopped claiming serves
|
|
45
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
|
|
46
|
+
* the retire to the worker's budget — `drain.workerDeadlineMs`, else `drain.deadlineMs` — and cuts
|
|
47
|
+
* off at the margin (`packages/jobs/src/worker.ts`). `isRetiring()` is true from the signal on.
|
|
47
48
|
*/
|
|
48
49
|
export function armWorkerRetire(options: WorkerRetireOptions = {}): WorkerRetire {
|
|
49
50
|
if ((options.platform ?? process.platform) === 'win32') return NOTHING;
|
|
@@ -84,6 +85,9 @@ export function armWorkerRetire(options: WorkerRetireOptions = {}): WorkerRetire
|
|
|
84
85
|
const listener = (): void => {
|
|
85
86
|
if (retiring) return;
|
|
86
87
|
retiring = true;
|
|
88
|
+
// At the signal, before the worker is awaited or stopped: the app's `isRetiring()` is how its
|
|
89
|
+
// own code (a heartbeat, a status row) sees a retire that `isDraining()` only shows at the end.
|
|
90
|
+
markRetiring();
|
|
87
91
|
// Never rejects: `retire` catches its own failure and always reaches the drain.
|
|
88
92
|
void retire();
|
|
89
93
|
};
|
package/src/serve-web.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { Route } from '@ultimat3/http';
|
|
7
7
|
import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
8
8
|
|
|
9
|
+
import { installedVapid } from '@ultimat3/pwa';
|
|
9
10
|
import { describePages } from '@ultimat3/render';
|
|
10
11
|
import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
|
|
11
12
|
import { loadSignInPath } from './app-auth';
|
|
@@ -96,6 +97,9 @@ export async function webSurface(
|
|
|
96
97
|
...sync.scripts,
|
|
97
98
|
...(navigation.script === undefined ? [] : [navigation.script]),
|
|
98
99
|
],
|
|
100
|
+
// The runtime `startServices` installed when `pwa.push` is on: the worker's push
|
|
101
|
+
// handler and the page's `x-push-key` come from the pair this process signs with.
|
|
102
|
+
vapid: installedVapid(),
|
|
99
103
|
});
|
|
100
104
|
// The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
|
|
101
105
|
const mcpMount = await mountAppMcp(options.root);
|
package/src/site-seo.ts
CHANGED
|
@@ -110,7 +110,7 @@ type Stamp = (file: string) => { readonly lastmod?: string };
|
|
|
110
110
|
/**
|
|
111
111
|
* The app's `seo.sitemap.extra` paths as records, each resolved to the route that answers it — the
|
|
112
112
|
* most specific match, the router's own tie-break. REFUSED, never skipped, when it names no route,
|
|
113
|
-
*
|
|
113
|
+
* a gated one or a `site/` page (listed already): a sitemap missing a page its
|
|
114
114
|
* author listed is the silent drop, and a gated page in it is a URL a crawler cannot open.
|
|
115
115
|
*/
|
|
116
116
|
function extraRoutes(extra: readonly string[], stamp: Stamp): readonly RouteRecord[] {
|
|
@@ -121,9 +121,6 @@ function extraRoutes(extra: readonly string[], stamp: Stamp): readonly RouteReco
|
|
|
121
121
|
.sort((a, b) => b.pattern.specificity - a.pattern.specificity)[0];
|
|
122
122
|
if (entry === undefined)
|
|
123
123
|
throw new SitemapExtraInvalidError(path, 'no registered route answers');
|
|
124
|
-
if (entry.surface === 'api') {
|
|
125
|
-
throw new SitemapExtraInvalidError(path, `is the api/ route ${entry.file}, not a page`);
|
|
126
|
-
}
|
|
127
124
|
if (entry.surface === 'site') {
|
|
128
125
|
throw new SitemapExtraInvalidError(
|
|
129
126
|
path,
|
package/src/sw-artifacts.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// until #390. Beside `pwa-artifacts.ts` and not inside it: that file needs a root and a config
|
|
4
4
|
// file, this one needs a booted app and a finished build.
|
|
5
5
|
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
6
|
+
import { escapeHtml } from '@ultimat3/core';
|
|
7
|
+
import type { PrecacheManifest, VapidConfig } from '@ultimat3/pwa';
|
|
8
|
+
import { generateServiceWorker, PUSH_KEY_META } from '@ultimat3/pwa';
|
|
8
9
|
import type { RouteDescriptor } from '@ultimat3/render';
|
|
9
10
|
import type { IslandBundle } from './island-bundle';
|
|
10
11
|
import { ISLAND_BASE_PATH } from './island-bundle';
|
|
@@ -72,6 +73,12 @@ export interface ServiceWorkerInput {
|
|
|
72
73
|
* offline reload that cannot load the boot restores no record and shows the old count.
|
|
73
74
|
*/
|
|
74
75
|
readonly scripts?: readonly { readonly url: string; readonly bytes: number }[];
|
|
76
|
+
/**
|
|
77
|
+
* The installed Web Push runtime's public key and subject (`@ultimat3/pwa`'s `installedVapid()`),
|
|
78
|
+
* which the boot put there from `pwa.push` and the VAPID pair — or absent where no runtime runs:
|
|
79
|
+
* a static export serves no subscribe action, so its worker carries no push handler.
|
|
80
|
+
*/
|
|
81
|
+
readonly vapid?: VapidConfig | undefined;
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
/**
|
|
@@ -175,6 +182,7 @@ export function serviceWorkerArtifacts(
|
|
|
175
182
|
personalPages: pwa.offline.personalPages,
|
|
176
183
|
},
|
|
177
184
|
capabilities: { backgroundSync: pwa.backgroundSync, push: pwa.push },
|
|
185
|
+
...(pwa.push && input.vapid !== undefined ? { vapid: input.vapid } : {}),
|
|
178
186
|
assets: precacheAssets({
|
|
179
187
|
routes: input.routes,
|
|
180
188
|
documents,
|
|
@@ -194,15 +202,14 @@ export function serviceWorkerArtifacts(
|
|
|
194
202
|
return {
|
|
195
203
|
source: output.source,
|
|
196
204
|
register: serviceWorkerRegistration(),
|
|
197
|
-
head,
|
|
205
|
+
head: head + pushKeyMeta(pwa, input.vapid),
|
|
198
206
|
precache: output.precache,
|
|
199
207
|
// `output.warnings` IS `output.precache.warnings` — the generator returns the manifest's list
|
|
200
208
|
// verbatim — so it is read once, not twice. The push line is this module's own, and it is the
|
|
201
209
|
// one thing the generator cannot say: `generateServiceWorker` emits a push handler only when a
|
|
202
|
-
// VAPID key comes with the capability, and drops it in SILENCE otherwise
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
warnings: [...output.warnings, ...pushWarning(pwa)],
|
|
210
|
+
// VAPID key comes with the capability, and drops it in SILENCE otherwise — which, since the
|
|
211
|
+
// boot installs the runtime whenever `pwa.push` is on, is a build with no runtime behind it.
|
|
212
|
+
warnings: [...output.warnings, ...pushWarning(pwa, input.vapid)],
|
|
206
213
|
};
|
|
207
214
|
}
|
|
208
215
|
|
|
@@ -219,10 +226,20 @@ export const serviceWorkerHead = (pwa: PwaArtifacts): string | undefined =>
|
|
|
219
226
|
pwa.offline.fallback === null ? undefined : `<script src="${SW_REGISTER_PATH}" defer></script>`;
|
|
220
227
|
|
|
221
228
|
/**
|
|
222
|
-
* `pwa.push: true`
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
229
|
+
* `pwa.push: true` in a build no Web Push runtime stands behind. A served boot installs one whenever
|
|
230
|
+
* the flag is on (`runtime-push.ts`, and it refuses to boot without the keys), so this fires for a
|
|
231
|
+
* static export alone — which serves no subscribe action, so a push handler there would be a
|
|
232
|
+
* capability the deploy cannot use. Said, never dropped in silence.
|
|
233
|
+
*/
|
|
234
|
+
const pushWarning = (pwa: PwaArtifacts, vapid: VapidConfig | undefined): readonly string[] =>
|
|
235
|
+
pwa.push && vapid === undefined ? [msg('cli.build.pushUnwired')] : [];
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* `<meta name="x-push-key">`: the public key `@ultimat3/pwa/client`'s `subscribeToPush` hands the
|
|
239
|
+
* browser. In the document rather than fetched, so subscribing is one click and no round trip, and
|
|
240
|
+
* a key rotation reaches every page on its next render. Public by definition; escaped regardless.
|
|
226
241
|
*/
|
|
227
|
-
const
|
|
228
|
-
pwa.push
|
|
242
|
+
const pushKeyMeta = (pwa: PwaArtifacts, vapid: VapidConfig | undefined): string =>
|
|
243
|
+
pwa.push && vapid !== undefined
|
|
244
|
+
? `<meta name="${PUSH_KEY_META}" content="${escapeHtml(vapid.publicKey)}">`
|
|
245
|
+
: '';
|
package/src/sync-url.ts
CHANGED
|
@@ -11,6 +11,15 @@ import { ConfigInvalidError } from '@ultimat3/core';
|
|
|
11
11
|
*/
|
|
12
12
|
export const SYNC_PATH = '/_x/sync';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Exactly what a proxy routes to the `sync` role: the socket's path, and anything under it. No
|
|
16
|
+
* other framework route matches it — scripts live under `FRAMEWORK_ASSET_BASE_PATH`
|
|
17
|
+
* (`/_x/assets/…`) — so an operator's ordinary prefix rule can never swallow a page's asset.
|
|
18
|
+
* `wiki/Deployment.md` prints this pattern; `worker-bundle.test.ts` holds every framework route
|
|
19
|
+
* outside it.
|
|
20
|
+
*/
|
|
21
|
+
export const SYNC_PROXY_PATTERN = /^\/_x\/sync(\/.*)?$/;
|
|
22
|
+
|
|
14
23
|
/**
|
|
15
24
|
* `SYNC_URL` verbatim when the deployment states one — the Compose rung publishes `sync` on its
|
|
16
25
|
* own port with no proxy in front, so only the deployment knows that URL — else `SYNC_PATH`,
|
|
@@ -40,7 +40,7 @@ stop; that answer is worth more than a plan built on a shape the app cannot hold
|
|
|
40
40
|
|---|---|
|
|
41
41
|
| \`apps/web/site/<path>/page.tsx\` | public, SEO-critical, 0kb JS |
|
|
42
42
|
| \`apps/web/app/<slice>/\` | the feature slice: entity, repo, policy, actions, queries, jobs, UI |
|
|
43
|
-
| \`apps/web/api
|
|
43
|
+
| \`apps/web/api/index.ts\` | the one \`defineApi()\` collecting every action and query — no route file lives under \`api/\` |
|
|
44
44
|
| \`apps/web/shared/\` | tokens, primitives, the actor type — a leaf, imports nothing of yours |
|
|
45
45
|
| \`packages/db/\` | schema, migrations, seed |
|
|
46
46
|
| \`packages/ui/\` | components with no feature knowledge |
|
|
@@ -79,7 +79,7 @@ feature slice. Nothing else. A change outside that set is a collision — report
|
|
|
79
79
|
| Money | \`{ minor, currency }\` — integer minor units and an ISO code, both, always. Never a float |
|
|
80
80
|
| Time | store UTC; a formatted date always names an explicit IANA time zone |
|
|
81
81
|
|
|
82
|
-
Inspect before you change: \`x entities list\`, \`x entities
|
|
82
|
+
Inspect before you change: \`x entities list\`, \`x entities show <name>\`, both with \`--json\`.
|
|
83
83
|
|
|
84
84
|
Checks: \`bun test <path>/entity.test.ts\`, \`bunx biome check --write <paths>\`, and \`bun run typecheck\`
|
|
85
85
|
once when you are otherwise done. Never \`x verify\` — that belongs to whoever coordinates you.
|
|
@@ -105,7 +105,7 @@ renders. A change outside that set is a collision — report it, do not make it.
|
|
|
105
105
|
| Tasks name a time zone | a cron schedule with an ambient zone is a different time twice a year |
|
|
106
106
|
| No \`any\` | \`unknown\` plus a schema parse |
|
|
107
107
|
|
|
108
|
-
Inspect before you change: \`x actions
|
|
108
|
+
Inspect before you change: \`x actions show <name>\`, \`x queries show <name>\`, \`x jobs list\`,
|
|
109
109
|
\`x tasks list\`, \`x policy explain <subject>\` — every one takes \`--json\`.
|
|
110
110
|
|
|
111
111
|
Checks: \`bun test <path>/<file>.test.ts\`, \`bunx biome check --write <paths>\`, and \`bun run typecheck\`
|
|
@@ -126,7 +126,7 @@ calls. A change outside that set is a collision — report it, do not make it.
|
|
|
126
126
|
|---|---|
|
|
127
127
|
| \`site/\` is 0kb JS | and may not import from \`app/\`. One interactive control on a static page is an island: \`x g island <name> --at <dir>\` |
|
|
128
128
|
| \`shared/\` is a leaf | tokens, primitives, the actor type. It imports nothing of yours |
|
|
129
|
-
| The directory is the URL | \`page.tsx\` under \`site/\`/\`app
|
|
129
|
+
| The directory is the URL | \`page.tsx\` under \`site/\`/\`app/\`; \`api/\` holds no route file, only \`defineApi()\`'s actions and queries. The filename is never the path |
|
|
130
130
|
| Every string through \`t()\` | a literal in a component is a string no locale can ever translate |
|
|
131
131
|
| Semantic tokens only | never a raw hex, in a component or a stylesheet |
|
|
132
132
|
| Dates name a zone | explicit IANA time zone at every call site, no ambient default |
|
|
@@ -66,12 +66,12 @@ SQL into \`packages/db/migrations/\`.
|
|
|
66
66
|
|---|---|---|
|
|
67
67
|
| \`apps/web/site/\` | 0kb JS, may not import \`apps/web/app/\` | the static path pays the app's bundle |
|
|
68
68
|
| \`apps/web/app/\` | authed, streaming, hydrated | — |
|
|
69
|
-
| \`apps/web/api/\` | actions only, \`
|
|
69
|
+
| \`apps/web/api/\` | actions and queries only, collected by \`defineApi()\` — no route file | a second HTTP surface |
|
|
70
70
|
| \`apps/web/shared/\` | a leaf: imports nothing of yours | an import cycle across surfaces |
|
|
71
71
|
| \`repo.ts\` | the only file that touches the database | authz bypassed by a raw read |
|
|
72
72
|
| routes | call actions and queries, never a repo | policy skipped |
|
|
73
73
|
|
|
74
|
-
Route files: \`page.tsx\` under \`site/\`/\`app/\`,
|
|
74
|
+
Route files: \`page.tsx\` under \`site/\`/\`app/\`, and none under \`api/\`. **The directory is the URL** —
|
|
75
75
|
the filename never is. One interactive control on a 0kb page is an island: \`x g island <name> --at <dir>\`.
|
|
76
76
|
|
|
77
77
|
## 4. Gate it
|
|
@@ -195,14 +195,18 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
|
|
|
195
195
|
{{/*
|
|
196
196
|
A role's terminationGracePeriodSeconds, in the order the kubelet's budget is spent: the preStop
|
|
197
197
|
sleep (web and sync, 1.30+), the worker's opt-in retire (roles.worker.retireSeconds), the
|
|
198
|
-
readiness grace (web and sync — every other role drains with none), the drain budget (app.config.ts drain.deadlineMs, every role
|
|
198
|
+
readiness grace (web and sync — every other role drains with none), the drain budget (app.config.ts drain.deadlineMs, every role;
|
|
199
|
+
the worker's is drain.workerDeadlineSeconds instead when > 0, from drain.workerDeadlineMs) and the teardown margin.
|
|
199
200
|
Defaults: 45s web/sync (40s below 1.30), 35s the rest. x deploy --method helm sets
|
|
200
|
-
drain.deadlineSeconds and drain.readinessGraceSeconds from app.config.ts.
|
|
201
|
+
drain.deadlineSeconds, drain.workerDeadlineSeconds and drain.readinessGraceSeconds from app.config.ts.
|
|
201
202
|
*/}}
|
|
202
203
|
{{- define "${app.kebab}.terminationGracePeriodSeconds" -}}
|
|
203
204
|
{{- $drain := .root.Values.drain -}}
|
|
204
205
|
{{- $total := add (int $drain.deadlineSeconds) (int $drain.teardownMarginSeconds) -}}
|
|
205
206
|
{{- if eq .role "worker" -}}
|
|
207
|
+
{{- if gt (int $drain.workerDeadlineSeconds) 0 -}}
|
|
208
|
+
{{- $total = add (int $drain.workerDeadlineSeconds) (int $drain.teardownMarginSeconds) -}}
|
|
209
|
+
{{- end -}}
|
|
206
210
|
{{- $total = add $total (int .cfg.retireSeconds) -}}
|
|
207
211
|
{{- end -}}
|
|
208
212
|
{{- if .cfg.port -}}
|
|
@@ -99,11 +99,14 @@ securityContext:
|
|
|
99
99
|
# readinessGraceSeconds app.config.ts drain.readinessGraceMs / 1000 (web and sync only)
|
|
100
100
|
# deadlineSeconds app.config.ts drain.deadlineMs / 1000 — x deploy --method helm sets
|
|
101
101
|
# both from app.config.ts, so a raised budget raises the grace with it
|
|
102
|
+
# workerDeadlineSeconds app.config.ts drain.workerDeadlineMs / 1000 — the worker's budget in place
|
|
103
|
+
# of deadlineSeconds, up to a day; 0 means the worker uses deadlineSeconds
|
|
102
104
|
# teardownMarginSeconds the release after the drain, the telemetry flush, the pool close
|
|
103
105
|
drain:
|
|
104
106
|
preStopSleepSeconds: 5
|
|
105
107
|
readinessGraceSeconds: 5
|
|
106
108
|
deadlineSeconds: 25
|
|
109
|
+
workerDeadlineSeconds: 0
|
|
107
110
|
teardownMarginSeconds: 10
|
|
108
111
|
|
|
109
112
|
# How long a new pod must stay Ready before a rollout counts it and stops an old one.
|
|
@@ -166,7 +169,7 @@ roles:
|
|
|
166
169
|
replicas: 2
|
|
167
170
|
# The retire, OFF at 0. Set to the longest a held job may run, in seconds, and the worker's
|
|
168
171
|
# preStop sends its PID 1 SIGUSR2: stop claiming, finish every held job, abort nothing, exit 0
|
|
169
|
-
# — for a job whose side effect must happen at most once and may outlast drain.
|
|
172
|
+
# — for a job whose side effect must happen at most once and may outlast its drain budget.
|
|
170
173
|
# Added to this role's terminationGracePeriodSeconds, which the kubelet counts the preStop against.
|
|
171
174
|
retireSeconds: 0
|
|
172
175
|
resources:
|
package/src/worker-bundle.ts
CHANGED
|
@@ -7,24 +7,34 @@
|
|
|
7
7
|
// why: Bun ships no path API; the entry is resolved to a file and named in the cause.
|
|
8
8
|
import { dirname, join, relative } from 'node:path';
|
|
9
9
|
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
10
|
-
import { applyCacheHeaders, jsonResponse } from '@ultimat3/http';
|
|
10
|
+
import { applyCacheHeaders, jsonResponse, NO_STORE } from '@ultimat3/http';
|
|
11
11
|
import { FrameworkScriptBuildFailedError, type FrameworkScriptKind } from './errors';
|
|
12
12
|
import { describeBuildError, graphHash, stripDebugId } from './island-identity';
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The ONE prefix every framework-served, content-addressed script lives under — the sync worker,
|
|
16
|
+
* the page boot, the client router — the way Rails serves `/assets/<name>-<sha>`. No live endpoint
|
|
17
|
+
* shares it: the worker lived at `/_x/sync-worker/…`, and an operator's ordinary prefix rule for
|
|
18
|
+
* the socket (`^/_x/sync` → the sync role) sent it to a process that serves only the socket, so
|
|
19
|
+
* no page got its worker and the CDN kept the 404 for hours. `SYNC_PROXY_PATTERN` (`sync-url.ts`)
|
|
20
|
+
* is the socket's whole claim, and `worker-bundle.test.ts` holds the two apart.
|
|
21
|
+
*/
|
|
22
|
+
export const FRAMEWORK_ASSET_BASE_PATH = '/_x/assets';
|
|
23
|
+
|
|
24
|
+
/** The sync worker: `<FRAMEWORK_ASSET_BASE_PATH>/sync-worker/<hash>.js`. */
|
|
25
|
+
export const SYNC_WORKER_BASE_PATH = `${FRAMEWORK_ASSET_BASE_PATH}/sync-worker`;
|
|
16
26
|
|
|
17
27
|
/** What an app resolves: realtime's worker entry, from the APP's install, never the CLI's own. */
|
|
18
28
|
export const SYNC_WORKER_SPECIFIER = '@ultimat3/realtime/sync-worker';
|
|
19
29
|
|
|
20
|
-
/**
|
|
21
|
-
export const PAGE_BOOT_BASE_PATH =
|
|
30
|
+
/** The page boot, one per document that carries a principal scope. */
|
|
31
|
+
export const PAGE_BOOT_BASE_PATH = `${FRAMEWORK_ASSET_BASE_PATH}/page-boot`;
|
|
22
32
|
|
|
23
33
|
/** Realtime's page boot, from the APP's install. */
|
|
24
34
|
export const PAGE_BOOT_SPECIFIER = '@ultimat3/realtime/boot';
|
|
25
35
|
|
|
26
|
-
/**
|
|
27
|
-
export const NAVIGATION_BASE_PATH =
|
|
36
|
+
/** The client router, one per document whose surface opted into client navigation. */
|
|
37
|
+
export const NAVIGATION_BASE_PATH = `${FRAMEWORK_ASSET_BASE_PATH}/navigation`;
|
|
28
38
|
|
|
29
39
|
/** Render's client router, from the APP's install — the copy its documents were rendered by. */
|
|
30
40
|
export const NAVIGATION_SPECIFIER = '@ultimat3/render/navigation';
|
|
@@ -198,7 +208,7 @@ function scriptRoute(
|
|
|
198
208
|
handler: (request: UltimateRequest): Response => {
|
|
199
209
|
const script = source();
|
|
200
210
|
if (script === undefined || script.url !== request.pathname) {
|
|
201
|
-
|
|
211
|
+
const missing = jsonResponse(
|
|
202
212
|
{
|
|
203
213
|
ok: false,
|
|
204
214
|
error: {
|
|
@@ -209,6 +219,9 @@ function scriptRoute(
|
|
|
209
219
|
},
|
|
210
220
|
{ status: 404 },
|
|
211
221
|
);
|
|
222
|
+
// `no-store`, said here and not left to the pipeline: a miss on a content-addressed URL
|
|
223
|
+
// must never be pinned by a CDN or a proxy (`island-routes.ts`' rule).
|
|
224
|
+
return applyCacheHeaders(missing, NO_STORE);
|
|
212
225
|
}
|
|
213
226
|
return applyCacheHeaders(
|
|
214
227
|
new Response(script.code, { headers: { 'content-type': 'text/javascript' } }),
|