@ultimat3/cli 19.2.0 → 19.3.2
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 +135 -9
- package/README.md +1 -1
- package/package.json +29 -29
- package/src/app-agents-md.ts +14 -3
- package/src/app-boundaries.ts +11 -2
- package/src/app-load.ts +96 -25
- package/src/budgets.ts +17 -6
- package/src/cmd-dev-fixture.ts +25 -0
- package/src/cmd-dev.ts +48 -46
- package/src/cmd-doctor.ts +61 -23
- package/src/cmd-generate.ts +25 -3
- package/src/cmd-i18n.ts +10 -3
- package/src/cmd-jobs.ts +56 -10
- package/src/cmd-test.ts +15 -10
- package/src/db-seed.ts +2 -1
- package/src/dev-queue.ts +16 -2
- package/src/dev-reload.ts +46 -0
- package/src/dev-render.ts +35 -9
- package/src/dev-runtime.ts +4 -1
- package/src/dev-sync.ts +11 -3
- package/src/dev-watch-tree.ts +226 -0
- package/src/dev-watch.ts +59 -37
- package/src/doctor-offline.ts +122 -0
- package/src/error-catalog.ts +4 -5
- package/src/fix-command.ts +40 -1
- package/src/fix-path.ts +10 -11
- package/src/flag-number.ts +15 -0
- package/src/generate-files.ts +24 -2
- package/src/generate-kinds.ts +54 -4
- package/src/generate-write.ts +25 -2
- package/src/gitignore.ts +145 -0
- package/src/hold.ts +50 -17
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +2 -1
- package/src/island-states-load.ts +2 -1
- package/src/jobs-driver.ts +4 -1
- package/src/mcp-host.ts +18 -9
- package/src/parse.ts +17 -0
- package/src/path-segments.ts +14 -0
- package/src/prerender.ts +46 -20
- package/src/retry-memo.ts +37 -0
- package/src/scaffold-fixture.ts +17 -0
- package/src/serve.ts +40 -5
- package/src/source-files.ts +3 -1
- package/src/sw-artifacts.ts +71 -7
- package/src/templates/action.ts +47 -16
- package/src/templates/admin-page.ts +49 -1
- package/src/templates/island.ts +4 -2
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-docs.ts +7 -0
- package/src/templates/scaffold-entries.ts +4 -2
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/slice-foundation.ts +36 -0
- package/src/test-passes.ts +79 -0
- package/src/test-shards.ts +110 -36
- package/src/verify-checks.ts +11 -8
- package/src/verify-floor.ts +59 -3
- package/src/verify-step.ts +4 -4
- package/src/verify-tests.ts +14 -2
package/src/hold.ts
CHANGED
|
@@ -33,12 +33,15 @@ export interface HoldOptions {
|
|
|
33
33
|
* handler opened them against. It is the resources core never learned about: the embedded
|
|
34
34
|
* Postgres, the worker, the file watcher.
|
|
35
35
|
*
|
|
36
|
-
* It
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* `
|
|
40
|
-
*
|
|
41
|
-
*
|
|
36
|
+
* It is BOUNDED, and that is not a detail. `drain()` ABANDONS a hook that overruns
|
|
37
|
+
* `ShutdownReason.deadlineAt` — the process is meant to exit without it — and `release` here
|
|
38
|
+
* re-enters the very same teardown one call later: `app.stop()` -> `startRoles().stop()` ->
|
|
39
|
+
* `worker.stop()`, memoised in the package that owns it, so awaiting it is awaiting the promise
|
|
40
|
+
* the drain just walked away from. Unbounded, that hangs forever and the deadline buys nothing.
|
|
41
|
+
*
|
|
42
|
+
* The bound is what is LEFT of the drain's budget, under a floor of `MIN_RELEASE_MS` — see there
|
|
43
|
+
* for why the remainder alone answered `0` on every busy pod and abandoned the teardown before it
|
|
44
|
+
* closed anything.
|
|
42
45
|
*/
|
|
43
46
|
export function holdUntilShutdown(
|
|
44
47
|
name: string,
|
|
@@ -48,8 +51,8 @@ export function holdUntilShutdown(
|
|
|
48
51
|
const uninstall = installSignalHandlers({ exit: false });
|
|
49
52
|
let unregister = (): void => {};
|
|
50
53
|
// The hook's own `reason`, not a stopwatch of ours: `deadlineAt` is the instant core computed
|
|
51
|
-
// when the drain began, on the same real monotonic clock, so
|
|
52
|
-
//
|
|
54
|
+
// when the drain began, on the same real monotonic clock, so what is left of the drain's budget
|
|
55
|
+
// is read off the drain's own number and never off a second one of the same length.
|
|
53
56
|
const shuttingDown = new Promise<number>((resolve) => {
|
|
54
57
|
unregister = onShutdown(
|
|
55
58
|
`cli:${name}:hold`,
|
|
@@ -71,7 +74,7 @@ export function holdUntilShutdown(
|
|
|
71
74
|
await drain();
|
|
72
75
|
unregister();
|
|
73
76
|
uninstall();
|
|
74
|
-
await releaseWithin(name, release, deadlineAt - systemClock.monotonic());
|
|
77
|
+
await releaseWithin(name, release, releaseBudgetMs(deadlineAt - systemClock.monotonic()));
|
|
75
78
|
options.exit?.(0);
|
|
76
79
|
})();
|
|
77
80
|
return held;
|
|
@@ -79,33 +82,63 @@ export function holdUntilShutdown(
|
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
/**
|
|
82
|
-
* `release
|
|
85
|
+
* The floor under `release`'s budget, and the reason the drain's REMAINDER alone is the wrong one.
|
|
86
|
+
*
|
|
87
|
+
* The remainder is what is left of a budget that was spent on something else. A drain that used
|
|
88
|
+
* all of it — one request over budget is enough, and that is the ordinary shutdown on a busy pod —
|
|
89
|
+
* hands `release` a NEGATIVE number, `Math.max(0, …)` reads it as `0`, and `setTimeout(resolve, 0)`
|
|
90
|
+
* wins against any teardown whose first await is real work. Measured at `deadlineMs: 60` with one
|
|
91
|
+
* `beginWork()` outstanding: the release STARTED and was abandoned 0ms later, so `app.stop()` never
|
|
92
|
+
* reached the pool close, the NATS close, the cache tiers or the mail driver, and the outbox relay
|
|
93
|
+
* was abandoned somewhere between `driver.enqueue` and `markPublished` — a duplicate job on the
|
|
94
|
+
* next boot. The abandonment is not the harmless "exit slightly early" it was written as: it is
|
|
95
|
+
* every resource the process holds, left to the kernel.
|
|
96
|
+
*
|
|
97
|
+
* So `release` gets a budget of its own, floored, never a leftover. 5s, and the arithmetic is
|
|
98
|
+
* what makes it defensible rather than a feel: `DEFAULT_DEADLINE_MS` is 25s
|
|
99
|
+
* (`packages/core/src/lifecycle.ts`) and `docker/helm/templates/deployments.yaml` sets
|
|
100
|
+
* `terminationGracePeriodSeconds: 45`, so a drain that spends everything PLUS a release that
|
|
101
|
+
* spends everything is 30s — still inside the grace period, so the kubelet never SIGKILLs a
|
|
102
|
+
* process this floor kept alive. It stays a floor and not a clamp: an app that raised
|
|
103
|
+
* `deadlineMs` for a slow teardown keeps the bigger number.
|
|
104
|
+
*/
|
|
105
|
+
export const MIN_RELEASE_MS = 5_000;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* What `release` really gets. Non-finite is the floor rather than the input, for the reason
|
|
109
|
+
* `packages/core/src/lifecycle-bounds.test.ts` exists: `Math.max(n, NaN)` is `NaN` and
|
|
110
|
+
* `setTimeout(fn, NaN)` fires on the next tick, which is this whole defect a second time.
|
|
111
|
+
*/
|
|
112
|
+
export const releaseBudgetMs = (remainingMs: number): number =>
|
|
113
|
+
Number.isFinite(remainingMs) ? Math.max(MIN_RELEASE_MS, remainingMs) : MIN_RELEASE_MS;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `release()` raced against its own budget.
|
|
83
117
|
*
|
|
84
118
|
* A local race and not core's `settleWithin`, which is internal to `lifecycle-deadline.ts` and not
|
|
85
119
|
* on core's barrel. The semantics are deliberately the same, including the one that matters: a
|
|
86
120
|
* REJECTION still rejects — `dispatch` awaits the hold inside its own `try`, and an embedded
|
|
87
121
|
* database that would not close is a finding on the way out, never a clean exit over it.
|
|
88
|
-
*
|
|
89
|
-
* A budget already spent is `0`, and that abandons immediately by design: past `deadlineAt` the
|
|
90
|
-
* orchestrator is already counting down to SIGKILL, so the honest move is to say so and exit
|
|
91
|
-
* rather than to start a second grace period nobody granted.
|
|
92
122
|
*/
|
|
93
123
|
async function releaseWithin(
|
|
94
124
|
name: string,
|
|
95
125
|
release: () => Promise<void>,
|
|
96
126
|
budgetMs: number,
|
|
97
127
|
): Promise<void> {
|
|
98
|
-
|
|
128
|
+
// Screened by `releaseBudgetMs`, which is the one answer to "how long does a teardown get" —
|
|
129
|
+
// a second `Math.max` here would be a second, quieter one.
|
|
99
130
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
100
131
|
const abandoned = new Promise<'abandoned'>((resolve) => {
|
|
101
|
-
timer = setTimeout(() => resolve('abandoned'),
|
|
132
|
+
timer = setTimeout(() => resolve('abandoned'), budgetMs);
|
|
102
133
|
});
|
|
103
134
|
try {
|
|
104
135
|
const outcome = await Promise.race([release().then(() => 'released' as const), abandoned]);
|
|
105
136
|
if (outcome === 'released') return;
|
|
106
137
|
logger.warn('X_SHUTDOWN_TIMEOUT', {
|
|
107
138
|
code: 'X_SHUTDOWN_TIMEOUT',
|
|
108
|
-
|
|
139
|
+
// Rounded: `deadlineAt - monotonic()` is a float, and `4923.185900000001ms` in a shutdown
|
|
140
|
+
// log reads as a bug in the number rather than as the budget it is.
|
|
141
|
+
cause: `the "${name}" release was still running ${Math.round(budgetMs)}ms after the drain finished and has been ABANDONED — the process exits without it, so anything it held may not be closed`,
|
|
109
142
|
fix: 'raise the budget past the slowest teardown — configureLifecycle({ deadlineMs: 600_000 }) for a 10-minute one — and set terminationGracePeriodSeconds to at least as many seconds',
|
|
110
143
|
});
|
|
111
144
|
} finally {
|
package/src/index.ts
CHANGED
|
@@ -68,7 +68,7 @@ export { deployCommand, planDeploy } from './cmd-deploy';
|
|
|
68
68
|
export type { DevServer, StartDevOptions } from './cmd-dev';
|
|
69
69
|
export { devCommand, startDev } from './cmd-dev';
|
|
70
70
|
export type { DoctorProbe } from './cmd-doctor';
|
|
71
|
-
export { doctorCommand,
|
|
71
|
+
export { doctorCommand, ENV_DEVELOPMENT, probeFor, runDoctor } from './cmd-doctor';
|
|
72
72
|
export { ERRORS_SUBCOMMANDS, errorsCommand } from './cmd-errors';
|
|
73
73
|
export { FIX_SUBCOMMANDS, fixCommand } from './cmd-fix';
|
|
74
74
|
export type { GenerateOptions, Generator } from './cmd-generate';
|
package/src/island-bundle.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/
|
|
|
12
12
|
import { contentHash } from '@ultimat3/render/server';
|
|
13
13
|
import { IslandBuildFailedError } from './errors';
|
|
14
14
|
import { islandStylesPlugin } from './island-styles';
|
|
15
|
+
import { hasPathSegment } from './path-segments';
|
|
15
16
|
import { solidJsxPlugin } from './solid-loader';
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -59,7 +60,7 @@ export interface IslandBundle {
|
|
|
59
60
|
export async function discoverIslands(root: string): Promise<readonly string[]> {
|
|
60
61
|
const files: string[] = [];
|
|
61
62
|
for await (const absolute of new Bun.Glob(ISLAND_GLOB).scan({ cwd: root, absolute: true })) {
|
|
62
|
-
if (absolute
|
|
63
|
+
if (hasPathSegment(absolute, 'node_modules')) continue;
|
|
63
64
|
files.push(relative(root, absolute).split(sep).join('/'));
|
|
64
65
|
}
|
|
65
66
|
return files.sort();
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from '@ultimat3/testing';
|
|
17
17
|
import { ISLAND_GLOB } from './island-bundle';
|
|
18
18
|
import { IslandStatesFileEmptyError } from './island-shot-errors';
|
|
19
|
+
import { hasPathSegment } from './path-segments';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* `.island.states.ts`, built from the two constants that own its halves and restated as neither:
|
|
@@ -37,7 +38,7 @@ export async function discoverIslandStates(root: string): Promise<readonly strin
|
|
|
37
38
|
const files: string[] = [];
|
|
38
39
|
const scan = new Bun.Glob(ISLAND_STATES_GLOB).scan({ cwd: root, absolute: true });
|
|
39
40
|
for await (const absolute of scan) {
|
|
40
|
-
if (absolute
|
|
41
|
+
if (hasPathSegment(absolute, 'node_modules')) continue;
|
|
41
42
|
files.push(relative(root, absolute).split(sep).join('/'));
|
|
42
43
|
}
|
|
43
44
|
return files.sort();
|
package/src/jobs-driver.ts
CHANGED
|
@@ -24,7 +24,10 @@ export async function withJobDriver(
|
|
|
24
24
|
const ambient = jobDriver();
|
|
25
25
|
if (ambient !== undefined) return fn(ambient);
|
|
26
26
|
const services = resolveServices(root, ctx.env);
|
|
27
|
-
|
|
27
|
+
// The command's own environment on both halves: `resolveServices` reads DATABASE_URL off it, so
|
|
28
|
+
// a queue that resolved its standby from `process.env` would talk to a pair the same call had
|
|
29
|
+
// just decided against.
|
|
30
|
+
const queue = await startQueue(services, undefined, ctx.env);
|
|
28
31
|
try {
|
|
29
32
|
return await fn(queue.jobs);
|
|
30
33
|
} finally {
|
package/src/mcp-host.ts
CHANGED
|
@@ -45,6 +45,7 @@ import { databaseTarget } from './mcp-db-target';
|
|
|
45
45
|
import { explainErrorCode } from './mcp-errors';
|
|
46
46
|
import { parseBunTest } from './mcp-test-output';
|
|
47
47
|
import { readMigrations } from './migrations';
|
|
48
|
+
import { retryMemo } from './retry-memo';
|
|
48
49
|
|
|
49
50
|
export interface DevHostInput {
|
|
50
51
|
readonly root: string;
|
|
@@ -91,7 +92,11 @@ export interface LazyServices {
|
|
|
91
92
|
*/
|
|
92
93
|
export function lazyServices(input: DevHostInput): LazyServices {
|
|
93
94
|
const services = resolveServices(input.root, input.env);
|
|
94
|
-
|
|
95
|
+
// `retryMemo`, not `??=`: a boot that REJECTED is not an answer to keep. A Postgres that refused
|
|
96
|
+
// one connection wedged every later tool call in the session with that first error, and the only
|
|
97
|
+
// way out was restarting the host — `startServices` unwinds everything it started before it
|
|
98
|
+
// rejects, so there is nothing left over for a second attempt to collide with.
|
|
99
|
+
const boot = retryMemo(() => startServices(services, input.env));
|
|
95
100
|
let closed = false;
|
|
96
101
|
return {
|
|
97
102
|
services,
|
|
@@ -104,14 +109,14 @@ export function lazyServices(input: DevHostInput): LazyServices {
|
|
|
104
109
|
fix: 'x mcp serve --transport stdio # keep the host open for the whole session',
|
|
105
110
|
});
|
|
106
111
|
}
|
|
107
|
-
|
|
108
|
-
return started;
|
|
112
|
+
return boot.get();
|
|
109
113
|
},
|
|
110
114
|
async close(): Promise<void> {
|
|
111
115
|
if (closed) return;
|
|
112
116
|
closed = true;
|
|
113
|
-
//
|
|
114
|
-
|
|
117
|
+
// `started()` and never `get()`: closing must not BOOT a database in order to stop one. A
|
|
118
|
+
// boot that rejected has nothing to stop, and close() must not throw on the way out.
|
|
119
|
+
await (await boot.started()?.catch(() => undefined))?.stop();
|
|
115
120
|
},
|
|
116
121
|
};
|
|
117
122
|
}
|
|
@@ -175,8 +180,13 @@ export async function readOnlyRows(
|
|
|
175
180
|
function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities {
|
|
176
181
|
const { root, runner } = input;
|
|
177
182
|
// Layer 1 is seven idempotent DDL statements, and `db.query` is a tool an agent calls in a
|
|
178
|
-
// loop — resolve the role once per process and reuse the answer, `null` included.
|
|
179
|
-
|
|
183
|
+
// loop — resolve the role once per process and reuse the answer, `null` included. A FAILED
|
|
184
|
+
// resolution is not an answer: `??=` kept the rejection, so a statement timeout on the DDL
|
|
185
|
+
// meant every later `db.query` in the session refused with it instead of trying again.
|
|
186
|
+
//
|
|
187
|
+
// It asks `lazy.running()` for the client rather than closing over one: this runs before any
|
|
188
|
+
// boot has happened, and the boot is memoised, so it is the same connection `runQuery` uses.
|
|
189
|
+
const readOnlyRole = retryMemo(async () => ensureReadOnlyRole((await lazy.running()).db));
|
|
180
190
|
|
|
181
191
|
return {
|
|
182
192
|
database: databaseTarget(lazy.services, input.env),
|
|
@@ -185,8 +195,7 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
|
|
|
185
195
|
const { db } = await lazy.running();
|
|
186
196
|
// A managed Postgres may refuse CREATE ROLE; `ensureReadOnlyRole` answers null and the
|
|
187
197
|
// layer is reported absent in `guards` rather than quietly assumed present.
|
|
188
|
-
|
|
189
|
-
return readOnlyRows(db, sql, limits, await readOnlyRole);
|
|
198
|
+
return readOnlyRows(db, sql, limits, await readOnlyRole.get());
|
|
190
199
|
},
|
|
191
200
|
|
|
192
201
|
async runMigrations(branch: string, dryRun: boolean) {
|
package/src/parse.ts
CHANGED
|
@@ -88,6 +88,15 @@ export interface CommandSpec {
|
|
|
88
88
|
* that declared it and forgot the call ran outside an app with no refusal.
|
|
89
89
|
*/
|
|
90
90
|
readonly requiresApp?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* The command hands everything after a bare `--` to another tool, so `ParsedArgs.passthrough`
|
|
93
|
+
* has a READER. Declared, because it did not until 2026-09 and nothing read it anywhere:
|
|
94
|
+
* `x test unit -- --coverage --bail` parsed both flags, carried them the whole way and dropped
|
|
95
|
+
* them, and every other command did the same in the same silence. A command that declares this
|
|
96
|
+
* forwards them; one that does not refuses the `--` (`X_CLI_BAD_FLAG`), which is the only way an
|
|
97
|
+
* argument that changes nothing becomes visible to the caller who typed it.
|
|
98
|
+
*/
|
|
99
|
+
readonly passthrough?: true;
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
export interface ParsedArgs {
|
|
@@ -170,6 +179,14 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
|
|
|
170
179
|
}
|
|
171
180
|
|
|
172
181
|
const spec = resolveCommand(first, specs);
|
|
182
|
+
if (passthrough.length > 0 && spec.passthrough !== true) {
|
|
183
|
+
throw new BadFlagError({
|
|
184
|
+
flag: '',
|
|
185
|
+
command: spec.name,
|
|
186
|
+
reason: `hands nothing to another tool, so ${passthrough.join(' ')} would be dropped in silence`,
|
|
187
|
+
fix: `x ${spec.name} --help`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
173
190
|
const flags = defaults(spec);
|
|
174
191
|
const positionals: string[] = [];
|
|
175
192
|
// What argv actually SET, as against what `defaults()` seeded: a default is nobody's request,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// One answer to "does this path pass through a directory called X". Six modules asked it with
|
|
2
|
+
// `path.includes('node_modules')`, which is a SUBSTRING match: an app checked out under
|
|
3
|
+
// `~/dev/node_modules-experiments/myapp` answered true for every file it holds, so `loadApp`
|
|
4
|
+
// imported none of them and the app registered nothing at all.
|
|
5
|
+
|
|
6
|
+
/** Split on either separator: a rule that reads `a/b` and not `a\b` does not exist on Windows. */
|
|
7
|
+
export function pathSegments(path: string): readonly string[] {
|
|
8
|
+
return path.replaceAll('\\', '/').split('/');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Whether any whole segment of `path` is `segment` — never a substring of one. */
|
|
12
|
+
export function hasPathSegment(path: string, segment: string): boolean {
|
|
13
|
+
return pathSegments(path).includes(segment);
|
|
14
|
+
}
|
package/src/prerender.ts
CHANGED
|
@@ -22,7 +22,13 @@ import { loadPwaArtifacts, WEB_MANIFEST_PATH, writePwaIcons } from './pwa-artifa
|
|
|
22
22
|
import type { SkippedRoute, UnmeasuredRoute } from './static-report';
|
|
23
23
|
import { skippedRoute, skipReasonFor, writeStaticReport } from './static-report';
|
|
24
24
|
import { styleBundle, writeStyles } from './style-bundle';
|
|
25
|
-
import {
|
|
25
|
+
import type { RenderedDocument } from './sw-artifacts';
|
|
26
|
+
import {
|
|
27
|
+
SERVICE_WORKER_PATH,
|
|
28
|
+
SW_REGISTER_PATH,
|
|
29
|
+
serviceWorkerArtifacts,
|
|
30
|
+
serviceWorkerHead,
|
|
31
|
+
} from './sw-artifacts';
|
|
26
32
|
|
|
27
33
|
// Re-exported, never re-declared: `static-report.ts` owns the shape because the report on disk
|
|
28
34
|
// carries it, and this file already imports that module.
|
|
@@ -141,6 +147,11 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
141
147
|
const skipped: SkippedRoute[] = [];
|
|
142
148
|
const routes: RouteStats[] = [];
|
|
143
149
|
const unmeasured: UnmeasuredRoute[] = [];
|
|
150
|
+
// What the loop below rendered, by the path it rendered — the service worker's precache
|
|
151
|
+
// revisions. A `Map` and not a record, so a route path spelling a prototype member cannot answer
|
|
152
|
+
// with one; insertion order is never read, because `pwaRoutes` walks the route table and
|
|
153
|
+
// `buildPrecacheManifest` sorts its own entries by code unit.
|
|
154
|
+
const documents = new Map<string, RenderedDocument>();
|
|
144
155
|
|
|
145
156
|
// Before the first document: a page's `data-x-entry` is a built chunk's URL, so the chunks have
|
|
146
157
|
// to exist to be named. Written into `out` too — a static export is served with no process
|
|
@@ -174,24 +185,15 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
174
185
|
// wiring exists to close. `undefined` when the app is not installable, and then no document
|
|
175
186
|
// names it either.
|
|
176
187
|
const pwa = await loadPwaArtifacts(options.root);
|
|
177
|
-
// The
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
buildId,
|
|
187
|
-
routes: describeRoutes(),
|
|
188
|
-
islands,
|
|
189
|
-
styles,
|
|
190
|
-
});
|
|
191
|
-
if (serviceWorker !== undefined) {
|
|
192
|
-
await Bun.write(join(options.out, SERVICE_WORKER_PATH.slice(1)), serviceWorker.source);
|
|
193
|
-
await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorker.register);
|
|
194
|
-
}
|
|
188
|
+
// The registration TAG now, the worker itself after the render loop — the two halves are wanted
|
|
189
|
+
// at different moments and used to be taken at the same one. Every document below has to name
|
|
190
|
+
// `/x-sw-register.js`, and the worker's precache manifest is built from the content hash of
|
|
191
|
+
// those same documents, which do not exist yet: emitted here, every route's revision was the
|
|
192
|
+
// BUILD ID and every route's byte count was 0, so a deploy of a byte-identical site re-fetched
|
|
193
|
+
// everything and the precache budget could not count one byte of HTML (`precache.ts`' own
|
|
194
|
+
// header). `serviceWorkerHead` is the one predicate behind both, so a page can never name a
|
|
195
|
+
// script the export does not carry.
|
|
196
|
+
const swHead = pwa === undefined ? undefined : serviceWorkerHead(pwa);
|
|
195
197
|
if (pwa !== undefined) {
|
|
196
198
|
await Bun.write(join(options.out, WEB_MANIFEST_PATH.slice(1)), pwa.body);
|
|
197
199
|
// And the icons that manifest NAMES. A static host runs no `assetRoutes()`, so every
|
|
@@ -224,7 +226,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
224
226
|
runWithContext(as, () =>
|
|
225
227
|
routeDocument(entry, data, {
|
|
226
228
|
resolveIsland: (file: string) => islands.resolverFor(file),
|
|
227
|
-
...(pwa === undefined ? {} : { pwaHead: pwa.head + (
|
|
229
|
+
...(pwa === undefined ? {} : { pwaHead: pwa.head + (swHead ?? '') }),
|
|
228
230
|
}),
|
|
229
231
|
);
|
|
230
232
|
const document = (entry: RouteEntry, data: { url: string; params: Record<string, string> }) =>
|
|
@@ -299,6 +301,11 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
299
301
|
hash: artifact.hash,
|
|
300
302
|
bytes,
|
|
301
303
|
});
|
|
304
|
+
// `artifact.hash` is `contentHash(html)` — the same identity that becomes this page's ETag,
|
|
305
|
+
// so the precache revision and the HTTP validator can never disagree about one document.
|
|
306
|
+
// Keyed by the FILLED path, which for a non-dynamic route is the declared one; a dynamic
|
|
307
|
+
// route is not precached as a single URL anyway (`buildPrecacheManifest` skips it).
|
|
308
|
+
documents.set(artifact.path, { revision: artifact.hash, bytes });
|
|
302
309
|
// Measured from the document that was just written, so the `budgets` step compares a
|
|
303
310
|
// declared budget against bytes that exist on disk rather than against a graph's estimate.
|
|
304
311
|
const measured = await measureDocumentJs(artifact.html, options.out);
|
|
@@ -312,6 +319,25 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
312
319
|
}
|
|
313
320
|
if (heaviest !== undefined) routes.push(heaviest);
|
|
314
321
|
}
|
|
322
|
+
// The worker, LAST: every document it precaches has now been rendered, hashed and weighed. A
|
|
323
|
+
// static host runs no route table, so both files go into the artifact — a
|
|
324
|
+
// `<script src="/x-sw-register.js">` in every document is a 404 otherwise, which is the same
|
|
325
|
+
// promise `favicon.ico` and the icons above keep.
|
|
326
|
+
const serviceWorker =
|
|
327
|
+
pwa === undefined
|
|
328
|
+
? undefined
|
|
329
|
+
: serviceWorkerArtifacts({
|
|
330
|
+
pwa,
|
|
331
|
+
buildId,
|
|
332
|
+
routes: describeRoutes(),
|
|
333
|
+
islands,
|
|
334
|
+
styles,
|
|
335
|
+
documents,
|
|
336
|
+
});
|
|
337
|
+
if (serviceWorker !== undefined) {
|
|
338
|
+
await Bun.write(join(options.out, SERVICE_WORKER_PATH.slice(1)), serviceWorker.source);
|
|
339
|
+
await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorker.register);
|
|
340
|
+
}
|
|
315
341
|
const stats = await writeBuildStats(options.root, { routes });
|
|
316
342
|
// Written LAST and by the same call that writes the stats, so an app whose `prerender.ts` does
|
|
317
343
|
// not reach `prerenderSite` produces neither — and `x verify`'s `budgets` step already reds that
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// One in-flight attempt, shared — and a FAILED one not kept. `started ??= boot()` caches the
|
|
2
|
+
// rejection with the value, so a Postgres that refused a connection once answered every later
|
|
3
|
+
// call in the session with that same error and no retry was possible short of restarting the host.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A lazily made attempt that may be made again.
|
|
7
|
+
*
|
|
8
|
+
* `started()` is the half `??=` has no spelling for: it answers the attempt already in flight or
|
|
9
|
+
* already made, and `undefined` when nobody has asked yet — so a `close()` can stop what was
|
|
10
|
+
* booted without BOOTING one in order to stop it.
|
|
11
|
+
*/
|
|
12
|
+
export interface RetryMemo<T> {
|
|
13
|
+
get(): Promise<T>;
|
|
14
|
+
started(): Promise<T> | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `start` runs at most once per SUCCESS. `@ultimat3/db`'s `createPgliteClient` states the rule this
|
|
19
|
+
* generalises — "a failed boot must not be cached" — and the clearing handler is attached at
|
|
20
|
+
* creation for the reason that matters: it therefore runs ahead of every caller's own `await`
|
|
21
|
+
* continuation, so by the time anyone sees the rejection the slot is already empty and the next
|
|
22
|
+
* call really does start a new attempt. Cleared inside the caller's `catch` instead, there is a
|
|
23
|
+
* window in which a second call is handed the dead promise.
|
|
24
|
+
*/
|
|
25
|
+
export function retryMemo<T>(start: () => Promise<T>): RetryMemo<T> {
|
|
26
|
+
let attempt: Promise<T> | undefined;
|
|
27
|
+
return {
|
|
28
|
+
get(): Promise<T> {
|
|
29
|
+
attempt ??= start().catch((error: unknown) => {
|
|
30
|
+
attempt = undefined;
|
|
31
|
+
throw error;
|
|
32
|
+
});
|
|
33
|
+
return attempt;
|
|
34
|
+
},
|
|
35
|
+
started: (): Promise<T> | undefined => attempt,
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/scaffold-fixture.ts
CHANGED
|
@@ -10,6 +10,17 @@ import type { GeneratedFile } from './templates';
|
|
|
10
10
|
/** The app the fixture scaffolds. Kebab, multi-word: single-word names hide casing bugs. */
|
|
11
11
|
export const FIXTURE_APP = 'ledger-demo';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* An `errors.ts` an author wrote: it declares what the slice throws, and not the generated name.
|
|
15
|
+
* The generator's INPUT only — the sandbox's own `errors.ts` is the one the resource wrote — so it
|
|
16
|
+
* carries no `X_*` code: a literal here would be one shipped source hands a reader, and the
|
|
17
|
+
* registry rule would ask where it was registered.
|
|
18
|
+
*/
|
|
19
|
+
export const HANDWRITTEN_ERRORS = `import { UltimateError } from '@ultimat3/core';
|
|
20
|
+
|
|
21
|
+
export class LedgerClosedError extends UltimateError {}
|
|
22
|
+
`;
|
|
23
|
+
|
|
13
24
|
/**
|
|
14
25
|
* One realistic invocation of every generator, on top of `x new --example`. Names differ from
|
|
15
26
|
* their feature on purpose: `x g query invoice --feature invoice` would collide with the entity
|
|
@@ -25,6 +36,12 @@ export const FIXTURE_GENERATORS: readonly GenerateOptions[] = [
|
|
|
25
36
|
{ kind: 'policy', name: 'credit-note', feature: 'credit-note' },
|
|
26
37
|
{ kind: 'action', name: 'send-invoice', feature: 'invoice' },
|
|
27
38
|
{ kind: 'mutator', name: 'rename-invoice', feature: 'invoice' },
|
|
39
|
+
// The other shape both templates have: a slice whose `errors.ts` is the author's and declares
|
|
40
|
+
// no `InvoiceNotFoundError`. The resource's own `errors.ts` still lands in the sandbox (it does
|
|
41
|
+
// declare one), which is the point — this compiles the file `x g action` writes when it must
|
|
42
|
+
// not import that class, beside the one it writes when it may.
|
|
43
|
+
{ kind: 'action', name: 'ping-invoice', feature: 'invoice', sliceErrors: HANDWRITTEN_ERRORS },
|
|
44
|
+
{ kind: 'mutator', name: 'touch-invoice', feature: 'invoice', sliceErrors: HANDWRITTEN_ERRORS },
|
|
28
45
|
{ kind: 'query', name: 'invoice-search', feature: 'invoice' },
|
|
29
46
|
{ kind: 'query', name: 'invoice-feed', feature: 'invoice', live: true },
|
|
30
47
|
{ kind: 'job', name: 'sweep-invoices', feature: 'invoice' },
|
package/src/serve.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// What a container starts. `apps/web/server.ts` is three lines that call `runRole`, so the boot a
|
|
2
2
|
// production process performs is framework code with tests rather than app code the author has to
|
|
3
3
|
// get right — and it is the SAME code `x dev` runs, minus the watcher, minus `/_x`, minus
|
|
4
|
-
// `dev: true`. The only production-shaped decisions live here: which role, which port, and
|
|
5
|
-
//
|
|
4
|
+
// `dev: true`. The only production-shaped decisions live here: which role, which port, and which
|
|
5
|
+
// interface — every one by default, because a container is reached through a port mapping.
|
|
6
6
|
|
|
7
7
|
import type { Role } from '@ultimat3/core';
|
|
8
8
|
import {
|
|
@@ -56,9 +56,35 @@ import { serviceWorkerRoutes } from './sw-routes';
|
|
|
56
56
|
|
|
57
57
|
export const DEFAULT_PORT = 3000;
|
|
58
58
|
|
|
59
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Every interface, which is what a role binds when nothing says otherwise: a container bound to
|
|
61
|
+
* loopback is unreachable through its own port mapping. `HOST` and `ServeOptions.hostname` are the
|
|
62
|
+
* two ways of saying otherwise — see `hostnameFromEnv`.
|
|
63
|
+
*/
|
|
60
64
|
export const CONTAINER_BINDING: WebBinding = { dev: false, hostname: '0.0.0.0' };
|
|
61
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The interface the `web` and `sync` roles bind, and the metrics endpoint with them (`WebBinding`
|
|
68
|
+
* is one decision). Read the way `PORT` is: empty or whitespace is the default.
|
|
69
|
+
*
|
|
70
|
+
* Exists because a container had exactly one binding, `0.0.0.0`, and an app whose auth mode is
|
|
71
|
+
* "nobody logs in, one implicit actor" must refuse a public interface — so it could not run in a
|
|
72
|
+
* container at all. `HOST=127.0.0.1` is unreachable through `docker run -p` (the proxy connects to
|
|
73
|
+
* the container's bridge address, never its loopback); it is reachable where the container shares
|
|
74
|
+
* the host's network namespace (`--network host`), or through a sidecar and `ssh -L` inside it —
|
|
75
|
+
* which is the exposure such an app wants. Not `HOSTNAME`: Docker sets that to the container id.
|
|
76
|
+
*/
|
|
77
|
+
export function hostnameFromEnv(env: Env): string {
|
|
78
|
+
const raw = env['HOST']?.trim();
|
|
79
|
+
return raw === undefined || raw.length === 0 ? CONTAINER_BINDING.hostname : raw;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What `serveApp` hands `startRoles`: the caller's hostname, else `HOST`, else every interface. */
|
|
83
|
+
export const containerBinding = (env: Env, hostname?: string): WebBinding => ({
|
|
84
|
+
dev: false,
|
|
85
|
+
hostname: hostname ?? hostnameFromEnv(env),
|
|
86
|
+
});
|
|
87
|
+
|
|
62
88
|
/**
|
|
63
89
|
* `ROLE` is the one knob one image exposes. Validated rather than defaulted: a typo that fell back
|
|
64
90
|
* to `web` would start a process that serves nothing the operator asked for and reports healthy.
|
|
@@ -144,6 +170,11 @@ export interface ServeOptions {
|
|
|
144
170
|
readonly port?: number;
|
|
145
171
|
/** Overrides `METRICS_PORT`, on the same terms. */
|
|
146
172
|
readonly metricsPort?: number;
|
|
173
|
+
/**
|
|
174
|
+
* Overrides `HOST`: the interface the HTTP roles bind. An app that must never answer on a public
|
|
175
|
+
* interface passes `'127.0.0.1'` here rather than trusting the deployment to set the variable.
|
|
176
|
+
*/
|
|
177
|
+
readonly hostname?: string;
|
|
147
178
|
/**
|
|
148
179
|
* The drivers this deployment supplies instead of the ones the environment would select.
|
|
149
180
|
*
|
|
@@ -195,7 +226,11 @@ export type StartedApp = ServedApp | MigratedApp;
|
|
|
195
226
|
* is what fails on it.
|
|
196
227
|
*/
|
|
197
228
|
export async function runMigrations(options: ServeOptions): Promise<MigratedApp> {
|
|
198
|
-
const queue = await startQueue(
|
|
229
|
+
const queue = await startQueue(
|
|
230
|
+
resolveServices(options.root, options.env),
|
|
231
|
+
options.runtime,
|
|
232
|
+
options.env,
|
|
233
|
+
);
|
|
199
234
|
try {
|
|
200
235
|
const migrations = await readMigrations(options.root);
|
|
201
236
|
const report = await migrate({
|
|
@@ -387,7 +422,7 @@ async function bootRoles(boot: {
|
|
|
387
422
|
// The app's own `apps/web/site/errors/<status>.html`, resolved inside `startWeb` so this
|
|
388
423
|
// process and `x dev` cannot answer a browser differently.
|
|
389
424
|
root: options.root,
|
|
390
|
-
http:
|
|
425
|
+
http: containerBinding(options.env, options.hostname),
|
|
391
426
|
// The read-replica scope rides in FRONT of whatever the host supplied, or the host's own value
|
|
392
427
|
// passes through untouched. `DATABASE_REPLICA_URL` was read by no booted process before this:
|
|
393
428
|
// `defaultClient()` is the one composer of a replicated pair and it runs only when an app
|
package/src/source-files.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// and an app. One list for every step that walks source, because two steps scanning different sets
|
|
3
3
|
// means a finding one of them can never see.
|
|
4
4
|
|
|
5
|
+
import { hasPathSegment } from './path-segments';
|
|
6
|
+
|
|
5
7
|
export const SOURCE_GLOBS = [
|
|
6
8
|
'packages/*/src/**/*.{ts,tsx}',
|
|
7
9
|
// Three packages carry an `e2e` directory beside `src`. It is shipped source by every rule that
|
|
@@ -21,7 +23,7 @@ export const SOURCE_GLOBS = [
|
|
|
21
23
|
* root. `dist/` is build output: the sources that produced it are already in the set.
|
|
22
24
|
*/
|
|
23
25
|
export const isVendored = (path: string): boolean =>
|
|
24
|
-
path
|
|
26
|
+
hasPathSegment(path, 'node_modules') || hasPathSegment(path, 'dist');
|
|
25
27
|
|
|
26
28
|
/** Emitted declarations, not authored source — a rule about authored code cannot apply to them. */
|
|
27
29
|
export const isGenerated = (path: string): boolean => path.endsWith('.d.ts');
|