@zerotal/core 1.6.1 → 1.6.3
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
CHANGED
|
@@ -8,7 +8,51 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
-
## [1.6.
|
|
11
|
+
## [1.6.3] — 2026-08-15
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`serve --dev` says when the framework on disk is no longer the framework running.** A
|
|
16
|
+
running dev server holds the code it imported at boot: `bun add zerotal@latest` in another
|
|
17
|
+
terminal rewrites `node_modules` and nothing else, and a save restarts only the _worker_,
|
|
18
|
+
which re-executes your app against that same in-memory framework. So an upgrade taken
|
|
19
|
+
mid-session appears to do nothing — the fix is installed, the symptom persists, and the
|
|
20
|
+
reasonable conclusion is that the fix does not work.
|
|
21
|
+
|
|
22
|
+
The supervisor now compares the version it booted with against the one installed, on each
|
|
23
|
+
restart, and says which is which:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
[zerotal:dev] ⚠ framework upgraded on disk: running v1.6.2, installed v1.6.3
|
|
27
|
+
[zerotal:dev] restart the dev server to pick it up (a save will not).
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Once per version, so a long session is not nagged, and silent where there is nothing to read
|
|
31
|
+
— a workspace checkout or a hoisted layout is not a finding.
|
|
32
|
+
|
|
33
|
+
- **The dev banner carries the version** — `Zerotal v1.6.3 › dev`. There was previously
|
|
34
|
+
nothing on screen naming the framework a running server was actually executing.
|
|
35
|
+
|
|
36
|
+
## [1.6.2] — 2026-08-15
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **`serve --dev` killed its worker outright on Windows instead of stopping it.** A restart
|
|
41
|
+
sent `SIGTERM`, but Windows has no POSIX signals: there that call is `TerminateProcess`, so
|
|
42
|
+
the worker died mid-instruction. No provider ran `onStopping`, no open response was
|
|
43
|
+
finished, no database handle was closed — on every save, since that is how the dev server
|
|
44
|
+
reloads. The visible symptom was a console full of `ERR_INCOMPLETE_CHUNKED_ENCODING` from
|
|
45
|
+
the devtools event stream, which is just what a chunked response looks like when the
|
|
46
|
+
process writing it stops existing.
|
|
47
|
+
|
|
48
|
+
The supervisor now **asks** over an IPC channel and kills only if the request goes
|
|
49
|
+
unanswered within a second. POSIX behaviour is unchanged in substance — the worker runs the
|
|
50
|
+
same `stop()` either way — and Windows gets the orderly shutdown it never had. Verified
|
|
51
|
+
across a real hot restart: before, an open stream ended in a connection reset with the
|
|
52
|
+
worker never draining; after, the stream ends cleanly and the worker logs its stop.
|
|
53
|
+
|
|
54
|
+
A supervisor that cannot open a channel falls straight through to the signal path, so
|
|
55
|
+
nothing waits out the grace period for a chance it never had.
|
|
12
56
|
|
|
13
57
|
### Added
|
|
14
58
|
|
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import { ServiceProvider } from "../provider/ServiceProvider.ts";
|
|
|
9
9
|
import type { AuthenticatedUser } from "../auth/AuthenticatedUser.ts";
|
|
10
10
|
import * as DevWsServer from "../dev/DevWsServer.ts";
|
|
11
11
|
import { DevReloadMiddleware, setDevReloadClientActive } from "../dev/DevReloadMiddleware.ts";
|
|
12
|
+
import { onGracefulStopRequest } from "../dev/devShutdown.ts";
|
|
12
13
|
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
13
14
|
import { Pipeline } from "../pipeline/Pipeline.ts";
|
|
14
15
|
import { ExceptionHandler } from "./ExceptionHandler.ts";
|
|
@@ -1520,6 +1521,11 @@ export class Application {
|
|
|
1520
1521
|
process.on(signal, () => void this.stop());
|
|
1521
1522
|
}
|
|
1522
1523
|
|
|
1524
|
+
// Windows cannot deliver either of those to a child, so `serve --dev` asks
|
|
1525
|
+
// over IPC instead and this is the ear for it. Silent anywhere there is no
|
|
1526
|
+
// supervisor on the other end.
|
|
1527
|
+
onGracefulStopRequest(() => void this.stop());
|
|
1528
|
+
|
|
1523
1529
|
process.on("SIGUSR2", () => void this._reloadRoutes());
|
|
1524
1530
|
}
|
|
1525
1531
|
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type PortOwner,
|
|
25
25
|
} from "../../support/port.ts";
|
|
26
26
|
import { localNetworkAddress } from "../../support/network.ts";
|
|
27
|
+
import { ZEROTAL_VERSION } from "../../support/version.ts";
|
|
27
28
|
import { createInterface } from "node:readline";
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -319,7 +320,11 @@ export class ServeCommand extends Command {
|
|
|
319
320
|
const network = localNetworkAddress();
|
|
320
321
|
|
|
321
322
|
this.newLine();
|
|
322
|
-
|
|
323
|
+
// The version is here because a running server holds the framework it booted
|
|
324
|
+
// with: an upgrade installed in another terminal is invisible until a full
|
|
325
|
+
// restart, and without this line there is nothing on screen to tell you which
|
|
326
|
+
// one you are looking at.
|
|
327
|
+
this.line(` Zerotal v${ZEROTAL_VERSION} › ${mode}`);
|
|
323
328
|
this.newLine();
|
|
324
329
|
this._route("Local", `http://localhost:${port}`);
|
|
325
330
|
this._route(
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
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
|
+
import { requestGracefulStop } from "./devShutdown.ts";
|
|
9
|
+
import { ZEROTAL_VERSION, installedCoreVersion } from "../support/version.ts";
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* How the orchestrator reports to whatever is presenting dev mode.
|
|
@@ -80,6 +82,8 @@ export class DevOrchestrator {
|
|
|
80
82
|
* queues a single follow-up instead of running in parallel.
|
|
81
83
|
*/
|
|
82
84
|
private _restartInFlight: Promise<void> | null = null;
|
|
85
|
+
/** The installed version already warned about, so one upgrade warns once. */
|
|
86
|
+
private _warnedVersion: string | null = null;
|
|
83
87
|
private _restartQueued = false;
|
|
84
88
|
|
|
85
89
|
/**
|
|
@@ -156,6 +160,9 @@ export class DevOrchestrator {
|
|
|
156
160
|
stdin: "pipe",
|
|
157
161
|
stdout: routed ? "pipe" : "inherit",
|
|
158
162
|
stderr: routed ? "pipe" : "inherit",
|
|
163
|
+
// Opens the channel `_stopChild` asks over. Nothing is sent the other
|
|
164
|
+
// way, but Bun only gives the parent a `send` when a handler is present.
|
|
165
|
+
ipc: () => {},
|
|
159
166
|
cwd: this._cwd,
|
|
160
167
|
env: {
|
|
161
168
|
...Bun.env,
|
|
@@ -278,17 +285,48 @@ export class DevOrchestrator {
|
|
|
278
285
|
// browser refetches the updated CSS.
|
|
279
286
|
await this._runBuild();
|
|
280
287
|
|
|
288
|
+
this._warnIfFrameworkUpgradedUnderneath();
|
|
289
|
+
|
|
281
290
|
// Stop before spawning, always: the old server owns the port until it exits.
|
|
282
291
|
await this._stopChild();
|
|
283
292
|
await this._spawnServerWithRetry();
|
|
284
293
|
}
|
|
285
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Say so when the framework on disk is no longer the framework running.
|
|
297
|
+
*
|
|
298
|
+
* A `bun add zerotal@latest` in another terminal changes `node_modules` and
|
|
299
|
+
* nothing else: this process holds the code it imported at boot, and a restart
|
|
300
|
+
* only re-executes the *app* against that same in-memory framework. So the
|
|
301
|
+
* upgrade appears to have no effect — the fix is installed, the symptom
|
|
302
|
+
* persists, and the obvious conclusion is that the fix does not work.
|
|
303
|
+
*
|
|
304
|
+
* Checked on restart rather than at boot, because that is when it can have
|
|
305
|
+
* become true, and warned once per version so a long session is not nagged.
|
|
306
|
+
*/
|
|
307
|
+
private _warnIfFrameworkUpgradedUnderneath(): void {
|
|
308
|
+
const onDisk = installedCoreVersion(this._cwd);
|
|
309
|
+
if (onDisk === null || onDisk === ZEROTAL_VERSION || onDisk === this._warnedVersion) return;
|
|
310
|
+
|
|
311
|
+
this._warnedVersion = onDisk;
|
|
312
|
+
this._say(
|
|
313
|
+
` [zerotal:dev] ⚠ framework upgraded on disk: running v${ZEROTAL_VERSION}, installed v${onDisk}`,
|
|
314
|
+
"warn",
|
|
315
|
+
);
|
|
316
|
+
this._say(" [zerotal:dev] restart the dev server to pick it up (a save will not).", "warn");
|
|
317
|
+
}
|
|
318
|
+
|
|
286
319
|
/** Stop the running server and wait for it to actually exit, releasing the port. */
|
|
287
320
|
private async _stopChild(): Promise<void> {
|
|
288
321
|
const child = this._child;
|
|
289
322
|
this._child = null;
|
|
290
323
|
if (!child) return;
|
|
291
324
|
|
|
325
|
+
// Ask first. On Windows a signal cannot reach the worker at all — it is
|
|
326
|
+
// terminated where it stands, with open responses left half-written — and
|
|
327
|
+
// asking is the only way it ever drains its providers. See devShutdown.ts.
|
|
328
|
+
if (await requestGracefulStop(child)) return;
|
|
329
|
+
|
|
292
330
|
child.kill("SIGTERM");
|
|
293
331
|
const forceKill = setTimeout(() => child.kill("SIGKILL"), 1_500);
|
|
294
332
|
try {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking a dev worker to stop, on a platform where a signal cannot.
|
|
3
|
+
*
|
|
4
|
+
* Windows has no POSIX signals. `child.kill("SIGTERM")` there is
|
|
5
|
+
* `TerminateProcess`: the worker dies mid-instruction, so no provider drains, no
|
|
6
|
+
* open response is finished, and no database handle is closed — on every save,
|
|
7
|
+
* because that is how `serve --dev` restarts. The visible symptom is a browser
|
|
8
|
+
* console full of `ERR_INCOMPLETE_CHUNKED_ENCODING` from the devtools event
|
|
9
|
+
* stream, which is simply what a chunked response looks like when the process
|
|
10
|
+
* writing it stops existing.
|
|
11
|
+
*
|
|
12
|
+
* The supervisor therefore asks over the IPC channel first and kills only when
|
|
13
|
+
* the request goes unanswered. POSIX behaviour is unchanged in substance — the
|
|
14
|
+
* worker runs the same {@link Application.stop} either way — and Windows gains
|
|
15
|
+
* the orderly shutdown it never had.
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The message a supervisor sends to ask a worker to shut itself down. */
|
|
21
|
+
export const DEV_SHUTDOWN_MESSAGE = "zerotal:dev:shutdown";
|
|
22
|
+
|
|
23
|
+
/** How long a supervisor waits for the worker to go on its own, in ms. */
|
|
24
|
+
export const DEV_SHUTDOWN_GRACE_MS = 1_000;
|
|
25
|
+
|
|
26
|
+
/** The part of a spawned child this module needs. */
|
|
27
|
+
export interface StoppableChild {
|
|
28
|
+
send?: (message: unknown) => void;
|
|
29
|
+
exited: Promise<number>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Ask a child to shut itself down, and wait for it to actually go.
|
|
34
|
+
*
|
|
35
|
+
* @param child - The spawned worker. Must have been spawned with an `ipc`
|
|
36
|
+
* handler, or there is no channel to ask over and this returns `false`.
|
|
37
|
+
* @param graceMs - How long to wait before giving up on the request.
|
|
38
|
+
* @returns `true` if the child exited on its own, `false` if the caller still
|
|
39
|
+
* has to kill it. Never throws: a dead channel is a `false`, not an error,
|
|
40
|
+
* because the caller's next move is the same either way.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* if (!(await requestGracefulStop(child))) child.kill("SIGTERM");
|
|
44
|
+
*/
|
|
45
|
+
export async function requestGracefulStop(
|
|
46
|
+
child: StoppableChild,
|
|
47
|
+
graceMs: number = DEV_SHUTDOWN_GRACE_MS,
|
|
48
|
+
): Promise<boolean> {
|
|
49
|
+
if (typeof child.send !== "function") return false;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
child.send(DEV_SHUTDOWN_MESSAGE);
|
|
53
|
+
} catch {
|
|
54
|
+
// The channel is already gone, which means so is the chance of a polite
|
|
55
|
+
// exit. The caller kills.
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
60
|
+
try {
|
|
61
|
+
const outcome = await Promise.race([
|
|
62
|
+
child.exited.then(() => "exited" as const),
|
|
63
|
+
new Promise<"timeout">((resolve) => {
|
|
64
|
+
timer = setTimeout(() => resolve("timeout"), graceMs);
|
|
65
|
+
}),
|
|
66
|
+
]);
|
|
67
|
+
return outcome === "exited";
|
|
68
|
+
} catch {
|
|
69
|
+
// `exited` rejecting means the child is gone by some other route.
|
|
70
|
+
return false;
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Run `stop` when a supervisor asks this process to shut down.
|
|
78
|
+
*
|
|
79
|
+
* Installed by {@link Application} alongside its signal handlers. Harmless in a
|
|
80
|
+
* process with no IPC channel — the message never arrives — so it needs no
|
|
81
|
+
* environment check to stay out of production's way.
|
|
82
|
+
*
|
|
83
|
+
* @param stop - What to run. Called at most once per request.
|
|
84
|
+
*/
|
|
85
|
+
export function onGracefulStopRequest(stop: () => void): void {
|
|
86
|
+
process.on("message", (message: unknown) => {
|
|
87
|
+
if (message === DEV_SHUTDOWN_MESSAGE) stop();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework version this build of `@zerotal/core` is.
|
|
3
|
+
*
|
|
4
|
+
* Read from the manifest rather than written down, for the reason `@zerotal/monitor`
|
|
5
|
+
* already learned: a hardcoded version is correct until the next release, and three
|
|
6
|
+
* separate literals across the monorepo once claimed a version that had never been
|
|
7
|
+
* published. The monorepo releases in lockstep, so this package's manifest carries
|
|
8
|
+
* the framework version, and it ships inside the tarball.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
const pkg: { version: string } = JSON.parse(
|
|
15
|
+
readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
|
|
16
|
+
) as { version: string };
|
|
17
|
+
|
|
18
|
+
/** Full version of the running framework, e.g. `1.6.2`. */
|
|
19
|
+
export const ZEROTAL_VERSION: string = pkg.version;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The version of `@zerotal/core` currently installed in a project, which is not
|
|
23
|
+
* necessarily {@link ZEROTAL_VERSION} — a long-running process holds the code it
|
|
24
|
+
* booted with, so an upgrade lands on disk without reaching it.
|
|
25
|
+
*
|
|
26
|
+
* @param cwd - Project root to look under.
|
|
27
|
+
* @returns The installed version, or `null` when there is nothing to read — a
|
|
28
|
+
* workspace checkout, a hoisted layout, a partial install. Absence is not a
|
|
29
|
+
* finding, so callers stay quiet on `null` rather than guessing.
|
|
30
|
+
*/
|
|
31
|
+
export function installedCoreVersion(cwd: string): string | null {
|
|
32
|
+
try {
|
|
33
|
+
const raw = readFileSync(`${cwd}/node_modules/@zerotal/core/package.json`, "utf8");
|
|
34
|
+
const { version } = JSON.parse(raw) as { version?: string };
|
|
35
|
+
return version ?? null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|