@zerotal/core 1.3.0 → 1.5.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/CHANGELOG.md +351 -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 +23 -1
- 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 +69 -2
- package/src/support/unroutedRoutes.ts +37 -0
|
@@ -6,6 +6,31 @@ import { watch } from "node:fs";
|
|
|
6
6
|
import type { BuildHookFn } from "./DevBuildHook.ts";
|
|
7
7
|
import { DEV_WORKER_ENV_VAR } from "../support/env.ts";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* How the orchestrator reports to whatever is presenting dev mode.
|
|
11
|
+
*
|
|
12
|
+
* All optional, and all defaulting to the console. Without them the orchestrator
|
|
13
|
+
* behaves exactly as it did before the deck existed — which is what keeps the
|
|
14
|
+
* plain `bun --watch` path and the existing tests honest.
|
|
15
|
+
*/
|
|
16
|
+
export interface DevOrchestratorHooks {
|
|
17
|
+
/**
|
|
18
|
+
* Take the server's output instead of letting it inherit the terminal.
|
|
19
|
+
*
|
|
20
|
+
* Providing this is what turns the server into a card in the deck rather than
|
|
21
|
+
* a process shouting over the tab bar.
|
|
22
|
+
*/
|
|
23
|
+
onServerLine?: (line: string, stream: "stdout" | "stderr") => void;
|
|
24
|
+
/** The server's own lifecycle, so its tab can show state like any other. */
|
|
25
|
+
onServerState?: (state: "starting" | "running" | "restarting" | "parked") => void;
|
|
26
|
+
/** Dev-mode's own narration — rebuilds, restarts, failures. */
|
|
27
|
+
onNotice?: (text: string, level: "info" | "warn" | "error") => void;
|
|
28
|
+
/** Called once the first server has bound, for `after: "server"` processes. */
|
|
29
|
+
onServerReady?: () => void | Promise<void>;
|
|
30
|
+
/** Called on SIGINT/SIGTERM, before the process exits. */
|
|
31
|
+
onCleanup?: () => void | Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
9
34
|
/**
|
|
10
35
|
* Dev Orchestrator — Process 1 of the two-process dev mode.
|
|
11
36
|
*
|
|
@@ -73,33 +98,64 @@ export class DevOrchestrator {
|
|
|
73
98
|
private readonly _port: number,
|
|
74
99
|
private readonly _cwd: string,
|
|
75
100
|
private readonly _build: BuildHookFn,
|
|
101
|
+
private readonly _hooks: DevOrchestratorHooks = {},
|
|
76
102
|
) {}
|
|
77
103
|
|
|
78
104
|
async start(): Promise<void> {
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
105
|
+
this._say(" [zerotal:dev] ⚙ building assets...");
|
|
106
|
+
const started = Bun.nanoseconds();
|
|
107
|
+
const build = await this._runBuild();
|
|
108
|
+
|
|
109
|
+
if (!build.ok) {
|
|
110
|
+
this._say(" [zerotal:dev] ⚠ initial build failed — starting server anyway", "warn");
|
|
111
|
+
} else if (build.skipped) {
|
|
112
|
+
// Said out loud on purpose. A boot that prints "building assets…" and then
|
|
113
|
+
// nothing looks like a hang, and a developer who cannot tell a skip from a
|
|
114
|
+
// stall deletes `.zerotal/` and stops trusting the cache.
|
|
115
|
+
this._say(` [zerotal:dev] ✓ assets unchanged — reused the last build (${_ms(started)})`);
|
|
83
116
|
}
|
|
84
117
|
|
|
85
118
|
// Same retry as a restart: the commonest reason the first bind fails is an orphaned
|
|
86
119
|
// server from a previous run that has not finished exiting.
|
|
87
120
|
await this._spawnServerWithRetry();
|
|
121
|
+
|
|
122
|
+
// Only now are `after: "server"` processes started — a process that talks to
|
|
123
|
+
// the server would otherwise spend its first attempts against a closed port
|
|
124
|
+
// and burn its restart budget before the server ever bound.
|
|
125
|
+
await this._hooks.onServerReady?.();
|
|
126
|
+
|
|
88
127
|
this._watch();
|
|
89
128
|
|
|
90
129
|
// Park the process — cleanup happens in signal handlers registered by _watch()
|
|
91
130
|
await new Promise<never>(() => {});
|
|
92
131
|
}
|
|
93
132
|
|
|
133
|
+
/** Narrate, through the deck when one is present and the console otherwise. */
|
|
134
|
+
private _say(text: string, level: "info" | "warn" | "error" = "info"): void {
|
|
135
|
+
if (this._hooks.onNotice) {
|
|
136
|
+
this._hooks.onNotice(text, level);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (level === "error") console.error(text);
|
|
140
|
+
else if (level === "warn") console.warn(text);
|
|
141
|
+
else console.log(text);
|
|
142
|
+
}
|
|
143
|
+
|
|
94
144
|
// ── Server management ──────────────────────────────────────────────────────
|
|
95
145
|
|
|
96
146
|
private _spawnServer(): ReturnType<typeof Bun.spawn> {
|
|
147
|
+
// Piped only when something is listening. Left inherited otherwise, so the
|
|
148
|
+
// server writes straight to the terminal exactly as it always has — routing
|
|
149
|
+
// it through a hook that discards would be how dev mode goes silent.
|
|
150
|
+
const routed = this._hooks.onServerLine !== undefined;
|
|
151
|
+
this._hooks.onServerState?.("starting");
|
|
152
|
+
|
|
97
153
|
const child = Bun.spawn(
|
|
98
154
|
["bun", Bun.main, "serve", "--port", String(this._port), "--dev-worker"],
|
|
99
155
|
{
|
|
100
156
|
stdin: "pipe",
|
|
101
|
-
stdout: "inherit",
|
|
102
|
-
stderr: "inherit",
|
|
157
|
+
stdout: routed ? "pipe" : "inherit",
|
|
158
|
+
stderr: routed ? "pipe" : "inherit",
|
|
103
159
|
cwd: this._cwd,
|
|
104
160
|
env: {
|
|
105
161
|
...Bun.env,
|
|
@@ -116,19 +172,62 @@ export class DevOrchestrator {
|
|
|
116
172
|
|
|
117
173
|
this._child = child;
|
|
118
174
|
|
|
175
|
+
if (routed) {
|
|
176
|
+
void this._pipe(child.stdout as ReadableStream<Uint8Array>, "stdout");
|
|
177
|
+
void this._pipe(child.stderr as ReadableStream<Uint8Array>, "stderr");
|
|
178
|
+
}
|
|
179
|
+
|
|
119
180
|
void child.exited.then((code) => {
|
|
120
181
|
// Report only the *current* server dying unexpectedly. Comparing against
|
|
121
182
|
// `this._child` identity matters: reading the field alone reported an exit that a
|
|
122
183
|
// restart had deliberately caused, because by then the field held the replacement.
|
|
123
184
|
// A restart in flight owns its own reporting.
|
|
124
185
|
if (this._child === child && this._restartInFlight === null && code !== 0) {
|
|
125
|
-
|
|
186
|
+
this._say(` [zerotal:dev] server exited with code ${code}`);
|
|
187
|
+
this._hooks.onServerState?.("parked");
|
|
126
188
|
}
|
|
127
189
|
});
|
|
128
190
|
|
|
129
191
|
return child;
|
|
130
192
|
}
|
|
131
193
|
|
|
194
|
+
/** Split the server child's piped output into lines and hand them to the deck. */
|
|
195
|
+
private async _pipe(
|
|
196
|
+
stream: ReadableStream<Uint8Array> | null,
|
|
197
|
+
kind: "stdout" | "stderr",
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
if (!stream) return;
|
|
200
|
+
const reader = stream.getReader();
|
|
201
|
+
const decoder = new TextDecoder();
|
|
202
|
+
let buffered = "";
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
for (;;) {
|
|
206
|
+
const { done, value } = await reader.read();
|
|
207
|
+
if (done) break;
|
|
208
|
+
buffered += decoder.decode(value, { stream: true });
|
|
209
|
+
const lines = buffered.split("\n");
|
|
210
|
+
buffered = lines.pop() ?? "";
|
|
211
|
+
for (const line of lines) this._hooks.onServerLine?.(line.replace(/\r$/, ""), kind);
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
// The stream ends when the server is replaced — the normal case on every
|
|
215
|
+
// restart, not something to report.
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (buffered) this._hooks.onServerLine?.(buffered, kind);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Restart the server on request — the deck's `r` key on the server tab.
|
|
223
|
+
*
|
|
224
|
+
* Goes through the same path a file change takes, so it rebuilds assets first
|
|
225
|
+
* and folds into a restart already running rather than racing it for the port.
|
|
226
|
+
*/
|
|
227
|
+
restartServer(): Promise<void> {
|
|
228
|
+
return this._requestRestart();
|
|
229
|
+
}
|
|
230
|
+
|
|
132
231
|
private _scheduleRestart(): void {
|
|
133
232
|
if (this._restartTimer) clearTimeout(this._restartTimer);
|
|
134
233
|
this._restartTimer = setTimeout(() => {
|
|
@@ -168,7 +267,8 @@ export class DevOrchestrator {
|
|
|
168
267
|
}
|
|
169
268
|
|
|
170
269
|
private async _restartOnce(): Promise<void> {
|
|
171
|
-
|
|
270
|
+
this._say(" [zerotal:dev] ↻ backend change — rebuilding + restarting server...");
|
|
271
|
+
this._hooks.onServerState?.("restarting");
|
|
172
272
|
|
|
173
273
|
// Rebuild assets before respawning: server-rendered views (Flow pages in `app/`,
|
|
174
274
|
// controllers returning markup) contain Tailwind classes the stylesheet scans via
|
|
@@ -216,7 +316,10 @@ export class DevOrchestrator {
|
|
|
216
316
|
Bun.sleep(DevOrchestrator.BIND_SETTLE_MS).then(() => false),
|
|
217
317
|
]);
|
|
218
318
|
|
|
219
|
-
if (!settled)
|
|
319
|
+
if (!settled) {
|
|
320
|
+
this._hooks.onServerState?.("running"); // still running → it bound the port
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
220
323
|
if (this._child !== child) return; // superseded by a newer restart
|
|
221
324
|
|
|
222
325
|
this._child = null;
|
|
@@ -225,10 +328,12 @@ export class DevOrchestrator {
|
|
|
225
328
|
}
|
|
226
329
|
}
|
|
227
330
|
|
|
228
|
-
|
|
331
|
+
this._hooks.onServerState?.("parked");
|
|
332
|
+
this._say(
|
|
229
333
|
` [zerotal:dev] ✗ server did not start after ${DevOrchestrator.RESPAWN_ATTEMPTS} attempts.\n` +
|
|
230
334
|
` Port ${this._port} may be held by another process — the error above says which.\n` +
|
|
231
335
|
` Dev mode is still watching; fix the cause and save to retry.`,
|
|
336
|
+
"error",
|
|
232
337
|
);
|
|
233
338
|
}
|
|
234
339
|
|
|
@@ -239,32 +344,43 @@ export class DevOrchestrator {
|
|
|
239
344
|
this._buildTimer = setTimeout(async () => {
|
|
240
345
|
this._buildTimer = null;
|
|
241
346
|
const label = path.startsWith("resources/pages/") ? "page" : "asset";
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
if (
|
|
246
|
-
|
|
347
|
+
this._say(` [zerotal:dev] ⚙ ${label} changed — rebuilding...`);
|
|
348
|
+
|
|
349
|
+
const build = await this._runBuild();
|
|
350
|
+
if (build.ok) {
|
|
351
|
+
// Still reload on a skip: the change that triggered this may have been
|
|
352
|
+
// to a file the bundles do not contain (a server-rendered template),
|
|
353
|
+
// and the browser has no other way to learn about it.
|
|
354
|
+
this._say(
|
|
355
|
+
build.skipped
|
|
356
|
+
? " [zerotal:dev] ✓ bundles unchanged — reloading browser"
|
|
357
|
+
: " [zerotal:dev] ✓ ready — reloading browser",
|
|
358
|
+
);
|
|
247
359
|
this._signalReload();
|
|
248
360
|
}
|
|
249
361
|
}, 80);
|
|
250
362
|
}
|
|
251
363
|
|
|
252
|
-
private async _runBuild(): Promise<boolean> {
|
|
364
|
+
private async _runBuild(): Promise<{ ok: boolean; skipped: boolean }> {
|
|
253
365
|
try {
|
|
254
366
|
const result = await this._build();
|
|
255
367
|
if (!result.success) {
|
|
256
|
-
|
|
368
|
+
this._say(" [zerotal:dev] ✗ build failed:", "error");
|
|
257
369
|
for (const entry of result.logs ?? []) {
|
|
258
|
-
|
|
370
|
+
this._say(` ${String(entry)}`, "error");
|
|
259
371
|
}
|
|
260
|
-
return false;
|
|
372
|
+
return { ok: false, skipped: false };
|
|
261
373
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
374
|
+
|
|
375
|
+
// A skipped build wrote nothing, so the files the browser already holds
|
|
376
|
+
// are still current — bumping the token would force a pointless refetch
|
|
377
|
+
// of every asset on the page.
|
|
378
|
+
if (result.skipped !== true) this._assetVersion = Date.now().toString(36);
|
|
379
|
+
|
|
380
|
+
return { ok: true, skipped: result.skipped === true };
|
|
265
381
|
} catch (error) {
|
|
266
|
-
|
|
267
|
-
return false;
|
|
382
|
+
this._say(` [zerotal:dev] ✗ build error: ${String(error)}`, "error");
|
|
383
|
+
return { ok: false, skipped: false };
|
|
268
384
|
}
|
|
269
385
|
}
|
|
270
386
|
|
|
@@ -306,13 +422,32 @@ export class DevOrchestrator {
|
|
|
306
422
|
}
|
|
307
423
|
});
|
|
308
424
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
process.exit(0);
|
|
313
|
-
};
|
|
425
|
+
process.on("SIGTERM", () => void this.shutdown(watcher));
|
|
426
|
+
process.on("SIGINT", () => void this.shutdown(watcher));
|
|
427
|
+
}
|
|
314
428
|
|
|
315
|
-
|
|
316
|
-
|
|
429
|
+
/**
|
|
430
|
+
* Stop everything dev mode owns, then exit.
|
|
431
|
+
*
|
|
432
|
+
* `onCleanup` runs *before* the exit and is awaited, because it is what stops
|
|
433
|
+
* the supervised processes and hands the terminal back. Exiting first would
|
|
434
|
+
* leave orphaned children and a shell in raw mode — the bug this ordering
|
|
435
|
+
* exists to prevent.
|
|
436
|
+
*/
|
|
437
|
+
async shutdown(watcher?: { close(): void }): Promise<never> {
|
|
438
|
+
watcher?.close();
|
|
439
|
+
try {
|
|
440
|
+
await this._hooks.onCleanup?.();
|
|
441
|
+
} catch {
|
|
442
|
+
// A failing cleanup must not stop the rest of the shutdown; whatever it
|
|
443
|
+
// could not release, exiting releases anyway.
|
|
444
|
+
}
|
|
445
|
+
this._child?.kill("SIGTERM");
|
|
446
|
+
process.exit(0);
|
|
317
447
|
}
|
|
318
448
|
}
|
|
449
|
+
|
|
450
|
+
/** Elapsed time since a `Bun.nanoseconds()` reading, as `"12ms"`. */
|
|
451
|
+
function _ms(since: number): string {
|
|
452
|
+
return `${Math.round((Bun.nanoseconds() - since) / 1e6)}ms`;
|
|
453
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev-process registry: what `bun zt dev` runs alongside the server.
|
|
3
|
+
*
|
|
4
|
+
* An app with a queue needs a worker running next to its server, and today that
|
|
5
|
+
* is a second terminal the developer has to remember to restart by hand. Every
|
|
6
|
+
* package with a companion process has the same gap — a type-checker, a Stripe
|
|
7
|
+
* listener, a Tailwind watcher — and until now there was no way for a package to
|
|
8
|
+
* close it, because the dev runner only knew about the server.
|
|
9
|
+
*
|
|
10
|
+
* A provider contributes one by overriding `devProcesses()`, the same shape as
|
|
11
|
+
* `replContext()`: the package declares what it needs, the tooling decides what
|
|
12
|
+
* to do with it.
|
|
13
|
+
*
|
|
14
|
+
* ## Why this is not the build hook
|
|
15
|
+
*
|
|
16
|
+
* `registerDevBuildHook` exists for *builds*, and a build is a step that has to
|
|
17
|
+
* finish before the server restarts — a failure there aborts the reload, which is
|
|
18
|
+
* correct, because serving a page against a half-built bundle is worse than not
|
|
19
|
+
* reloading. A process is the opposite: it runs for as long as dev mode does, and
|
|
20
|
+
* its death must never take the server with it. Two different lifetimes, two
|
|
21
|
+
* different failure rules, two different mechanisms.
|
|
22
|
+
*/
|
|
23
|
+
import type { ServiceProvider } from "../provider/ServiceProvider.ts";
|
|
24
|
+
|
|
25
|
+
/** What a provider or an app declares when it contributes a dev process. */
|
|
26
|
+
export interface DevProcessDefinition {
|
|
27
|
+
/**
|
|
28
|
+
* Stable identity, and what the user types in `--only` / `--without`.
|
|
29
|
+
*
|
|
30
|
+
* Registering a name twice replaces the earlier definition rather than adding
|
|
31
|
+
* a second entry, so an app can swap out a provider's worker for its own.
|
|
32
|
+
*/
|
|
33
|
+
name: string;
|
|
34
|
+
/**
|
|
35
|
+
* What to run, in one of three forms:
|
|
36
|
+
*
|
|
37
|
+
* - `"queue:work"` — a bare string is a `zt` command, resolved against the
|
|
38
|
+
* app's own entrypoint, so it picks up the same bootstrap the CLI does.
|
|
39
|
+
* - `["stripe", "listen"]` — raw argv, for a tool that is not a zt command.
|
|
40
|
+
* - `() => [...]` — the same, computed at startup when the argv depends on
|
|
41
|
+
* config the provider can only read once the app has booted.
|
|
42
|
+
*
|
|
43
|
+
* Mutually exclusive with {@link run}.
|
|
44
|
+
*/
|
|
45
|
+
command?: string | string[] | (() => string[]);
|
|
46
|
+
/**
|
|
47
|
+
* Run in-process instead of spawning, for work that has no separate binary.
|
|
48
|
+
* The signal aborts on shutdown and on a restart.
|
|
49
|
+
*
|
|
50
|
+
* Mutually exclusive with {@link command}.
|
|
51
|
+
*/
|
|
52
|
+
run?: (signal: AbortSignal) => Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Whether to run at all. A function is resolved **once**, at startup, so a
|
|
55
|
+
* provider can consult its own config — and so a process cannot flicker in
|
|
56
|
+
* and out of the deck while dev mode is running.
|
|
57
|
+
*/
|
|
58
|
+
enabled?: boolean | (() => boolean | Promise<boolean>);
|
|
59
|
+
/** Restart policy. Defaults to `"on-failure"`. */
|
|
60
|
+
restart?: "always" | "on-failure" | "never";
|
|
61
|
+
/**
|
|
62
|
+
* `"server"` waits for the server to be up before starting — for a process
|
|
63
|
+
* that talks to it. Defaults to `"none"`, which starts immediately.
|
|
64
|
+
*/
|
|
65
|
+
after?: "server" | "none";
|
|
66
|
+
/** Display name for the tab. Defaults to {@link name}. */
|
|
67
|
+
label?: string;
|
|
68
|
+
/** Tab colour. Auto-assigned from the palette when omitted. */
|
|
69
|
+
color?: DevProcessColor;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The colours a deck tab can take. Named, not ANSI codes, so the deck owns the rendering. */
|
|
73
|
+
export type DevProcessColor = "cyan" | "magenta" | "yellow" | "green" | "blue" | "red";
|
|
74
|
+
|
|
75
|
+
/** Assigned in order to processes that did not pick a colour. */
|
|
76
|
+
const _PALETTE: DevProcessColor[] = ["cyan", "magenta", "yellow", "green", "blue", "red"];
|
|
77
|
+
|
|
78
|
+
/** A definition with every default filled in and its argv settled. */
|
|
79
|
+
export interface ResolvedDevProcess {
|
|
80
|
+
name: string;
|
|
81
|
+
label: string;
|
|
82
|
+
color: DevProcessColor;
|
|
83
|
+
/** Settled argv, when this is a spawned process. */
|
|
84
|
+
argv?: string[];
|
|
85
|
+
/** The in-process body, when this is a `run` process. */
|
|
86
|
+
run?: (signal: AbortSignal) => Promise<void>;
|
|
87
|
+
restart: "always" | "on-failure" | "never";
|
|
88
|
+
after: "server" | "none";
|
|
89
|
+
/**
|
|
90
|
+
* Who registered it — a provider class name, or `"app.dev.processes"`.
|
|
91
|
+
*
|
|
92
|
+
* Kept so `zt dev --list` can answer "why is this running?", which is the
|
|
93
|
+
* question a developer actually has when an unfamiliar tab appears.
|
|
94
|
+
*/
|
|
95
|
+
registrant: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The `app.dev` config block. */
|
|
99
|
+
export interface DevConfigShape {
|
|
100
|
+
/** App-level processes, registered after every provider's. */
|
|
101
|
+
processes?: DevProcessDefinition[];
|
|
102
|
+
/** Names to drop, whoever registered them. */
|
|
103
|
+
disable?: string[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The bits of `Application` this module needs — kept structural to avoid a cycle. */
|
|
107
|
+
interface ProviderHost {
|
|
108
|
+
_activeProviders: ServiceProvider[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The bits of `ConfigManager` this module needs. */
|
|
112
|
+
interface ConfigReader {
|
|
113
|
+
get<T>(key: string, fallback?: T): T | undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Gather every dev process the booted app has to offer.
|
|
118
|
+
*
|
|
119
|
+
* Collection order is providers in boot order, then `app.dev.processes`, so an
|
|
120
|
+
* app always gets the last word. Name collisions replace in place rather than
|
|
121
|
+
* appending, which keeps the deck's tab order stable when an app overrides one.
|
|
122
|
+
*
|
|
123
|
+
* @param app A booted application — providers must have run, since `enabled`
|
|
124
|
+
* and a `command` thunk are allowed to read config.
|
|
125
|
+
* @param config The config manager, read for `app.dev`.
|
|
126
|
+
*/
|
|
127
|
+
export async function collectDevProcesses(
|
|
128
|
+
app: ProviderHost,
|
|
129
|
+
config?: ConfigReader,
|
|
130
|
+
): Promise<ResolvedDevProcess[]> {
|
|
131
|
+
const devConfig = config?.get<DevConfigShape>("app.dev") ?? {};
|
|
132
|
+
const declared = new Map<string, { definition: DevProcessDefinition; registrant: string }>();
|
|
133
|
+
|
|
134
|
+
for (const provider of app._activeProviders) {
|
|
135
|
+
const contributed = provider.devProcesses?.() ?? [];
|
|
136
|
+
const registrant = provider.constructor.name;
|
|
137
|
+
for (const definition of contributed) {
|
|
138
|
+
_assertShape(definition, registrant);
|
|
139
|
+
declared.set(definition.name, { definition, registrant });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const definition of devConfig.processes ?? []) {
|
|
144
|
+
_assertShape(definition, "app.dev.processes");
|
|
145
|
+
declared.set(definition.name, { definition, registrant: "app.dev.processes" });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const disabled = new Set(devConfig.disable ?? []);
|
|
149
|
+
const resolved: ResolvedDevProcess[] = [];
|
|
150
|
+
let paletteIndex = 0;
|
|
151
|
+
|
|
152
|
+
for (const { definition, registrant } of declared.values()) {
|
|
153
|
+
if (disabled.has(definition.name)) continue;
|
|
154
|
+
if (!(await _isEnabled(definition))) continue;
|
|
155
|
+
|
|
156
|
+
// Consumed only by processes that survived the filters, so removing one
|
|
157
|
+
// does not re-colour the others.
|
|
158
|
+
const color = definition.color ?? _PALETTE[paletteIndex++ % _PALETTE.length]!;
|
|
159
|
+
|
|
160
|
+
resolved.push({
|
|
161
|
+
name: definition.name,
|
|
162
|
+
label: definition.label ?? definition.name,
|
|
163
|
+
color,
|
|
164
|
+
...(definition.command !== undefined ? { argv: _toArgv(definition.command) } : {}),
|
|
165
|
+
...(definition.run !== undefined ? { run: definition.run } : {}),
|
|
166
|
+
restart: definition.restart ?? "on-failure",
|
|
167
|
+
after: definition.after ?? "none",
|
|
168
|
+
registrant,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return resolved;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Reject a definition that cannot be run, naming who registered it.
|
|
177
|
+
*
|
|
178
|
+
* Both errors are authoring mistakes in a provider the developer may not own, so
|
|
179
|
+
* the message has to say which package to go and look at.
|
|
180
|
+
*/
|
|
181
|
+
function _assertShape(definition: DevProcessDefinition, registrant: string): void {
|
|
182
|
+
if (!definition.name) {
|
|
183
|
+
throw new Error(`[Zerotal] ${registrant} registered a dev process with no name.`);
|
|
184
|
+
}
|
|
185
|
+
const hasCommand = definition.command !== undefined;
|
|
186
|
+
const hasRun = definition.run !== undefined;
|
|
187
|
+
if (hasCommand === hasRun) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`[Zerotal] Dev process "${definition.name}" (from ${registrant}) must set exactly one of ` +
|
|
190
|
+
`\`command\` or \`run\`, not ${hasCommand ? "both" : "neither"}.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Resolve `enabled` once. Anything that throws is treated as "not enabled". */
|
|
196
|
+
async function _isEnabled(definition: DevProcessDefinition): Promise<boolean> {
|
|
197
|
+
const enabled = definition.enabled;
|
|
198
|
+
if (enabled === undefined) return true;
|
|
199
|
+
if (typeof enabled !== "function") return enabled;
|
|
200
|
+
try {
|
|
201
|
+
return await enabled();
|
|
202
|
+
} catch {
|
|
203
|
+
// A provider probing config that is absent should leave its process out,
|
|
204
|
+
// not fail dev mode for every other process in the deck.
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Settle a `command` into argv.
|
|
211
|
+
*
|
|
212
|
+
* A bare string is a zt command and runs through `Bun.main` — the app's own
|
|
213
|
+
* entrypoint — so it boots the same providers and reads the same config the
|
|
214
|
+
* developer's `bun zt queue:work` would. Splitting on whitespace is deliberate
|
|
215
|
+
* and shallow: anything needing quoting should use the array form.
|
|
216
|
+
*/
|
|
217
|
+
function _toArgv(command: string | string[] | (() => string[])): string[] {
|
|
218
|
+
if (typeof command === "function") return command();
|
|
219
|
+
if (Array.isArray(command)) return command;
|
|
220
|
+
return ["bun", Bun.main, ...command.split(/\s+/).filter(Boolean)];
|
|
221
|
+
}
|
|
@@ -61,7 +61,7 @@ export function setDevReloadClientActive(active: boolean): void {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export class DevReloadMiddleware extends BaseMiddleware {
|
|
64
|
-
protected options:
|
|
64
|
+
protected options: Record<string, never> = {};
|
|
65
65
|
|
|
66
66
|
async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
|
|
67
67
|
const res = await next();
|