@ultimat3/cli 22.11.0 → 22.13.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/package.json +28 -28
- package/src/app-reload-graph.ts +51 -9
- package/src/bin.ts +14 -0
- package/src/budgets.ts +35 -2
- package/src/cmd-dev.ts +31 -9
- package/src/dev-child-watch.ts +144 -0
- package/src/dev-live-fixture.ts +122 -0
- package/src/dev-port.ts +1 -1
- package/src/dev-supervisor.ts +207 -0
- package/src/error-codes.ts +5 -0
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +234 -170
- package/src/island-identity.ts +158 -0
- package/src/island-link.ts +138 -0
- package/src/island-realtime.ts +28 -11
- package/src/island-routes.ts +3 -1
- package/src/island-store.ts +65 -8
- package/src/mcp-errors.ts +3 -0
- package/src/messages.ts +1 -0
- package/src/prerender.ts +6 -1
- package/src/sw-precache-plan.ts +6 -0
- package/src/worker-bundle.ts +1 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// `x dev` as two processes: this one supervises, a child serves. A save that reaches a module which
|
|
2
|
+
// DEFINES a primitive (`app-reload-graph.ts`'s pinned modules — a query over a slice's service, an
|
|
3
|
+
// admin page's view under `defineAdmin`) cannot be served by re-importing: the registries and the
|
|
4
|
+
// route table hold that module's first instance. The child exits `DEV_RESTART_EXIT_CODE` instead
|
|
5
|
+
// of logging "reloaded" over stale code, and this loop boots a fresh one on the same port. Every
|
|
6
|
+
// other save stays the in-process reload it was — a restart is paid only where nothing else works.
|
|
7
|
+
|
|
8
|
+
// why: Bun has no path API; the restart line names files relative to the app root.
|
|
9
|
+
import { relative } from 'node:path';
|
|
10
|
+
import { drain } from '@ultimat3/core';
|
|
11
|
+
import type { StalePin } from './app-reload-graph';
|
|
12
|
+
import { devSpec } from './cmd-dev-spec';
|
|
13
|
+
import type { DevChildGone } from './dev-child-watch';
|
|
14
|
+
import { DEV_SUPERVISOR_PID_ENV, startDevChildWatch, supervisorPid } from './dev-child-watch';
|
|
15
|
+
import { devPortFor } from './dev-port';
|
|
16
|
+
import { msg } from './messages';
|
|
17
|
+
import type { Finding } from './output';
|
|
18
|
+
import { parseArgs } from './parse';
|
|
19
|
+
|
|
20
|
+
/** Set on the child: it serves, and a pinned save makes it exit for a restart. */
|
|
21
|
+
export const DEV_CHILD_ENV = 'ULTIMATE_DEV_CHILD';
|
|
22
|
+
/** `EX_TEMPFAIL`: "try again" — the one exit code the supervisor answers with a new child. */
|
|
23
|
+
export const DEV_RESTART_EXIT_CODE = 75;
|
|
24
|
+
|
|
25
|
+
/** What the supervisor runs the child with: the argv, its port pinned. */
|
|
26
|
+
export interface DevSupervision {
|
|
27
|
+
readonly argv: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether this `x …` invocation is a supervised `x dev`, and with what child argv. Not for the
|
|
32
|
+
* child itself, `--once` (it boots, reports and exits — nothing to restart), `--help`, or an argv
|
|
33
|
+
* the parse refuses (`dispatch` reports that, in the child's place). `--port 0` is pinned to one
|
|
34
|
+
* free port here, so a restarted child binds the address the first one printed.
|
|
35
|
+
*/
|
|
36
|
+
export function devSupervision(
|
|
37
|
+
argv: readonly string[],
|
|
38
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
39
|
+
pickPort: () => number,
|
|
40
|
+
): DevSupervision | undefined {
|
|
41
|
+
if (argv[0] !== 'dev' || env[DEV_CHILD_ENV] === '1') return undefined;
|
|
42
|
+
try {
|
|
43
|
+
const args = parseArgs(argv, [devSpec]);
|
|
44
|
+
if (args.help || args.flags.get('once') === true) return undefined;
|
|
45
|
+
return { argv: devPortFor(args, env) === 0 ? withPort(argv, pickPort()) : argv };
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A port nothing holds right now, asked of the OS — what `--port 0` is pinned to. */
|
|
52
|
+
export function freeDevPort(): number {
|
|
53
|
+
const probe = Bun.serve({ port: 0, fetch: () => new Response() });
|
|
54
|
+
const port = probe.port ?? 0;
|
|
55
|
+
probe.stop(true);
|
|
56
|
+
return port;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `argv` with every `--port` removed and `--port <port>` appended. */
|
|
60
|
+
function withPort(argv: readonly string[], port: number): readonly string[] {
|
|
61
|
+
const out: string[] = [];
|
|
62
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
63
|
+
const token = argv[index] ?? '';
|
|
64
|
+
if (token === '--port') {
|
|
65
|
+
index += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (token.startsWith('--port=')) continue;
|
|
69
|
+
out.push(token);
|
|
70
|
+
}
|
|
71
|
+
return [...out, '--port', String(port)];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The child as the loop sees it — `Bun.spawn`'s answer, narrowed so a test can hand one in. */
|
|
75
|
+
export interface DevChild {
|
|
76
|
+
readonly exited: Promise<number>;
|
|
77
|
+
kill(signal: 'SIGINT' | 'SIGTERM'): void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SuperviseDevInput {
|
|
81
|
+
readonly bin: string;
|
|
82
|
+
readonly argv: readonly string[];
|
|
83
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
84
|
+
readonly spawn?: (
|
|
85
|
+
command: readonly string[],
|
|
86
|
+
env: Record<string, string | undefined>,
|
|
87
|
+
) => DevChild;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const spawnInherited = (
|
|
91
|
+
command: readonly string[],
|
|
92
|
+
env: Record<string, string | undefined>,
|
|
93
|
+
): DevChild =>
|
|
94
|
+
Bun.spawn([...command], { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit', env });
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Runs children until one exits with anything but `DEV_RESTART_EXIT_CODE`, and answers that code.
|
|
98
|
+
* SIGINT and SIGTERM are forwarded — a terminal's Ctrl-C reaches the child on its own too, and its
|
|
99
|
+
* drain is idempotent — and once one arrived no child is started again: a Ctrl-C during a restart
|
|
100
|
+
* is a stop, never a respawn.
|
|
101
|
+
*
|
|
102
|
+
* Every other way out stops the child too: an uncaught error or a `process.exit` here reaches the
|
|
103
|
+
* `exit` listener, which SIGTERMs a child still running. The one way no listener sees is a SIGKILL
|
|
104
|
+
* of this process — the child's own watch (`dev-child-watch.ts`) answers that, from its side, off
|
|
105
|
+
* the pid this loop hands it.
|
|
106
|
+
*/
|
|
107
|
+
export async function superviseDev(input: SuperviseDevInput): Promise<number> {
|
|
108
|
+
const spawn = input.spawn ?? spawnInherited;
|
|
109
|
+
let child: DevChild | undefined;
|
|
110
|
+
let running = false;
|
|
111
|
+
let stopping = false;
|
|
112
|
+
const forward = (signal: 'SIGINT' | 'SIGTERM') => (): void => {
|
|
113
|
+
stopping = true;
|
|
114
|
+
child?.kill(signal);
|
|
115
|
+
};
|
|
116
|
+
const onInt = forward('SIGINT');
|
|
117
|
+
const onTerm = forward('SIGTERM');
|
|
118
|
+
const onExit = (): void => {
|
|
119
|
+
if (running) child?.kill('SIGTERM');
|
|
120
|
+
};
|
|
121
|
+
process.on('SIGINT', onInt);
|
|
122
|
+
process.on('SIGTERM', onTerm);
|
|
123
|
+
process.on('exit', onExit);
|
|
124
|
+
try {
|
|
125
|
+
for (;;) {
|
|
126
|
+
child = spawn([process.execPath, input.bin, ...input.argv], {
|
|
127
|
+
...input.env,
|
|
128
|
+
[DEV_CHILD_ENV]: '1',
|
|
129
|
+
[DEV_SUPERVISOR_PID_ENV]: String(process.pid),
|
|
130
|
+
});
|
|
131
|
+
running = true;
|
|
132
|
+
const code = await child.exited;
|
|
133
|
+
running = false;
|
|
134
|
+
if (code !== DEV_RESTART_EXIT_CODE || stopping) return code;
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
process.off('SIGINT', onInt);
|
|
138
|
+
process.off('SIGTERM', onTerm);
|
|
139
|
+
process.off('exit', onExit);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `apps/web/app/greet/service.ts under apps/web/app/greet/queries.ts`, one per pinned module. */
|
|
144
|
+
export function restartReason(root: string, pins: readonly StalePin[]): string {
|
|
145
|
+
return pins
|
|
146
|
+
.map(({ changed, pinned }) =>
|
|
147
|
+
changed === pinned
|
|
148
|
+
? relative(root, pinned)
|
|
149
|
+
: `${relative(root, changed)} under ${relative(root, pinned)}`,
|
|
150
|
+
)
|
|
151
|
+
.join(', ');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `/_x`'s finding for a process nothing supervises (an embedded `startDev()` with no `onRestart`). */
|
|
155
|
+
export function restartFinding(root: string, pins: readonly StalePin[]): Finding {
|
|
156
|
+
const first = pins[0];
|
|
157
|
+
return {
|
|
158
|
+
code: 'X_DEV_RESTART_REQUIRED',
|
|
159
|
+
cause: `${restartReason(root, pins)} — a module that defines a primitive still holds the code before the save, and re-importing it would register a second definition`,
|
|
160
|
+
fix: 'restart x dev (a supervised `x dev` restarts itself; an embedded startDev() passes onRestart)',
|
|
161
|
+
at: first === undefined ? '' : relative(root, first.pinned),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The child's half: what `startDev` is handed, and what the hold calls once released. */
|
|
166
|
+
export interface ChildRestart {
|
|
167
|
+
readonly options: { readonly onRestart?: (pins: readonly StalePin[]) => void };
|
|
168
|
+
readonly exit: (code: number) => void;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Only a supervised child restarts: it says why on stderr (fd 1 may be `--json`'s one document),
|
|
173
|
+
* starts core's drain — the hold then releases the lock, the port and the embedded database — and
|
|
174
|
+
* exits `DEV_RESTART_EXIT_CODE` once released. Anything else keeps the finding on `/_x`.
|
|
175
|
+
*
|
|
176
|
+
* A child its supervisor named (`DEV_SUPERVISOR_PID_ENV`) also watches for that supervisor's death
|
|
177
|
+
* and its app root's, and stops on either — exit 1 for a root that is gone, which the supervisor,
|
|
178
|
+
* if it is still there, answers by stopping rather than respawning into nothing.
|
|
179
|
+
*/
|
|
180
|
+
export function childRestart(
|
|
181
|
+
root: string,
|
|
182
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
183
|
+
watch: typeof startDevChildWatch = startDevChildWatch,
|
|
184
|
+
): ChildRestart {
|
|
185
|
+
if (env[DEV_CHILD_ENV] !== '1') return { options: {}, exit: () => undefined };
|
|
186
|
+
let leaving: 'restart' | DevChildGone | undefined;
|
|
187
|
+
const watched =
|
|
188
|
+
supervisorPid(env) === undefined
|
|
189
|
+
? undefined
|
|
190
|
+
: watch(root, env, (why) => {
|
|
191
|
+
leaving ??= why;
|
|
192
|
+
});
|
|
193
|
+
return {
|
|
194
|
+
options: {
|
|
195
|
+
onRestart: (pins) => {
|
|
196
|
+
process.stderr.write(`${msg('cli.dev.restart', { reason: restartReason(root, pins) })}\n`);
|
|
197
|
+
leaving ??= 'restart';
|
|
198
|
+
watched?.stopping(DEV_RESTART_EXIT_CODE);
|
|
199
|
+
void drain('restart');
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
exit: () => {
|
|
203
|
+
if (leaving === 'restart') process.exit(DEV_RESTART_EXIT_CODE);
|
|
204
|
+
if (leaving === 'root') process.exit(1);
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
package/src/error-codes.ts
CHANGED
|
@@ -203,6 +203,8 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
203
203
|
'X_VERIFY_MERGE_INCOMPLETE',
|
|
204
204
|
'X_VERIFY_MERGE_INPUT',
|
|
205
205
|
'X_SITEMAP_EXTRA_INVALID',
|
|
206
|
+
'X_DEV_RESTART_REQUIRED',
|
|
207
|
+
'X_DEV_ROOT_GONE',
|
|
206
208
|
] as const;
|
|
207
209
|
|
|
208
210
|
/**
|
|
@@ -355,6 +357,9 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
355
357
|
X_VERIFY_MERGE_INCOMPLETE: 'x verify merge was handed parts that do not add up to the whole gate',
|
|
356
358
|
X_VERIFY_MERGE_INPUT: 'a part handed to x verify merge is not an x verify --json document',
|
|
357
359
|
X_SITEMAP_EXTRA_INVALID: 'a seo.sitemap.extra path names no public page',
|
|
360
|
+
X_DEV_RESTART_REQUIRED:
|
|
361
|
+
'a save reached a module that defines a primitive; only a new process serves it',
|
|
362
|
+
X_DEV_ROOT_GONE: 'the app root x dev serves was deleted or moved',
|
|
358
363
|
};
|
|
359
364
|
|
|
360
365
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
package/src/index.ts
CHANGED
|
@@ -107,7 +107,7 @@ export { ICON_BASE_PATH, ICON_SOURCE } from './icon-assets';
|
|
|
107
107
|
// `@ultimat3/testing` takes this function as its `build` parameter (issue #260). `discoverIslands`,
|
|
108
108
|
// `islandBundle`, `writeIslands`, `ISLAND_BASE_PATH` and `ISLAND_GLOB` stay internal: they are
|
|
109
109
|
// `x build`'s and `x dev`'s wiring, and every name here is a semver promise forever.
|
|
110
|
-
export type { IslandChunk } from './island-bundle';
|
|
110
|
+
export type { IslandChunk, SharedChunk } from './island-bundle';
|
|
111
111
|
export { buildIslands } from './island-bundle';
|
|
112
112
|
export { JOB_STATES } from './jobs-report';
|
|
113
113
|
export { renderJobTable } from './jobs-table';
|