@zerotal/core 1.6.3 → 1.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.6.3",
3
+ "version": "1.7.2",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -11,6 +11,7 @@
11
11
  ".": "./src/index.ts",
12
12
  "./routes": "./src/router/routes.ts",
13
13
  "./contracts": "./src/contracts/index.ts",
14
+ "./errors": "./src/errors/index.ts",
14
15
  "./lock": "./src/lock/index.ts",
15
16
  "./logger": "./src/logger/index.ts",
16
17
  "./commands": "./src/command/builtin/index.ts",
@@ -34,6 +35,7 @@
34
35
  },
35
36
  "files": [
36
37
  "CHANGELOG.md",
38
+ "api-surface.md",
37
39
  "src",
38
40
  "!src/**/*.test.ts",
39
41
  "!src/**/*.test.tsx",
@@ -14,6 +14,7 @@ import type { HttpContext } from "../pipeline/HttpContext.ts";
14
14
  import { Pipeline } from "../pipeline/Pipeline.ts";
15
15
  import { ExceptionHandler } from "./ExceptionHandler.ts";
16
16
  import { Router, RouterState } from "../router/Router.ts";
17
+ import { defineRouteMethods, defineRoutes } from "../router/routes.ts";
17
18
  import type { StaticOptions } from "../router/Router.ts";
18
19
  import { Health, resolveHealthConfig, checkHealthAccess } from "../health/Health.ts";
19
20
  import type { HealthConfigShape } from "../health/Health.ts";
@@ -27,7 +28,10 @@ import {
27
28
  import { builtinConcerns } from "../conventions/builtinConcerns.ts";
28
29
  import { ConfigManager } from "../config/ConfigManager.ts";
29
30
  import { DEFAULT_MAX_REQUEST_BODY_SIZE } from "../config/AppConfig.ts";
30
- import { SecureHeadersMiddleware } from "../middleware/SecureHeadersMiddleware.ts";
31
+ import {
32
+ SecureHeadersMiddleware,
33
+ staticSecurityHeaders,
34
+ } from "../middleware/SecureHeadersMiddleware.ts";
31
35
  import { isAllowedOrigin, allowedOriginsFrom } from "../http/originGuard.ts";
32
36
  import { rescueSync } from "../helpers/index.ts";
33
37
  import { configureAssets, setAssetVersion, assetVersion } from "../assets/assets.ts";
@@ -39,7 +43,7 @@ import { NotFoundError } from "../errors/HttpError.ts";
39
43
  import type { ContainerBindings } from "../container/types.ts";
40
44
  import { dispatchRequest } from "../router/RouteHandler.ts";
41
45
  import type { ProviderHooks } from "../router/RouteHandler.ts";
42
- import { isProdLike, deployEnv } from "../support/env.ts";
46
+ import { isProdLike, deployEnv, runtimeMode } from "../support/env.ts";
43
47
  import { appKeyStrengthWarning } from "../support/appKey.ts";
44
48
  import { runBootDoctor } from "./BootDoctor.ts";
45
49
  import { runConfigValidators } from "../config/validation.ts";
@@ -237,10 +241,13 @@ export async function _lazyStaticResponse(
237
241
  const relative = pathname.slice(trimmedPrefix.length).replace(/^\//, "");
238
242
  const file = Bun.file(`${rootDir}/${relative}`);
239
243
  if (await file.exists()) {
240
- return new Response(
241
- file as unknown as BodyInit,
242
- options?.headers ? { headers: options.headers } : undefined,
243
- );
244
+ // The same headers Bun's native static routes carry (see Router.compile).
245
+ // This path returns before the pipeline runs, so without it a file served
246
+ // lazily every file under the dev worker — would be the one response in
247
+ // the app with no security headers on it.
248
+ return new Response(file as unknown as BodyInit, {
249
+ headers: { ...staticSecurityHeaders(), ...options?.headers },
250
+ });
244
251
  }
245
252
  }
246
253
  return undefined;
@@ -248,6 +255,8 @@ export async function _lazyStaticResponse(
248
255
 
249
256
  /** Minimal WebSocket handler shape accepted by Bun.serve(). */
250
257
  export interface WebSocketHandlers {
258
+ /** Seconds a connection may go quiet before Bun closes it. Bun's default is 10. */
259
+ idleTimeout?: number;
251
260
  open?(ws: unknown): void;
252
261
  message(ws: unknown, message: string | Uint8Array): void;
253
262
  close?(ws: unknown, code: number, reason: string): void;
@@ -260,6 +269,39 @@ export interface WebSocketHandlers {
260
269
  */
261
270
  export type AppScopeInstaller = () => () => void;
262
271
 
272
+ /**
273
+ * What one provider cost to boot, and what it contributed.
274
+ *
275
+ * @see Application.providerReport
276
+ */
277
+ export interface ProviderReport {
278
+ /** Provider class name. The array is in boot order. */
279
+ name: string;
280
+ /**
281
+ * Wall-clock milliseconds across all three lifecycle hooks.
282
+ *
283
+ * `onBooted` runs concurrently across providers, so these do not sum to the
284
+ * application's boot time — they overlap, and the report reports that rather
285
+ * than serialising the boot to produce a tidier number.
286
+ */
287
+ durationMs: number;
288
+ /** Container tokens this provider bound, as names. */
289
+ bindings: string[];
290
+ }
291
+
292
+ /**
293
+ * A container token as a readable name.
294
+ *
295
+ * Tokens are class constructors or plain strings; the map keys are neither
296
+ * sorted nor serialisable as they stand, and a reader wants `CacheManager`, not
297
+ * `[class CacheManager]`.
298
+ */
299
+ function _tokenName(token: unknown): string {
300
+ if (typeof token === "string") return token;
301
+ if (typeof token === "function") return token.name || "‹anonymous›";
302
+ return String(token);
303
+ }
304
+
263
305
  const _appScopeInstallers: AppScopeInstaller[] = [];
264
306
 
265
307
  /**
@@ -318,6 +360,8 @@ export class Application {
318
360
  _env: Environment = "web";
319
361
  private _booted = false;
320
362
  private _bootDurationMs: number | undefined = undefined;
363
+ /** Per-provider boot cost and container provenance; see {@link providerReport}. */
364
+ private _providerReport: ProviderReport[] = [];
321
365
  private _static?: ReturnType<typeof Bun.serve>;
322
366
  private _configMap: Record<string, Record<string, unknown>> | undefined = undefined;
323
367
  /** Tracks where config came from, to reject conflicting overrides via useConfig(). */
@@ -460,8 +504,12 @@ export class Application {
460
504
  );
461
505
  }
462
506
 
463
- // eslint-disable-next-line no-restricted-syntax -- runtime mode is exactly what Application.create() wants; _normaliseEnv maps deployment names onto it
464
- const rawEnv = options.env ?? Bun.env["APP_ENV"] ?? "web";
507
+ // The runtime mode, which is what provider filtering is keyed on. Reading
508
+ // `APP_ENV` here used to be right only because `setAppEnv()` had overwritten
509
+ // it with the mode; now the mode has its own variable and this asks for it
510
+ // directly. `_normaliseEnv` still maps a deployment name onto a mode, for an
511
+ // explicit `options.env`.
512
+ const rawEnv = options.env ?? runtimeMode("web");
465
513
  const resolvedEnv: Environment = _normaliseEnv(rawEnv);
466
514
 
467
515
  const app = new Application();
@@ -609,6 +657,60 @@ export class Application {
609
657
  return this._bootDurationMs;
610
658
  }
611
659
 
660
+ /**
661
+ * What each provider cost to boot, and what it put in the container.
662
+ *
663
+ * `bootDurationMs` says the app took 240ms and nothing said which provider
664
+ * spent it; the container lists a hundred bindings and nothing said who bound
665
+ * them. Both are answered here, in boot order — which is itself the answer to
666
+ * a third question, since provider order decides who wins a contested binding.
667
+ *
668
+ * Empty until `boot()` runs. Populated in every environment: it costs one
669
+ * `performance.now()` pair and one registry diff per provider at boot, and a
670
+ * report that only exists in development is one you cannot ask for when a
671
+ * staging boot is the slow one.
672
+ *
673
+ * @category Lifecycle
674
+ */
675
+ get providerReport(): readonly ProviderReport[] {
676
+ return this._providerReport;
677
+ }
678
+
679
+ /**
680
+ * Time one provider lifecycle hook and attribute anything it bound.
681
+ *
682
+ * Accumulates across the three phases, so a provider that binds in
683
+ * `onRegister` and spends its time in `onBooting` reads as one row.
684
+ */
685
+ private _recordProviderWork<T>(provider: ServiceProvider, work: () => T): T {
686
+ const name = provider.constructor.name;
687
+ let row = this._providerReport.find((entry) => entry.name === name);
688
+ if (!row) {
689
+ row = { name, durationMs: 0, bindings: [] };
690
+ this._providerReport.push(row);
691
+ }
692
+ const before = new Set(this.container.registry.keys());
693
+ const started = performance.now();
694
+ const finish = (): T => {
695
+ row!.durationMs = Math.round((row!.durationMs + performance.now() - started) * 100) / 100;
696
+ for (const token of this.container.registry.keys()) {
697
+ if (!before.has(token)) row!.bindings.push(_tokenName(token));
698
+ }
699
+ return undefined as T;
700
+ };
701
+ const result = work();
702
+ // An async hook is only finished when its promise is — timing it
703
+ // synchronously would report every `await` in it as free.
704
+ if (result instanceof Promise) {
705
+ return result.then((value: unknown) => {
706
+ finish();
707
+ return value;
708
+ }) as T;
709
+ }
710
+ finish();
711
+ return result;
712
+ }
713
+
612
714
  /**
613
715
  * The runtime environment this application is running in.
614
716
  *
@@ -973,6 +1075,17 @@ export class Application {
973
1075
  };
974
1076
 
975
1077
  return {
1078
+ // Bun closes an idle WebSocket after 10 seconds by default, and the client
1079
+ // pings every 30 — so a connection that is merely *quiet* was being cut
1080
+ // before it ever had reason to speak, taking its channel subscriptions
1081
+ // with it. Nothing surfaced: the page stayed rendered, the client kept its
1082
+ // channel objects, and broadcasts simply stopped arriving for anyone who
1083
+ // had been reading for more than ten seconds.
1084
+ //
1085
+ // 120s leaves room for four missed pings before a genuinely dead socket is
1086
+ // reaped, which is the direction to err: a stale connection costs memory,
1087
+ // a reaped live one costs the feature.
1088
+ idleTimeout: 120,
976
1089
  open: (ws: unknown) => {
977
1090
  if ((ws as AnyWS).data._dev) {
978
1091
  DevWsServer.open(ws as AnyWS);
@@ -1056,7 +1169,15 @@ export class Application {
1056
1169
  for (const callback of this._bindCallbacks) callback(this.container);
1057
1170
 
1058
1171
  // Phase 1 — synchronous, binds into container.
1059
- for (const provider of this._activeProviders) provider.onRegister();
1172
+ //
1173
+ // Timed, and the container is diffed around each provider, so the inspector
1174
+ // can answer "who bound `cache`, and what did booting it cost". Provenance
1175
+ // by diff rather than by having the container record a registrar: it keeps
1176
+ // the cost at boot instead of on every binding, and adds no mutable state to
1177
+ // the container for a question only a debugging tool asks.
1178
+ for (const provider of this._activeProviders) {
1179
+ this._recordProviderWork(provider, () => provider.onRegister());
1180
+ }
1060
1181
 
1061
1182
  // Config validation — providers have registered their namespace validators
1062
1183
  // in onRegister; run them before anything boots. In a production-like
@@ -1069,10 +1190,20 @@ export class Application {
1069
1190
  }
1070
1191
 
1071
1192
  // Phase 2 — sequential in registration order.
1072
- for (const provider of this._activeProviders) await provider.onBooting();
1193
+ for (const provider of this._activeProviders) {
1194
+ await this._recordProviderWork(provider, () => provider.onBooting());
1195
+ }
1073
1196
 
1074
1197
  // Phase 3 — async, all providers have finished booting.
1075
- await Promise.all(this._activeProviders.map((provider) => provider.onBooted()));
1198
+ //
1199
+ // These run concurrently, so the recorded durations overlap and do not sum
1200
+ // to the phase. That is the truth about this phase and the report says so
1201
+ // rather than serialising the boot to make a tidier number.
1202
+ await Promise.all(
1203
+ this._activeProviders.map((provider) =>
1204
+ this._recordProviderWork(provider, () => provider.onBooted()),
1205
+ ),
1206
+ );
1076
1207
 
1077
1208
  // Ensure config and events are resolved so makeSync() works below.
1078
1209
  await this.container.make("config");
@@ -1100,6 +1231,21 @@ export class Application {
1100
1231
  await this._loadFileRoutes();
1101
1232
  }
1102
1233
 
1234
+ // Install the route table for `zerotal/routes`, now that every route is
1235
+ // registered.
1236
+ //
1237
+ // That module is the standalone URL builder a browser bundle imports, so it
1238
+ // cannot reach for `Router` itself — importing the router would drag the
1239
+ // server into every client bundle. The dependency therefore points this way:
1240
+ // the server, which already has both, pushes the table in.
1241
+ //
1242
+ // Without this, `route()` threw on the server for any app that renders its
1243
+ // own markup — a `view` build produces every href and form action there —
1244
+ // and the fix was a `defineRoutes()` call each app had to know to write.
1245
+ // A browser entry still calls it; that is a different process with no router
1246
+ // to read. See T24.
1247
+ this._installRouteTable();
1248
+
1103
1249
  // A routes/ directory nobody routed is a silent 404 for every path in it — the file
1104
1250
  // imports cleanly and registers nothing, which looks identical to a typo'd URL.
1105
1251
  this._warnUnroutedRoutesDir(process.cwd());
@@ -1153,6 +1299,23 @@ export class Application {
1153
1299
  if (warning) frameworkLog("app").warn(warning);
1154
1300
  }
1155
1301
 
1302
+ /**
1303
+ * Hand the registered routes to the standalone `route()` builder.
1304
+ *
1305
+ * Name → pattern for the URLs, and name → verb for `action()`. `RouteDefinition`
1306
+ * carries its own `name` beside `method`, so the pair comes from one record —
1307
+ * a path join would be wrong, since `GET /login` and `POST /login` share a path.
1308
+ */
1309
+ private _installRouteTable(): void {
1310
+ const methods = new Map<string, string>();
1311
+ for (const definition of Router.routes.values()) {
1312
+ if (definition.name) methods.set(definition.name, definition.method);
1313
+ }
1314
+
1315
+ defineRoutes(Router.namedRoutes);
1316
+ defineRouteMethods(Object.fromEntries(methods));
1317
+ }
1318
+
1156
1319
  private async _loadFileRoutes(): Promise<void> {
1157
1320
  for (const { dir, prefix, middleware } of this._fileRouteGroups) {
1158
1321
  await Router.groupAsync({ prefix, middleware }, () => scanFileRoutes(dir).then(() => {}));
@@ -2,15 +2,18 @@
2
2
  * `bun zt doctor` — run every static sanity check against this app and print
3
3
  * the findings with their fixes. Exits 1 when anything is broken outright.
4
4
  *
5
- * With `--url`, it also probes the deployed app's WebSocket transport from the outside,
6
- * through whatever proxy is in front of it. That is the only way to see the failures that
7
- * leave the app healthy from the inside and inert in the browser.
5
+ * With `--url`, it also reads the deployed app from the outside, through whatever proxy is
6
+ * in front of it the only way to see two classes of failure. The WebSocket transport,
7
+ * which a proxy can gate or drop while leaving the app healthy from the inside and inert in
8
+ * the browser. And security headers sent twice, because a header the app sets and the proxy
9
+ * also sets is invisible from in here: the app's own view is the value it wrote.
8
10
  */
9
11
  import { Command } from "../Command.ts";
10
12
  import type { Application } from "../../application/Application.ts";
11
13
  import { runDoctor } from "../../doctor/AppDoctor.ts";
12
14
  import type { DoctorReportEntry } from "../../doctor/AppDoctor.ts";
13
15
  import { probeTransport } from "../../doctor/TransportProbe.ts";
16
+ import { probeHeaders } from "../../doctor/HeaderProbe.ts";
14
17
 
15
18
  export class DoctorCommand extends Command {
16
19
  static override commandName = "doctor";
@@ -21,7 +24,8 @@ export class DoctorCommand extends Command {
21
24
  name: "url",
22
25
  type: "string" as const,
23
26
  description:
24
- "Also probe the deployed app's WebSocket transport at this public URL, as a browser would",
27
+ "Also read the deployed app at this public URL: handshake its WebSocket transport " +
28
+ "as a browser would, and report security headers the response carries twice",
25
29
  },
26
30
  ];
27
31
 
@@ -34,9 +38,13 @@ export class DoctorCommand extends Command {
34
38
  for (const entry of report) this._print(entry);
35
39
 
36
40
  const probeFailures = await this._probeTransport(app);
41
+ const headerFindings = await this._probeHeaders();
37
42
 
38
- const warns = report.filter((e) => e.result.status === "warn").length;
39
- const fails = report.filter((e) => e.result.status === "fail").length + probeFailures;
43
+ const warns = report.filter((e) => e.result.status === "warn").length + headerFindings.warnings;
44
+ const fails =
45
+ report.filter((e) => e.result.status === "fail").length +
46
+ probeFailures +
47
+ headerFindings.failures;
40
48
  this.newLine();
41
49
  // Throw rather than `process.exit(1)` — same exit code from the CLI (the runner
42
50
  // converts it), but composable. Exiting here killed any caller running the
@@ -86,6 +94,45 @@ export class DoctorCommand extends Command {
86
94
  return failures;
87
95
  }
88
96
 
97
+ /**
98
+ * Report security headers the deployed response carries more than once.
99
+ *
100
+ * Only reachable from outside: the app's own view of a header is the value it
101
+ * wrote, which is correct as far as it goes — the proxy's copy is invisible
102
+ * from in here. Conflicting values count as failures because a control that
103
+ * different browsers apply differently is not a control; identical duplicates
104
+ * are a warning, because they are a conflict waiting for someone to edit one
105
+ * side.
106
+ */
107
+ private async _probeHeaders(): Promise<{ failures: number; warnings: number }> {
108
+ const url = this.flags["url"] as string | undefined;
109
+ if (!url) return { failures: 0, warnings: 0 };
110
+
111
+ const findings = await probeHeaders(url);
112
+ this.newLine();
113
+ this.section("Response headers");
114
+
115
+ if (findings.length === 0) {
116
+ this.line("✓ No duplicated security headers.");
117
+ return { failures: 0, warnings: 0 };
118
+ }
119
+
120
+ let failures = 0;
121
+ let warnings = 0;
122
+ for (const finding of findings) {
123
+ const line = `${finding.conflicting ? "✗" : "!"} ${finding.header} — ${finding.message}`;
124
+ if (finding.conflicting) {
125
+ this.error(line);
126
+ failures++;
127
+ } else {
128
+ this.warn(line);
129
+ warnings++;
130
+ }
131
+ if (finding.fix) this.line(` fix: ${finding.fix}`);
132
+ }
133
+ return { failures, warnings };
134
+ }
135
+
89
136
  private _print({ check, result }: DoctorReportEntry): void {
90
137
  const mark = result.status === "ok" ? "✓" : result.status === "warn" ? "!" : "✗";
91
138
  const line = `${mark} ${check.label} — ${result.message}`;
@@ -34,6 +34,7 @@ export class RouteTypesCommand extends Command {
34
34
 
35
35
  async run(): Promise<void> {
36
36
  const check = this.flags["check"] as boolean;
37
+
37
38
  const result = await writeRouteTypes(Router.namedRoutes, { check });
38
39
 
39
40
  if (check) {