@zerotal/core 1.6.0 → 1.6.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/CHANGELOG.md CHANGED
@@ -8,6 +8,38 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.6.2] — 2026-08-15
12
+
13
+ ### Fixed
14
+
15
+ - **`serve --dev` killed its worker outright on Windows instead of stopping it.** A restart
16
+ sent `SIGTERM`, but Windows has no POSIX signals: there that call is `TerminateProcess`, so
17
+ the worker died mid-instruction. No provider ran `onStopping`, no open response was
18
+ finished, no database handle was closed — on every save, since that is how the dev server
19
+ reloads. The visible symptom was a console full of `ERR_INCOMPLETE_CHUNKED_ENCODING` from
20
+ the devtools event stream, which is just what a chunked response looks like when the
21
+ process writing it stops existing.
22
+
23
+ The supervisor now **asks** over an IPC channel and kills only if the request goes
24
+ unanswered within a second. POSIX behaviour is unchanged in substance — the worker runs the
25
+ same `stop()` either way — and Windows gets the orderly shutdown it never had. Verified
26
+ across a real hot restart: before, an open stream ended in a connection reset with the
27
+ worker never draining; after, the stream ends cleanly and the worker logs its stop.
28
+
29
+ A supervisor that cannot open a channel falls straight through to the signal path, so
30
+ nothing waits out the grace period for a chance it never had.
31
+
32
+ ### Added
33
+
34
+ - **`import.meta.env` is defined for bundled browser builds.** `DEV`, `PROD` and `MODE`,
35
+ set from whether the build is a production one. It is a Vite convention rather than a web
36
+ standard — `import.meta.env` is undefined in a browser module — but enough of the npm
37
+ ecosystem branches on it that a bundler leaving it alone ships code reading a property off
38
+ `undefined`. Bun leaves it alone, so Zerotal defines it.
39
+
40
+ `BASE_URL` and `SSR` are deliberately omitted: Zerotal has its own answers for both, and a
41
+ wrong value is worse than an absent one for a package that feature-detects.
42
+
11
43
  ## [1.6.0] — 2026-08-15
12
44
 
13
45
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -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
 
@@ -5,6 +5,7 @@
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";
8
9
 
9
10
  /**
10
11
  * How the orchestrator reports to whatever is presenting dev mode.
@@ -156,6 +157,9 @@ export class DevOrchestrator {
156
157
  stdin: "pipe",
157
158
  stdout: routed ? "pipe" : "inherit",
158
159
  stderr: routed ? "pipe" : "inherit",
160
+ // Opens the channel `_stopChild` asks over. Nothing is sent the other
161
+ // way, but Bun only gives the parent a `send` when a handler is present.
162
+ ipc: () => {},
159
163
  cwd: this._cwd,
160
164
  env: {
161
165
  ...Bun.env,
@@ -289,6 +293,11 @@ export class DevOrchestrator {
289
293
  this._child = null;
290
294
  if (!child) return;
291
295
 
296
+ // Ask first. On Windows a signal cannot reach the worker at all — it is
297
+ // terminated where it stands, with open responses left half-written — and
298
+ // asking is the only way it ever drains its providers. See devShutdown.ts.
299
+ if (await requestGracefulStop(child)) return;
300
+
292
301
  child.kill("SIGTERM");
293
302
  const forceKill = setTimeout(() => child.kill("SIGKILL"), 1_500);
294
303
  try {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The `import.meta.env` members a bundled browser build is expected to carry.
3
+ *
4
+ * These are a Vite convention, not a web standard — `import.meta.env` is
5
+ * undefined in a browser module — but enough of the npm ecosystem branches on
6
+ * them that a bundler which leaves them alone ships code that reads a property
7
+ * off `undefined`. Bun's bundler leaves them alone, so we define them.
8
+ *
9
+ * The case that found this: the Inertia DevTools browser extension relies on
10
+ * client-side hooks that `createInertiaApp()` enables from its `dev` option,
11
+ * and that option's default is literally `import.meta.env.DEV`. Under Bun that
12
+ * expression survived into the bundle, so the panel reported the app was "not
13
+ * running in dev mode" and told the developer to start a Vite server they do
14
+ * not have — advice that cannot be followed in a Zerotal app.
15
+ *
16
+ * Only the three members whose meaning is unambiguous are defined. `BASE_URL`
17
+ * and `SSR` are deliberately omitted: Zerotal has its own answers for both
18
+ * (`app.assets.prefix`, and SSR being a server concern), and a wrong value is
19
+ * worse than an absent one for a package that feature-detects.
20
+ *
21
+ * @param isProduction Whether this build is for a deployment.
22
+ * @internal
23
+ */
24
+ export function browserEnvDefines(isProduction: boolean): Record<string, string> {
25
+ return {
26
+ // The WHOLE object, not `import.meta.env.DEV` member by member. Adapters write
27
+ // `import.meta.env?.DEV` — optional-chained, which is why an unbundled
28
+ // `import.meta.env` yields `false` instead of throwing — and a define keyed on
29
+ // `import.meta.env.DEV` does not match `import.meta.env?.DEV`, so the member
30
+ // form silently changes nothing. Replacing the object satisfies both spellings.
31
+ //
32
+ // Members this does not name resolve to `undefined` rather than throwing, which
33
+ // is the right answer for a bundler that is not Vite: code that feature-detects
34
+ // `import.meta.env.SSR` or a `VITE_*` variable gets a clean miss.
35
+ "import.meta.env": JSON.stringify({
36
+ DEV: !isProduction,
37
+ PROD: isProduction,
38
+ MODE: isProduction ? "production" : "development",
39
+ }),
40
+ };
41
+ }
@@ -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
+ }
package/src/dev/index.ts CHANGED
@@ -30,6 +30,7 @@ export type { BuildHookFn, BuildResult } from "./DevBuildHook.ts";
30
30
  export { DevReloadMiddleware, registerDevHtmlSnippet } from "./DevReloadMiddleware.ts";
31
31
  export type { DevHtmlSnippet } from "./DevReloadMiddleware.ts";
32
32
  export { DEV_RELOAD_CLIENT } from "./reloadClient.ts";
33
+ export { browserEnvDefines } from "./buildEnv.ts";
33
34
  export { detectCssPlugins, buildCssBundle, buildJsBundle } from "./CssPlugins.ts";
34
35
  export type { AssetBuildConfig } from "./CssPlugins.ts";
35
36
  export { pruneBuildOutput } from "./BuildOutput.ts";