@zerotal/core 1.4.0 → 1.5.1
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/CHANGELOG.md +370 -0
- package/package.json +1 -1
- package/src/application/Application.ts +107 -9
- package/src/application/DevErrorPage.ts +82 -0
- package/src/application/diagnostics.ts +111 -0
- package/src/command/CommandRunner.ts +82 -1
- package/src/command/builtin/AssetsBuildCommand.ts +102 -0
- package/src/command/builtin/DeployCommand.ts +315 -0
- package/src/command/builtin/DevCommand.ts +88 -0
- package/src/command/builtin/DoctorCommand.ts +97 -0
- package/src/command/builtin/MakeCommandCommand.ts +2 -0
- package/src/command/builtin/RouteTypesCommand.ts +56 -0
- package/src/command/builtin/ServeCommand.ts +232 -44
- package/src/command/builtin/index.ts +5 -0
- package/src/command/scaffold/zerotal.ts.txt +2 -10
- package/src/config/AppConfig.ts +109 -2
- package/src/config/DeployConfig.ts +71 -0
- package/src/config/index.ts +2 -0
- package/src/config/registry.ts +1 -0
- package/src/container/Container.ts +3 -3
- package/src/container/inject.ts +3 -2
- package/src/context/RequestContext.ts +60 -0
- package/src/contracts/session.ts +18 -3
- package/src/dev/BuildCache.ts +312 -0
- package/src/dev/CssPlugins.ts +93 -7
- package/src/dev/DevBuildHook.ts +14 -1
- package/src/dev/DevDeck.ts +549 -0
- package/src/dev/DevOrchestrator.ts +166 -31
- package/src/dev/DevProcess.ts +221 -0
- package/src/dev/DevReloadMiddleware.ts +1 -1
- package/src/dev/DevSupervisor.ts +363 -0
- package/src/dev/bootBuild.ts +94 -0
- package/src/dev/index.ts +24 -0
- package/src/dev/startDevMode.ts +145 -0
- package/src/doctor/AppDoctor.ts +399 -0
- package/src/doctor/TransportProbe.ts +169 -0
- package/src/events/Emitter.ts +4 -3
- package/src/facade/facades/App.ts +10 -2
- package/src/helpers/index.ts +24 -4
- package/src/helpers/response.ts +18 -8
- package/src/http/Uri.ts +7 -3
- package/src/http/originGuard.ts +1 -1
- package/src/http/url.ts +10 -4
- package/src/index.ts +43 -0
- package/src/lock/LockManager.ts +190 -14
- package/src/lock/drivers/LockDriver.ts +11 -0
- package/src/lock/drivers/MemoryLockDriver.ts +21 -1
- package/src/lock/drivers/RedisLockDriver.ts +64 -8
- package/src/lock/drivers/SqliteLockDriver.ts +13 -0
- package/src/lock/errors.ts +26 -0
- package/src/lock/facades/Lock.ts +30 -5
- package/src/lock/index.ts +2 -2
- package/src/macros/config.macro.ts +2 -0
- package/src/provider/ServiceProvider.ts +40 -0
- package/src/router/Router.ts +111 -13
- package/src/router/registry.ts +123 -0
- package/src/router/routeTypes.ts +132 -0
- package/src/support/classRef.ts +27 -0
- package/src/support/env.ts +99 -3
- package/src/support/unroutedRoutes.ts +37 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the dev processes and keeps them running.
|
|
3
|
+
*
|
|
4
|
+
* The supervisor owns lifetimes, not presentation: it spawns, watches, restarts,
|
|
5
|
+
* gives up, and reports — every line of output and every state change goes out
|
|
6
|
+
* through callbacks. The deck draws them; the stream writer prefixes them. Neither
|
|
7
|
+
* is imported here, which is what lets the whole thing be tested with a fake
|
|
8
|
+
* spawner and no terminal at all.
|
|
9
|
+
*
|
|
10
|
+
* ## One rule above the others
|
|
11
|
+
*
|
|
12
|
+
* A dev process dying must never take the server down. That is the whole
|
|
13
|
+
* difference from the build hook, where a failure aborts the reload on purpose.
|
|
14
|
+
* Here a crashed type-checker is an annoyance in one tab; if it could stop the
|
|
15
|
+
* server it would be a worse problem than the one the tab was added to solve.
|
|
16
|
+
*/
|
|
17
|
+
import type { ResolvedDevProcess } from "./DevProcess.ts";
|
|
18
|
+
|
|
19
|
+
/** What the supervisor needs back from whatever it spawned. */
|
|
20
|
+
export interface DevChild {
|
|
21
|
+
readonly stdout: ReadableStream<Uint8Array> | null;
|
|
22
|
+
readonly stderr: ReadableStream<Uint8Array> | null;
|
|
23
|
+
readonly exited: Promise<number>;
|
|
24
|
+
kill(signal?: number | NodeJS.Signals): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** How the supervisor starts a child. Injected so tests need no real processes. */
|
|
28
|
+
export type DevSpawnFn = (
|
|
29
|
+
argv: string[],
|
|
30
|
+
options: { cwd: string; env: Record<string, string | undefined> },
|
|
31
|
+
) => DevChild;
|
|
32
|
+
|
|
33
|
+
/** Where a process is in its life. */
|
|
34
|
+
export type DevProcessState = "starting" | "running" | "restarting" | "exited" | "parked";
|
|
35
|
+
|
|
36
|
+
/** A process as the deck sees it. */
|
|
37
|
+
export interface DevProcessStatus {
|
|
38
|
+
name: string;
|
|
39
|
+
label: string;
|
|
40
|
+
color: ResolvedDevProcess["color"];
|
|
41
|
+
state: DevProcessState;
|
|
42
|
+
/** Exit code of the last run, when it has exited. */
|
|
43
|
+
exitCode?: number;
|
|
44
|
+
/** Consecutive failed starts. Reset once a run stays up. */
|
|
45
|
+
attempts: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How the supervisor reports what is happening. */
|
|
49
|
+
export interface DevSupervisorHooks {
|
|
50
|
+
/** One line of a process's output. `stream` distinguishes stderr for colouring. */
|
|
51
|
+
onLine?: (name: string, line: string, stream: "stdout" | "stderr") => void;
|
|
52
|
+
/** A process changed state — repaint the tab. */
|
|
53
|
+
onState?: (status: DevProcessStatus) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DevSupervisorOptions extends DevSupervisorHooks {
|
|
57
|
+
cwd: string;
|
|
58
|
+
env?: Record<string, string | undefined>;
|
|
59
|
+
/** Defaults to `Bun.spawn` with output piped and **stdin ignored**. */
|
|
60
|
+
spawn?: DevSpawnFn;
|
|
61
|
+
/**
|
|
62
|
+
* Backoff before each retry, in ms. Overridable for the same reason `spawn`
|
|
63
|
+
* is: a test of the restart policy should assert the policy, not spend three
|
|
64
|
+
* seconds proving that `setTimeout` works.
|
|
65
|
+
*/
|
|
66
|
+
backoffMs?: number[];
|
|
67
|
+
/** How long a run must last to be considered healthy. See {@link HEALTHY_AFTER_MS}. */
|
|
68
|
+
healthyAfterMs?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Give up after this many consecutive failed starts. */
|
|
72
|
+
const MAX_ATTEMPTS = 3;
|
|
73
|
+
/** Backoff before retry N, in ms. */
|
|
74
|
+
const BACKOFF_MS = [300, 900, 2_400];
|
|
75
|
+
/**
|
|
76
|
+
* A run that stays up this long is treated as healthy, and the attempt counter
|
|
77
|
+
* resets. Without it a process that crashes once an hour eventually parks itself
|
|
78
|
+
* for a reason that has nothing to do with the current failure.
|
|
79
|
+
*/
|
|
80
|
+
const HEALTHY_AFTER_MS = 10_000;
|
|
81
|
+
|
|
82
|
+
/** One supervised process's mutable state. */
|
|
83
|
+
interface Entry {
|
|
84
|
+
definition: ResolvedDevProcess;
|
|
85
|
+
status: DevProcessStatus;
|
|
86
|
+
child?: DevChild | undefined;
|
|
87
|
+
abort?: AbortController | undefined;
|
|
88
|
+
/** Set while a deliberate stop or restart is in flight, so the exit is not "unexpected". */
|
|
89
|
+
stopping: boolean;
|
|
90
|
+
/** Cleared on stop so a parked retry cannot fire after shutdown. */
|
|
91
|
+
retryTimer?: ReturnType<typeof setTimeout> | undefined;
|
|
92
|
+
healthyTimer?: ReturnType<typeof setTimeout> | undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class DevSupervisor {
|
|
96
|
+
private readonly _entries = new Map<string, Entry>();
|
|
97
|
+
private readonly _cwd: string;
|
|
98
|
+
private readonly _env: Record<string, string | undefined>;
|
|
99
|
+
private readonly _spawn: DevSpawnFn;
|
|
100
|
+
private readonly _hooks: DevSupervisorHooks;
|
|
101
|
+
private readonly _backoff: number[];
|
|
102
|
+
private readonly _healthyAfter: number;
|
|
103
|
+
private _stopped = false;
|
|
104
|
+
|
|
105
|
+
constructor(options: DevSupervisorOptions) {
|
|
106
|
+
this._cwd = options.cwd;
|
|
107
|
+
this._env = options.env ?? { ...Bun.env };
|
|
108
|
+
this._spawn = options.spawn ?? _bunSpawn;
|
|
109
|
+
this._backoff = options.backoffMs ?? BACKOFF_MS;
|
|
110
|
+
this._healthyAfter = options.healthyAfterMs ?? HEALTHY_AFTER_MS;
|
|
111
|
+
this._hooks = {
|
|
112
|
+
...(options.onLine && { onLine: options.onLine }),
|
|
113
|
+
...(options.onState && { onState: options.onState }),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Every process currently known, in registration order. */
|
|
118
|
+
statuses(): DevProcessStatus[] {
|
|
119
|
+
return [...this._entries.values()].map((entry) => ({ ...entry.status }));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The definitions this supervisor was given, in registration order. */
|
|
123
|
+
definitions(): ResolvedDevProcess[] {
|
|
124
|
+
return [...this._entries.values()].map((entry) => entry.definition);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Start every process in `definitions`.
|
|
129
|
+
*
|
|
130
|
+
* Called twice by the orchestrator — once with the `after: "none"` set beside
|
|
131
|
+
* the first server spawn, once with `after: "server"` after it binds — so a
|
|
132
|
+
* process that talks to the server is not started against a closed port.
|
|
133
|
+
*/
|
|
134
|
+
start(definitions: ResolvedDevProcess[]): void {
|
|
135
|
+
for (const definition of definitions) {
|
|
136
|
+
if (this._entries.has(definition.name)) continue;
|
|
137
|
+
const entry: Entry = {
|
|
138
|
+
definition,
|
|
139
|
+
status: {
|
|
140
|
+
name: definition.name,
|
|
141
|
+
label: definition.label,
|
|
142
|
+
color: definition.color,
|
|
143
|
+
state: "starting",
|
|
144
|
+
attempts: 0,
|
|
145
|
+
},
|
|
146
|
+
stopping: false,
|
|
147
|
+
};
|
|
148
|
+
this._entries.set(definition.name, entry);
|
|
149
|
+
this._launch(entry);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Restart one process by name, whatever state it is in.
|
|
155
|
+
*
|
|
156
|
+
* This is also the way out of `parked`: the attempt counter resets, because a
|
|
157
|
+
* developer asking for a restart has usually just fixed the thing that broke.
|
|
158
|
+
*/
|
|
159
|
+
async restart(name: string): Promise<void> {
|
|
160
|
+
const entry = this._entries.get(name);
|
|
161
|
+
if (!entry || this._stopped) return;
|
|
162
|
+
|
|
163
|
+
entry.status.attempts = 0;
|
|
164
|
+
this._setState(entry, "restarting");
|
|
165
|
+
await this._stopEntry(entry);
|
|
166
|
+
if (this._stopped) return;
|
|
167
|
+
this._launch(entry);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Stop everything and stay stopped. Safe to call twice. */
|
|
171
|
+
async stopAll(): Promise<void> {
|
|
172
|
+
this._stopped = true;
|
|
173
|
+
await Promise.all([...this._entries.values()].map((entry) => this._stopEntry(entry)));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Internals ──────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
private _launch(entry: Entry): void {
|
|
179
|
+
if (this._stopped) return;
|
|
180
|
+
entry.stopping = false;
|
|
181
|
+
this._setState(entry, "starting");
|
|
182
|
+
|
|
183
|
+
const startedAt = Date.now();
|
|
184
|
+
entry.healthyTimer = setTimeout(() => {
|
|
185
|
+
entry.status.attempts = 0;
|
|
186
|
+
}, this._healthyAfter);
|
|
187
|
+
|
|
188
|
+
if (entry.definition.run) {
|
|
189
|
+
const abort = new AbortController();
|
|
190
|
+
entry.abort = abort;
|
|
191
|
+
this._setState(entry, "running");
|
|
192
|
+
void entry.definition
|
|
193
|
+
.run(abort.signal)
|
|
194
|
+
.then(() => this._onExit(entry, 0, startedAt))
|
|
195
|
+
.catch((error: unknown) => {
|
|
196
|
+
if (!abort.signal.aborted) this._emit(entry, _errorText(error), "stderr");
|
|
197
|
+
this._onExit(entry, abort.signal.aborted ? 0 : 1, startedAt);
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let child: DevChild;
|
|
203
|
+
try {
|
|
204
|
+
child = this._spawn(entry.definition.argv!, { cwd: this._cwd, env: this._env });
|
|
205
|
+
} catch (error) {
|
|
206
|
+
// A missing binary is the usual cause, and it fails every attempt
|
|
207
|
+
// identically — report it as output so the tab explains itself.
|
|
208
|
+
this._emit(entry, _errorText(error), "stderr");
|
|
209
|
+
this._onExit(entry, 1, startedAt);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
entry.child = child;
|
|
214
|
+
this._setState(entry, "running");
|
|
215
|
+
void this._pump(entry, child.stdout, "stdout");
|
|
216
|
+
void this._pump(entry, child.stderr, "stderr");
|
|
217
|
+
void child.exited.then((code) => this._onExit(entry, code, startedAt));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Decide what happens after a process ends.
|
|
222
|
+
*
|
|
223
|
+
* A deliberate stop reports nothing and schedules nothing — `restart()` and
|
|
224
|
+
* `stopAll()` own what comes next.
|
|
225
|
+
*/
|
|
226
|
+
private _onExit(entry: Entry, code: number, startedAt: number): void {
|
|
227
|
+
if (entry.healthyTimer) clearTimeout(entry.healthyTimer);
|
|
228
|
+
entry.child = undefined;
|
|
229
|
+
entry.abort = undefined;
|
|
230
|
+
entry.status.exitCode = code;
|
|
231
|
+
|
|
232
|
+
if (entry.stopping || this._stopped) return;
|
|
233
|
+
|
|
234
|
+
// A run that lasted counts as healthy even if it then failed: the attempt
|
|
235
|
+
// budget is for start-up failure loops, not for every crash forever.
|
|
236
|
+
if (Date.now() - startedAt >= this._healthyAfter) entry.status.attempts = 0;
|
|
237
|
+
|
|
238
|
+
const policy = entry.definition.restart;
|
|
239
|
+
const shouldRestart = policy === "always" || (policy === "on-failure" && code !== 0);
|
|
240
|
+
|
|
241
|
+
if (!shouldRestart) {
|
|
242
|
+
this._setState(entry, "exited");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
entry.status.attempts += 1;
|
|
247
|
+
if (entry.status.attempts >= MAX_ATTEMPTS) {
|
|
248
|
+
this._setState(entry, "parked");
|
|
249
|
+
this._emit(
|
|
250
|
+
entry,
|
|
251
|
+
`[zerotal:dev] "${entry.definition.name}" failed ${MAX_ATTEMPTS} times — parked. ` +
|
|
252
|
+
`Press r with this tab focused to retry, or run \`bun zt dev --only=${entry.definition.name}\`.`,
|
|
253
|
+
"stderr",
|
|
254
|
+
);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const delay = this._backoff[entry.status.attempts - 1] ?? this._backoff.at(-1)!;
|
|
259
|
+
this._setState(entry, "restarting");
|
|
260
|
+
entry.retryTimer = setTimeout(() => {
|
|
261
|
+
entry.retryTimer = undefined;
|
|
262
|
+
this._launch(entry);
|
|
263
|
+
}, delay);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Stop one process and wait for it to actually be gone. */
|
|
267
|
+
private async _stopEntry(entry: Entry): Promise<void> {
|
|
268
|
+
entry.stopping = true;
|
|
269
|
+
if (entry.retryTimer) {
|
|
270
|
+
clearTimeout(entry.retryTimer);
|
|
271
|
+
entry.retryTimer = undefined;
|
|
272
|
+
}
|
|
273
|
+
if (entry.healthyTimer) clearTimeout(entry.healthyTimer);
|
|
274
|
+
|
|
275
|
+
entry.abort?.abort();
|
|
276
|
+
|
|
277
|
+
const child = entry.child;
|
|
278
|
+
if (child) {
|
|
279
|
+
child.kill("SIGTERM");
|
|
280
|
+
// A child ignoring SIGTERM would otherwise hold the whole quit open, and a
|
|
281
|
+
// developer pressing q expects their shell back.
|
|
282
|
+
const force = setTimeout(() => child.kill("SIGKILL"), 1_500);
|
|
283
|
+
try {
|
|
284
|
+
await child.exited;
|
|
285
|
+
} catch {
|
|
286
|
+
// Already gone.
|
|
287
|
+
} finally {
|
|
288
|
+
clearTimeout(force);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
entry.child = undefined;
|
|
293
|
+
entry.abort = undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Split a piped stream into lines and hand each one to the hook. */
|
|
297
|
+
private async _pump(
|
|
298
|
+
entry: Entry,
|
|
299
|
+
stream: ReadableStream<Uint8Array> | null,
|
|
300
|
+
kind: "stdout" | "stderr",
|
|
301
|
+
): Promise<void> {
|
|
302
|
+
if (!stream) return;
|
|
303
|
+
const decoder = new TextDecoder();
|
|
304
|
+
// A reader rather than `for await`: async iteration on a ReadableStream is a
|
|
305
|
+
// runtime extension Bun has and the DOM types do not, and reaching for a cast
|
|
306
|
+
// to paper over that would hide a real portability question.
|
|
307
|
+
const reader = stream.getReader();
|
|
308
|
+
let buffered = "";
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
for (;;) {
|
|
312
|
+
const { done, value } = await reader.read();
|
|
313
|
+
if (done) break;
|
|
314
|
+
buffered += decoder.decode(value, { stream: true });
|
|
315
|
+
const lines = buffered.split("\n");
|
|
316
|
+
// The last element is whatever came after the final newline — a partial
|
|
317
|
+
// line that must wait for the next chunk rather than being emitted as a
|
|
318
|
+
// short one, which is how progress bars end up shredded across tabs.
|
|
319
|
+
buffered = lines.pop() ?? "";
|
|
320
|
+
for (const line of lines) this._emit(entry, line.replace(/\r$/, ""), kind);
|
|
321
|
+
}
|
|
322
|
+
} catch {
|
|
323
|
+
// The stream closes under us when the process is killed; that is the
|
|
324
|
+
// normal end of a pump, not a failure worth reporting.
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (buffered) this._emit(entry, buffered, kind);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private _emit(entry: Entry, line: string, kind: "stdout" | "stderr"): void {
|
|
331
|
+
this._hooks.onLine?.(entry.definition.name, line, kind);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private _setState(entry: Entry, state: DevProcessState): void {
|
|
335
|
+
entry.status.state = state;
|
|
336
|
+
this._hooks.onState?.({ ...entry.status });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The real spawner.
|
|
342
|
+
*
|
|
343
|
+
* `stdin: "ignore"` is not a detail. The dev worker reads stdin for reload
|
|
344
|
+
* signals from the orchestrator, and a second process holding a claim on the
|
|
345
|
+
* same terminal input makes that behaviour undebuggable — the reload silently
|
|
346
|
+
* goes to whoever won.
|
|
347
|
+
*/
|
|
348
|
+
function _bunSpawn(
|
|
349
|
+
argv: string[],
|
|
350
|
+
options: { cwd: string; env: Record<string, string | undefined> },
|
|
351
|
+
): DevChild {
|
|
352
|
+
return Bun.spawn(argv, {
|
|
353
|
+
stdin: "ignore",
|
|
354
|
+
stdout: "pipe",
|
|
355
|
+
stderr: "pipe",
|
|
356
|
+
cwd: options.cwd,
|
|
357
|
+
env: options.env,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function _errorText(error: unknown): string {
|
|
362
|
+
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
363
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether an asset bundle should be built as part of starting the server.
|
|
3
|
+
*
|
|
4
|
+
* `serve` rebuilds the frontend bundle at boot rather than trusting what was shipped,
|
|
5
|
+
* which is right in development and load-bearing in production for the wrong reason: it
|
|
6
|
+
* makes the server process require write access to its own output directory. A unit
|
|
7
|
+
* hardened the way a unit should be —
|
|
8
|
+
*
|
|
9
|
+
* ProtectSystem=strict
|
|
10
|
+
* ReadWritePaths=/opt/app/database /opt/app/storage
|
|
11
|
+
*
|
|
12
|
+
* — then fails at startup with `Read-only file system: writing chunk "./app.css"`, and
|
|
13
|
+
* restart-loops. The logs blame the filesystem rather than the boot-time build that made
|
|
14
|
+
* it a problem, so the fix looks like "grant more write access" when it should be "stop
|
|
15
|
+
* writing at boot".
|
|
16
|
+
*
|
|
17
|
+
* The policy here: in production, build only if the output directory is actually
|
|
18
|
+
* writable. A read-only output directory is a deployment that built its assets ahead of
|
|
19
|
+
* time and locked the tree down, which is the correct shape — so it is honoured with one
|
|
20
|
+
* log line rather than a crash. Everywhere else, and anywhere the directory is writable,
|
|
21
|
+
* behaviour is exactly as before.
|
|
22
|
+
*/
|
|
23
|
+
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
|
24
|
+
import { isProdLike } from "../support/env.ts";
|
|
25
|
+
|
|
26
|
+
/** What to do about a boot-time asset build, and why. */
|
|
27
|
+
export interface BootBuildDecision {
|
|
28
|
+
/** Whether to run the build. */
|
|
29
|
+
build: boolean;
|
|
30
|
+
/** Single-line explanation of a skip, worth logging. Absent when building. */
|
|
31
|
+
reason?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether a directory can be written to, creating it if absent.
|
|
36
|
+
*
|
|
37
|
+
* Tested by writing rather than by reading a permission bit: the thing that makes
|
|
38
|
+
* `public/` unwritable in the failure this guards against is `ProtectSystem=strict`, a
|
|
39
|
+
* mount-level restriction that a mode check does not see.
|
|
40
|
+
*/
|
|
41
|
+
export async function isWritableDir(dir: string): Promise<boolean> {
|
|
42
|
+
const probe = `${dir}/.zerotal-write-probe-${process.pid}`;
|
|
43
|
+
try {
|
|
44
|
+
await mkdir(dir, { recursive: true });
|
|
45
|
+
await writeFile(probe, "");
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
// Best-effort cleanup: the probe is already proof of writability, and failing to remove
|
|
50
|
+
// it should not turn a writable directory into an unwritable one.
|
|
51
|
+
try {
|
|
52
|
+
await unlink(probe);
|
|
53
|
+
} catch {
|
|
54
|
+
/* empty */
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decide whether to build the given output directories at boot.
|
|
61
|
+
*
|
|
62
|
+
* @param outDirs - Absolute paths the build writes into.
|
|
63
|
+
* @param env - The resolved `app.env` (or `APP_ENV`).
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* const decision = await bootBuildDecision([`${cwd}/public/css`], config('app.env'));
|
|
68
|
+
* if (!decision.build) console.info(decision.reason);
|
|
69
|
+
* else await build();
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export async function bootBuildDecision(
|
|
73
|
+
outDirs: string[],
|
|
74
|
+
env: string | undefined,
|
|
75
|
+
): Promise<BootBuildDecision> {
|
|
76
|
+
// `isProdLike` — so `staging` gets the same treatment. A staging box is hardened
|
|
77
|
+
// like a production one, and used to build at boot regardless of whether its output
|
|
78
|
+
// directory was writable: the one environment where the read-only crash was still
|
|
79
|
+
// reachable.
|
|
80
|
+
if (!isProdLike(env ?? "")) return { build: true };
|
|
81
|
+
|
|
82
|
+
const unwritable: string[] = [];
|
|
83
|
+
for (const dir of outDirs) {
|
|
84
|
+
if (!(await isWritableDir(dir))) unwritable.push(dir);
|
|
85
|
+
}
|
|
86
|
+
if (unwritable.length === 0) return { build: true };
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
build: false,
|
|
90
|
+
reason:
|
|
91
|
+
`skipping the boot-time asset build: ${unwritable.join(", ")} is not writable. ` +
|
|
92
|
+
`Serving what was shipped — build assets at deploy time, before the process starts.`,
|
|
93
|
+
};
|
|
94
|
+
}
|
package/src/dev/index.ts
CHANGED
|
@@ -9,6 +9,23 @@
|
|
|
9
9
|
* @packageDocumentation
|
|
10
10
|
*/
|
|
11
11
|
export { registerDevBuildHook } from "./DevBuildHook.ts";
|
|
12
|
+
// The dev-process registry and its runners. The definition *types* are on the
|
|
13
|
+
// main barrel (a provider's `devProcesses()` signature needs to name them);
|
|
14
|
+
// everything that runs them is here, with the rest of the dev-server wiring.
|
|
15
|
+
export { collectDevProcesses } from "./DevProcess.ts";
|
|
16
|
+
export { DevSupervisor } from "./DevSupervisor.ts";
|
|
17
|
+
export type {
|
|
18
|
+
DevChild,
|
|
19
|
+
DevSpawnFn,
|
|
20
|
+
DevProcessState,
|
|
21
|
+
DevProcessStatus,
|
|
22
|
+
DevSupervisorOptions,
|
|
23
|
+
} from "./DevSupervisor.ts";
|
|
24
|
+
export { createDeck, StreamDeck, TabsDeck } from "./DevDeck.ts";
|
|
25
|
+
export type { Deck, DeckOptions } from "./DevDeck.ts";
|
|
26
|
+
export { startDevMode, SERVER_PROCESS_NAME } from "./startDevMode.ts";
|
|
27
|
+
export { DevOrchestrator } from "./DevOrchestrator.ts";
|
|
28
|
+
export type { DevOrchestratorHooks } from "./DevOrchestrator.ts";
|
|
12
29
|
export type { BuildHookFn, BuildResult } from "./DevBuildHook.ts";
|
|
13
30
|
export { DevReloadMiddleware, registerDevHtmlSnippet } from "./DevReloadMiddleware.ts";
|
|
14
31
|
export type { DevHtmlSnippet } from "./DevReloadMiddleware.ts";
|
|
@@ -16,4 +33,11 @@ export { DEV_RELOAD_CLIENT } from "./reloadClient.ts";
|
|
|
16
33
|
export { detectCssPlugins, buildCssBundle, buildJsBundle } from "./CssPlugins.ts";
|
|
17
34
|
export type { AssetBuildConfig } from "./CssPlugins.ts";
|
|
18
35
|
export { pruneBuildOutput } from "./BuildOutput.ts";
|
|
36
|
+
// Lets `serve` and any view provider agree on whether to build at boot, so a hardened
|
|
37
|
+
// production unit with a read-only output directory logs a line instead of restart-looping.
|
|
38
|
+
export { bootBuildDecision, isWritableDir } from "./bootBuild.ts";
|
|
39
|
+
export type { BootBuildDecision } from "./bootBuild.ts";
|
|
40
|
+
// Lets a view provider skip its own boot-time build when the orchestrator is
|
|
41
|
+
// already driving builds through the hook above.
|
|
42
|
+
export { isDevOrchestrated } from "../support/env.ts";
|
|
19
43
|
// `AppAssetsConfig` (the app-level assets config shape) lives on @zerotal/core/config.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `bun zt dev` and `bun zt serve --dev` both run.
|
|
3
|
+
*
|
|
4
|
+
* One entry point on purpose. The two commands differ only in their flags —
|
|
5
|
+
* `dev` adds the deck controls — and a second copy of this wiring is how they
|
|
6
|
+
* would quietly stop agreeing about what dev mode consists of.
|
|
7
|
+
*
|
|
8
|
+
* The pieces it joins up:
|
|
9
|
+
*
|
|
10
|
+
* - `collectDevProcesses` asks the booted app what to run.
|
|
11
|
+
* - {@link DevSupervisor} runs it and keeps it running.
|
|
12
|
+
* - `createDeck` draws it, in tabs or as a prefixed stream.
|
|
13
|
+
* - {@link DevOrchestrator} owns the server and the file watcher, as before.
|
|
14
|
+
*
|
|
15
|
+
* The server is a card in the deck like any other, which is what makes
|
|
16
|
+
* `--only=server` mean something and lets its tab show `restarting` on a save.
|
|
17
|
+
*/
|
|
18
|
+
import { DevOrchestrator } from "./DevOrchestrator.ts";
|
|
19
|
+
import { DevSupervisor } from "./DevSupervisor.ts";
|
|
20
|
+
import type { DevProcessStatus } from "./DevSupervisor.ts";
|
|
21
|
+
import { createDeck } from "./DevDeck.ts";
|
|
22
|
+
import type { ResolvedDevProcess } from "./DevProcess.ts";
|
|
23
|
+
import type { BuildHookFn } from "./DevBuildHook.ts";
|
|
24
|
+
import type { OutputWriter } from "../command/OutputWriter.ts";
|
|
25
|
+
|
|
26
|
+
export interface StartDevModeOptions {
|
|
27
|
+
port: number;
|
|
28
|
+
cwd: string;
|
|
29
|
+
/**
|
|
30
|
+
* The asset build. Optional because a run with no server has nothing to build
|
|
31
|
+
* and nothing to serve it to — `--only=queue` is supervision and a deck, and
|
|
32
|
+
* the orchestrator that would call this never starts.
|
|
33
|
+
*/
|
|
34
|
+
build?: BuildHookFn;
|
|
35
|
+
/** Everything the providers and the app contributed, already filtered. */
|
|
36
|
+
processes: ResolvedDevProcess[];
|
|
37
|
+
writer: OutputWriter;
|
|
38
|
+
/** Force a renderer; omitted means tabs on a TTY, stream otherwise. */
|
|
39
|
+
deckMode?: "tabs" | "stream" | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The server's card. Named so `--only=server` and `--without=server` read naturally. */
|
|
43
|
+
export const SERVER_PROCESS_NAME = "server";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run dev mode until the process is signalled.
|
|
47
|
+
*
|
|
48
|
+
* Never returns: the orchestrator parks on an unresolved promise and the exit
|
|
49
|
+
* happens in its signal handlers, after the deck has restored the terminal.
|
|
50
|
+
*/
|
|
51
|
+
export async function startDevMode(options: StartDevModeOptions): Promise<void> {
|
|
52
|
+
const supervised = options.processes.filter((entry) => entry.name !== SERVER_PROCESS_NAME);
|
|
53
|
+
const wantsServer = options.processes.some((entry) => entry.name === SERVER_PROCESS_NAME);
|
|
54
|
+
|
|
55
|
+
// The server's card is synthesised rather than registered, because the
|
|
56
|
+
// orchestrator — not the supervisor — owns its lifetime. It still gets a tab,
|
|
57
|
+
// a colour and a state, so from the deck's side it is one of the crowd.
|
|
58
|
+
const serverStatus: DevProcessStatus = {
|
|
59
|
+
name: SERVER_PROCESS_NAME,
|
|
60
|
+
label: SERVER_PROCESS_NAME,
|
|
61
|
+
color: "green",
|
|
62
|
+
state: "starting",
|
|
63
|
+
attempts: 0,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Declared before the three objects that reference each other in a cycle: the
|
|
67
|
+
// deck's key handlers call the supervisor, the supervisor's output callbacks
|
|
68
|
+
// call the deck. Every one of them fires from a later turn of the event loop,
|
|
69
|
+
// by which point all three are assigned.
|
|
70
|
+
// eslint-disable-next-line prefer-const -- assigned below; the deck closes over both
|
|
71
|
+
let supervisor: DevSupervisor;
|
|
72
|
+
// eslint-disable-next-line prefer-const -- assigned below; the deck closes over both
|
|
73
|
+
let orchestrator: DevOrchestrator;
|
|
74
|
+
|
|
75
|
+
const deck = createDeck({
|
|
76
|
+
writer: options.writer,
|
|
77
|
+
...(options.deckMode ? { mode: options.deckMode } : {}),
|
|
78
|
+
onRestart: (name) => {
|
|
79
|
+
// The server restarts through the orchestrator, which also rebuilds
|
|
80
|
+
// assets — restarting it any other way would leave the browser holding
|
|
81
|
+
// bundles from before whatever the developer just fixed.
|
|
82
|
+
if (name === SERVER_PROCESS_NAME) void orchestrator.restartServer();
|
|
83
|
+
else void supervisor.restart(name);
|
|
84
|
+
},
|
|
85
|
+
onQuit: () => void orchestrator.shutdown(),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
supervisor = new DevSupervisor({
|
|
89
|
+
cwd: options.cwd,
|
|
90
|
+
onLine: (name, line, stream) => deck.line(name, line, stream),
|
|
91
|
+
onState: (status) => deck.state(status),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const build: BuildHookFn =
|
|
95
|
+
options.build ?? ((): Promise<{ success: boolean }> => Promise.resolve({ success: true }));
|
|
96
|
+
|
|
97
|
+
orchestrator = new DevOrchestrator(options.port, options.cwd, build, {
|
|
98
|
+
...(wantsServer
|
|
99
|
+
? {
|
|
100
|
+
onServerLine: (line, stream) => deck.line(SERVER_PROCESS_NAME, line, stream),
|
|
101
|
+
onServerState: (state) => {
|
|
102
|
+
serverStatus.state = state;
|
|
103
|
+
deck.state({ ...serverStatus });
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
: {}),
|
|
107
|
+
onNotice: (text) => deck.notice(text.trim()),
|
|
108
|
+
onServerReady: () => {
|
|
109
|
+
supervisor.start(supervised.filter((entry) => entry.after === "server"));
|
|
110
|
+
},
|
|
111
|
+
onCleanup: async () => {
|
|
112
|
+
await supervisor.stopAll();
|
|
113
|
+
deck.stop();
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Cards exist before anything can write to them, so early output has a tab to
|
|
118
|
+
// land in rather than being dropped for want of one.
|
|
119
|
+
deck.start([...(wantsServer ? [serverStatus] : []), ...supervised.map(_toStatus)]);
|
|
120
|
+
|
|
121
|
+
// Started before the server rather than after it: these declared they do not
|
|
122
|
+
// depend on it, and making them wait for a build they have nothing to do with
|
|
123
|
+
// is dead time on every boot.
|
|
124
|
+
supervisor.start(supervised.filter((entry) => entry.after === "none"));
|
|
125
|
+
|
|
126
|
+
if (!wantsServer) {
|
|
127
|
+
// `--only=queue`, say — supervised and drawn, with no server underneath.
|
|
128
|
+
// Still parks forever: quitting goes through the deck like everywhere else.
|
|
129
|
+
await new Promise<never>(() => {});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
await orchestrator.start();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The card a process starts life with, before the supervisor has run it. */
|
|
137
|
+
function _toStatus(process: ResolvedDevProcess): DevProcessStatus {
|
|
138
|
+
return {
|
|
139
|
+
name: process.name,
|
|
140
|
+
label: process.label,
|
|
141
|
+
color: process.color,
|
|
142
|
+
state: "starting",
|
|
143
|
+
attempts: 0,
|
|
144
|
+
};
|
|
145
|
+
}
|