@zerotal/core 1.0.3 → 1.1.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 CHANGED
@@ -1,4 +1,4 @@
1
- # Changelog — @zerotal/core
1
+ # Changelog @zerotal/core
2
2
 
3
3
  All notable changes to this package are documented here. The format is
4
4
  based on [Keep a Changelog](https://keepachangelog.com/); this package
@@ -8,6 +8,19 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.1.0] — 2026-08-08
12
+
13
+ ### Fixed
14
+
15
+ - **`bun zt test` no longer fails a cold suite on Bun's 5-second hook timeout.** A `beforeAll` that calls `createTestApp()` boots providers and runs migrations, which exceeds 5s on a cold start; `bunfig.toml`'s `[test] timeout` does not cover hooks, only the CLI flag does. The failure appeared on the first run and not the second, which reads as flakiness and sends you looking for a race that is not there. The default is now 30s, still overridable with `--timeout`.
16
+ - **`serve --dev` no longer loses its server to a restart race.** The debounce timer spaced out the *scheduling* of a restart, but its callback cleared the timer and then awaited a rebuild that can take seconds — so a change arriving in that window scheduled a second callback which ran concurrently. Both reached the spawn, two servers raced for the port, and the loser died with "Failed to start server. Is port 3000 in use?". Dev mode was then left owning no server while the winner kept serving stale code, so every later save appeared to do nothing. Restarts are now serialized: a request arriving mid-restart queues exactly one follow-up instead of running in parallel. A failed bind also retries briefly, since the OS releases a listening socket asynchronously after the previous owner exits, and the initial spawn uses the same path — an orphan from a previous run is the commonest reason the first bind fails. An unexpected exit is reported against the child that actually exited rather than whichever child the field happened to hold.
17
+
18
+ ### Added
19
+
20
+ - `ctx.param(name, fallback?)` — the single-value route-parameter accessor, matching `string()` and `header()` in shape. Only the `params` record existed, though every other single-value read on the object is a method. Unlike `string()` it does not fall back to the query string: a route parameter either matched or it did not.
21
+ - `URLSigner` is exported from `@zerotal/core/security`. It carried a full documented example but was reachable only through a deep internal path, which is a worse dependency to take than reimplementing it. (`Url` / `url` were already exported — from the **http** subpath, not security; the docblock now says so.)
22
+ - `app.assets.loader` — per-extension bundler loader overrides, e.g. `{ ".woff2": "file" }`. Bun inlines small `url()` assets as data URIs, which is right for an icon and wrong for a font: the bytes move into the render-blocking stylesheet, so nine woff2 subsets turned a 36 KB stylesheet into 260 KB that had to download before first paint. There was no configuration to turn it off.
23
+
11
24
  ## [1.0.3] — 2026-08-07
12
25
 
13
26
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -28,6 +28,15 @@ export class TestCommand extends Command {
28
28
  static description = "Run the test suite in test environment";
29
29
  static needsApp = false;
30
30
 
31
+ /**
32
+ * Default per-test and per-hook timeout, in milliseconds.
33
+ *
34
+ * Generous on purpose: the expensive thing in a Zerotal suite is booting the app in a
35
+ * `beforeAll`, which Bun's 5s default does not allow for. Raising it costs nothing on a
36
+ * passing suite and removes a failure mode that looks exactly like flakiness.
37
+ */
38
+ static readonly DEFAULT_TIMEOUT_MS = 30_000;
39
+
31
40
  static override args = [{ name: "pattern", required: false, default: "" }];
32
41
 
33
42
  static override flags = [
@@ -47,7 +56,7 @@ export class TestCommand extends Command {
47
56
  {
48
57
  name: "timeout",
49
58
  type: "number" as const,
50
- description: "Per-test timeout in milliseconds",
59
+ description: `Per-test and per-hook timeout in milliseconds (default ${TestCommand.DEFAULT_TIMEOUT_MS})`,
51
60
  default: 0,
52
61
  },
53
62
  {
@@ -103,8 +112,14 @@ export class TestCommand extends Command {
103
112
  if (this.flags["watch"]) bunArguments.push("--watch");
104
113
  if (this.flags["bail"]) bunArguments.push("--bail");
105
114
 
106
- const timeout = this.flags["timeout"] as number | undefined;
107
- if (timeout && timeout > 0) bunArguments.push(`--timeout=${timeout}`);
115
+ // Bun's default is 5s and applies to hooks as well as tests — and `bunfig.toml`'s
116
+ // `[test] timeout` does not cover hooks, only this flag does. A `beforeAll` that calls
117
+ // `createTestApp()` boots providers and runs migrations, which on a cold start with a
118
+ // dozen providers exceeds 5s; the failure lands on the first run and not the second,
119
+ // so it reads as a flaky test and sends you hunting for a race that is not there.
120
+ // A framework's own test runner should account for that framework's boot cost.
121
+ const timeout = (this.flags["timeout"] as number | undefined) || TestCommand.DEFAULT_TIMEOUT_MS;
122
+ bunArguments.push(`--timeout=${timeout}`);
108
123
 
109
124
  this.dim(`APP_ENV=test ZT_DB_URL=${dbUrl}`);
110
125
  this.dim(`bun ${bunArguments.join(" ")}\n`);
@@ -66,8 +66,29 @@ export interface AppAssetsConfig {
66
66
  prefix: string;
67
67
  /** Minify output. Default: true in production, false otherwise. */
68
68
  minify: boolean;
69
+ /**
70
+ * Per-extension loader overrides handed to Bun's bundler, e.g.
71
+ * `{ ".woff2": "file" }`.
72
+ *
73
+ * Bun inlines small files a stylesheet `url()`s as data URIs. That is the right
74
+ * default for an icon, and the wrong one for a font: the bytes move into the
75
+ * render-blocking stylesheet, so nine woff2 subsets can turn a 36KB stylesheet into
76
+ * 260KB that must download before first paint — the opposite of what `font-display:
77
+ * swap` is for. `{ ".woff2": "file" }` emits them as separate files again.
78
+ *
79
+ * @example
80
+ * // config/app.ts
81
+ * assets: { entrypoint: 'resources/css/app.css', loader: { '.woff2': 'file', '.woff': 'file' } }
82
+ */
83
+ loader?: Record<string, AssetLoaderKind>;
69
84
  }
70
85
 
86
+ /**
87
+ * Bun bundler loaders that are meaningful for an asset referenced from CSS/JS.
88
+ * Mirrors Bun's `Loader` union, minus the source-code loaders a `url()` cannot name.
89
+ */
90
+ export type AssetLoaderKind = "file" | "dataurl" | "base64" | "text" | "json" | "toml";
91
+
71
92
  /**
72
93
  * Default request-body ceiling: 8 MiB.
73
94
  *
@@ -214,7 +235,13 @@ export function AppConfig(options: {
214
235
  cors?: Partial<AppCorsConfig>;
215
236
  throttle?: Partial<AppThrottleConfig>;
216
237
  secureHeaders?: Partial<AppSecureHeadersConfig>;
217
- assets?: { entrypoint: string | string[]; outDir?: string; prefix?: string; minify?: boolean };
238
+ assets?: {
239
+ entrypoint: string | string[];
240
+ outDir?: string;
241
+ prefix?: string;
242
+ minify?: boolean;
243
+ loader?: Record<string, AssetLoaderKind>;
244
+ };
218
245
  conventions?: { enabled?: boolean; paths?: Partial<ConventionsConfig["paths"]> };
219
246
  }): AppConfigShape {
220
247
  // Resolve env-derived defaults, then deep-merge the caller's overrides so partial nested
@@ -247,6 +274,9 @@ export function AppConfig(options: {
247
274
  outDir: options.assets.outDir ?? "public",
248
275
  prefix: options.assets.prefix ?? "/",
249
276
  minify: options.assets.minify ?? isProduction,
277
+ // Only set when declared: an explicit `undefined` is a distinct value under
278
+ // exactOptionalPropertyTypes, and would override Bun's own defaults with nothing.
279
+ ...(options.assets.loader ? { loader: options.assets.loader } : {}),
250
280
  };
251
281
  }
252
282
  return resolved;
@@ -34,6 +34,7 @@ export type {
34
34
  AppTlsConfig,
35
35
  ConventionsConfig,
36
36
  AppAssetsConfig,
37
+ AssetLoaderKind,
37
38
  } from "./AppConfig.ts";
38
39
  export type { ConfigRegistry, ConfigPath, ConfigValue } from "./registry.ts";
39
40
  export { ConfigValidationError } from "./validation.ts";
@@ -8,11 +8,18 @@ import { safeEqual, hmacHex } from "../support/crypto.ts";
8
8
  * the full URL (without the signature param) sorted by key so that the
9
9
  * order of other query parameters doesn't matter.
10
10
  *
11
- * For app-key-keyed signing without managing a secret, prefer the {@link Url}
12
- * facade (`Url.sign` / `Url.verify`), which derives the secret from `APP_KEY`.
11
+ * For app-key-keyed signing without managing a secret, prefer the `Url` facade
12
+ * (`Url.sign` / `Url.verify`), which derives the secret from `APP_KEY`. It is exported
13
+ * from the **http** subpath, not this one:
14
+ *
15
+ * ```ts
16
+ * import { Url } from "zerotal/http"; // or "@zerotal/core/http"
17
+ * ```
13
18
  *
14
19
  * @example
15
20
  * ```ts
21
+ * import { URLSigner } from "zerotal/security"; // or "@zerotal/core/security"
22
+ *
16
23
  * const signer = new URLSigner(process.env.APP_KEY!);
17
24
  *
18
25
  * // Generate a link that expires in 15 minutes:
@@ -45,6 +45,7 @@ export async function buildCssBundle(
45
45
  input: string,
46
46
  outdir: string,
47
47
  minify = false,
48
+ loader?: Record<string, string>,
48
49
  ): Promise<{ success: boolean; logs: unknown[] }> {
49
50
  const cwd = process.cwd();
50
51
  const plugins = await detectCssPlugins(cwd);
@@ -57,6 +58,9 @@ export async function buildCssBundle(
57
58
  target: "browser",
58
59
  minify,
59
60
  plugins,
61
+ ...(loader
62
+ ? { loader: loader as NonNullable<Parameters<typeof Bun.build>[0]["loader"]> }
63
+ : {}),
60
64
  });
61
65
  }
62
66
 
@@ -135,6 +139,8 @@ export interface AssetBuildConfig {
135
139
  outDir: string;
136
140
  prefix: string;
137
141
  minify: boolean;
142
+ /** Per-extension loader overrides (e.g. `{ ".woff2": "file" }`). See AppAssetsConfig. */
143
+ loader?: Record<string, string>;
138
144
  }
139
145
 
140
146
  /**
@@ -174,6 +180,11 @@ export async function buildConfiguredAssets(
174
180
  sourcemap: assets.minify ? "none" : "external",
175
181
  minify: assets.minify,
176
182
  plugins,
183
+ // `app.assets.loader` — e.g. `{ ".woff2": "file" }` to stop fonts being inlined
184
+ // as data URIs into the render-blocking stylesheet.
185
+ ...(assets.loader
186
+ ? { loader: assets.loader as NonNullable<Parameters<typeof Bun.build>[0]["loader"]> }
187
+ : {}),
177
188
  });
178
189
 
179
190
  if (result.success) await pruneBuildOutput(outdir, result.outputs);
@@ -45,6 +45,27 @@ export class DevOrchestrator {
45
45
  private _child: ReturnType<typeof Bun.spawn> | null = null;
46
46
  private _restartTimer: ReturnType<typeof setTimeout> | null = null;
47
47
  private _buildTimer: ReturnType<typeof setTimeout> | null = null;
48
+ /**
49
+ * Serializes restarts. The debounce timer only spaces out the *scheduling* of a
50
+ * restart — once its callback starts it clears the timer and then awaits a rebuild
51
+ * that can take seconds, during which another change schedules a second callback.
52
+ * Both then reached `_spawnServer()`, so two servers raced for the port, one lost
53
+ * with "Failed to start server. Is port 3000 in use?", and the dev server was left
54
+ * dead while the winner kept serving. A restart requested while one is running now
55
+ * queues a single follow-up instead of running in parallel.
56
+ */
57
+ private _restartInFlight: Promise<void> | null = null;
58
+ private _restartQueued = false;
59
+
60
+ /**
61
+ * Bounded respawn attempts. The race above is fixed at the source, but a port can
62
+ * still be briefly unavailable after the previous owner exits — the OS releases the
63
+ * listening socket asynchronously — so a lost bind retries rather than killing dev mode.
64
+ */
65
+ private static readonly RESPAWN_ATTEMPTS = 3;
66
+ private static readonly RESPAWN_DELAY_MS = 300;
67
+ /** A server that binds stays up; one that loses the port exits almost immediately. */
68
+ private static readonly BIND_SETTLE_MS = 400;
48
69
  /** Per-build asset-version token — bumped on each build, busts `asset()` `?v=` URLs. */
49
70
  private _assetVersion = Date.now().toString(36);
50
71
 
@@ -61,7 +82,9 @@ export class DevOrchestrator {
61
82
  console.warn(" [zerotal:dev] ⚠ initial build failed — starting server anyway");
62
83
  }
63
84
 
64
- this._spawnServer();
85
+ // Same retry as a restart: the commonest reason the first bind fails is an orphaned
86
+ // server from a previous run that has not finished exiting.
87
+ await this._spawnServerWithRetry();
65
88
  this._watch();
66
89
 
67
90
  // Park the process — cleanup happens in signal handlers registered by _watch()
@@ -70,8 +93,8 @@ export class DevOrchestrator {
70
93
 
71
94
  // ── Server management ──────────────────────────────────────────────────────
72
95
 
73
- private _spawnServer(): void {
74
- this._child = Bun.spawn(
96
+ private _spawnServer(): ReturnType<typeof Bun.spawn> {
97
+ const child = Bun.spawn(
75
98
  ["bun", Bun.main, "serve", "--port", String(this._port), "--dev-worker"],
76
99
  {
77
100
  stdin: "pipe",
@@ -91,40 +114,122 @@ export class DevOrchestrator {
91
114
  },
92
115
  );
93
116
 
94
- this._child.exited.then((code) => {
95
- // Ignore expected exits (restart in progress or clean shutdown)
96
- if (this._child && this._restartTimer === null && code !== 0) {
117
+ this._child = child;
118
+
119
+ void child.exited.then((code) => {
120
+ // Report only the *current* server dying unexpectedly. Comparing against
121
+ // `this._child` identity matters: reading the field alone reported an exit that a
122
+ // restart had deliberately caused, because by then the field held the replacement.
123
+ // A restart in flight owns its own reporting.
124
+ if (this._child === child && this._restartInFlight === null && code !== 0) {
97
125
  console.log(` [zerotal:dev] server exited with code ${code}`);
98
126
  }
99
127
  });
128
+
129
+ return child;
100
130
  }
101
131
 
102
132
  private _scheduleRestart(): void {
103
133
  if (this._restartTimer) clearTimeout(this._restartTimer);
104
- this._restartTimer = setTimeout(async () => {
134
+ this._restartTimer = setTimeout(() => {
105
135
  this._restartTimer = null;
106
- console.log(" [zerotal:dev] ↻ backend change — rebuilding + restarting server...");
136
+ void this._requestRestart();
137
+ }, 150);
138
+ }
107
139
 
108
- // Rebuild assets before respawning: server-rendered views (Flow pages in `app/`,
109
- // controllers returning markup) contain Tailwind classes the stylesheet scans via
110
- // `@source`, so a backend edit can introduce new classes. Without this, a new utility
111
- // used in a page wouldn't appear until an unrelated `resources/` file changed. The
112
- // fresh `_assetVersion` (bumped by _runBuild) is passed to the respawned worker, so the
113
- // browser refetches the updated CSS.
114
- await this._runBuild();
140
+ /**
141
+ * Run a restart, or fold this request into the one already running.
142
+ *
143
+ * Restarts must not overlap: each one kills the current server and binds a new one to
144
+ * the same port, so two in flight means two servers competing for it. A request that
145
+ * arrives mid-restart sets a flag instead, and exactly one more restart runs when the
146
+ * current one finishes — enough to pick up whatever changed, without a queue that
147
+ * grows one entry per keystroke.
148
+ */
149
+ private _requestRestart(): Promise<void> {
150
+ if (this._restartInFlight) {
151
+ this._restartQueued = true;
152
+ return this._restartInFlight;
153
+ }
115
154
 
116
- const previousChild = this._child;
117
- this._child = null;
155
+ const run = async (): Promise<void> => {
156
+ try {
157
+ do {
158
+ this._restartQueued = false;
159
+ await this._restartOnce();
160
+ } while (this._restartQueued);
161
+ } finally {
162
+ this._restartInFlight = null;
163
+ }
164
+ };
165
+
166
+ this._restartInFlight = run();
167
+ return this._restartInFlight;
168
+ }
118
169
 
119
- if (previousChild) {
120
- previousChild.kill("SIGTERM");
121
- const forceKill = setTimeout(() => previousChild.kill("SIGKILL"), 1_500);
122
- await previousChild.exited;
123
- clearTimeout(forceKill);
170
+ private async _restartOnce(): Promise<void> {
171
+ console.log(" [zerotal:dev] ↻ backend change — rebuilding + restarting server...");
172
+
173
+ // Rebuild assets before respawning: server-rendered views (Flow pages in `app/`,
174
+ // controllers returning markup) contain Tailwind classes the stylesheet scans via
175
+ // `@source`, so a backend edit can introduce new classes. Without this, a new utility
176
+ // used in a page wouldn't appear until an unrelated `resources/` file changed. The
177
+ // fresh `_assetVersion` (bumped by _runBuild) is passed to the respawned worker, so the
178
+ // browser refetches the updated CSS.
179
+ await this._runBuild();
180
+
181
+ // Stop before spawning, always: the old server owns the port until it exits.
182
+ await this._stopChild();
183
+ await this._spawnServerWithRetry();
184
+ }
185
+
186
+ /** Stop the running server and wait for it to actually exit, releasing the port. */
187
+ private async _stopChild(): Promise<void> {
188
+ const child = this._child;
189
+ this._child = null;
190
+ if (!child) return;
191
+
192
+ child.kill("SIGTERM");
193
+ const forceKill = setTimeout(() => child.kill("SIGKILL"), 1_500);
194
+ try {
195
+ await child.exited;
196
+ } finally {
197
+ clearTimeout(forceKill);
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Spawn the server, retrying briefly if it dies on startup.
203
+ *
204
+ * A bind failure is indistinguishable from an application crash by exit code alone, so
205
+ * this retries either — a crash simply fails the remaining attempts quickly and reports.
206
+ * Leaving dev mode with no server and no explanation is the worse outcome: the watcher
207
+ * stays alive, so the next save appears to do nothing while the browser talks to
208
+ * whatever is still listening.
209
+ */
210
+ private async _spawnServerWithRetry(): Promise<void> {
211
+ for (let attempt = 1; attempt <= DevOrchestrator.RESPAWN_ATTEMPTS; attempt++) {
212
+ const child = this._spawnServer();
213
+
214
+ const settled = await Promise.race([
215
+ child.exited.then(() => true),
216
+ Bun.sleep(DevOrchestrator.BIND_SETTLE_MS).then(() => false),
217
+ ]);
218
+
219
+ if (!settled) return; // still running → it bound the port
220
+ if (this._child !== child) return; // superseded by a newer restart
221
+
222
+ this._child = null;
223
+ if (attempt < DevOrchestrator.RESPAWN_ATTEMPTS) {
224
+ await Bun.sleep(DevOrchestrator.RESPAWN_DELAY_MS);
124
225
  }
226
+ }
125
227
 
126
- this._spawnServer();
127
- }, 150);
228
+ console.error(
229
+ ` [zerotal:dev] ✗ server did not start after ${DevOrchestrator.RESPAWN_ATTEMPTS} attempts.\n` +
230
+ ` Port ${this._port} may be held by another process — the error above says which.\n` +
231
+ ` Dev mode is still watching; fix the cause and save to retry.`,
232
+ );
128
233
  }
129
234
 
130
235
  // ── Build management ───────────────────────────────────────────────────────
@@ -550,6 +550,29 @@ export class HttpContext<TParams extends Record<string, unknown> = Record<string
550
550
  return (this.params[key] as string | undefined) ?? this.url.searchParams.get(key) ?? fallback;
551
551
  }
552
552
 
553
+ /**
554
+ * Read a single route parameter.
555
+ *
556
+ * `params` is the whole record; this is the one-value accessor that matches
557
+ * {@link HttpContext.string} and {@link HttpContext.header} in shape. Every other
558
+ * single-value read on this object is a method, so reaching for `param('token')` and
559
+ * finding only `params['token']` is a needless inconsistency — even caught at compile
560
+ * time, it is one more thing to remember for no benefit.
561
+ *
562
+ * Unlike {@link HttpContext.string} this does **not** fall back to the query string: a
563
+ * route parameter either matched or it did not, and quietly answering with a
564
+ * user-supplied query value would let `?id=1` stand in for a path segment.
565
+ *
566
+ * @example
567
+ * const token = ctx.param('token');
568
+ * const page = ctx.param('page', '1');
569
+ *
570
+ * @category Request data
571
+ */
572
+ param(key: string, fallback?: string): string | undefined {
573
+ return (this.params[key] as string | undefined) ?? fallback;
574
+ }
575
+
553
576
  /**
554
577
  * Read a query param or route param coerced to a boolean.
555
578
  * Truthy values: `'1'`, `'true'`, `'yes'`, `'on'` (case-insensitive).
@@ -20,3 +20,9 @@
20
20
  export { Crypt, CryptKeyMissingError, DecryptionError } from "../crypt/Crypt.ts";
21
21
  export { Hash } from "../hash/Hash.ts";
22
22
  export type { HashAlgorithm } from "../hash/Hash.ts";
23
+ // Signed URLs with a secret you manage yourself. Documented with a full example on the
24
+ // class, but previously reachable only through a deep internal path — which is a worse
25
+ // dependency to take than reimplementing it, so people reimplemented it. For app-key-keyed
26
+ // signing with no secret to manage, `Url.sign` / `Url.verify` on the http subpath is the
27
+ // higher-level option.
28
+ export { URLSigner } from "../crypt/URLSigner.ts";