@zerotal/core 1.14.3 → 1.15.1

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,131 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.15.1] — 2026-09-13
12
+
13
+ ### Added
14
+
15
+ - **`registerViewFileRouteResolver` is public.** It is what turns on server-rendered
16
+ file-route pages — a route file's default export rendered to HTML and wrapped in the
17
+ nearest `_layout` — and it carried an internal marker, so nothing outside the framework
18
+ could call it. Both features were implemented, tested, and described in `docs/view.md` as
19
+ automatic, while being unreachable through the public API.
20
+
21
+ Promoted rather than removed: the marker was wrong, not the design. It stays opt-in, and
22
+ the docblock now says why — it claims every `.tsx` route file's default export, which an
23
+ app using file routes for plain handlers has not asked for, and it turns on a directory
24
+ walk per scanned route. `docs/view.md` documents the call and the `_layout` convention,
25
+ including that `_layout` is read by its **default** export where `_middleware` is read by
26
+ a named one.
27
+
28
+ ### Fixed
29
+
30
+ - **Four test suites left their fixture trees on disk after every run.** Each builds a
31
+ temporary package or asset tree and removes it in `afterAll`, and the removal reported
32
+ success without removing anything: on Bun 1.4.0 under Windows, a recursive `rm()` from
33
+ `node:fs/promises` resolves and deletes nothing when the path carries a `"./"` prefix,
34
+ which all four used. Written bare, the same call works.
35
+
36
+ Test-only, but it was not harmless. The abandoned trees contain deliberately
37
+ non-conformant fixture packages, `lint:ci` lints anything under `packages/**`, and the
38
+ gate is a ratchet against a committed count — so a full test run left the next lint run
39
+ one or two warnings over baseline, and the regression pointed at whatever change happened
40
+ to be in the tree. Previously read as fallout from interrupted runs, because an
41
+ interrupted run skips the hook and produces the same evidence.
42
+
43
+ - **A `_layout` file that could not apply rendered its pages without one.** The same
44
+ fail-open the `_middleware` loader had, one function up in the same file, and found by
45
+ looking for the shape rather than by hitting it: an import error was swallowed together
46
+ with the not-found case under "component resolution is best-effort". Best-effort is
47
+ right for _absence_ and wrong for a file that exists and threw — a `_layout.tsx` with a
48
+ typo rendered every page beneath it without its chrome, with no error and no log, and on
49
+ a hot-reload without chrome it had a moment earlier.
50
+
51
+ Absence stays silent and the walk continues outward, as before. An import error stops
52
+ the boot with the path and the original error, and a `_layout` that default-exports no
53
+ component now says so rather than silently handing those pages an outer directory's
54
+ layout instead.
55
+
56
+ Worth noting alongside the `_middleware` fix in 1.15.0: these two sibling conventions
57
+ disagreed about export shape — `_layout` reads `default` and ignored a named export,
58
+ `_middleware` read the named export and ignored `default` — and each failed open on the
59
+ other's spelling. That disagreement is why `export default` was the natural guess in a
60
+ `_middleware.ts` to begin with.
61
+
62
+ - **`zt doctor` says when its outside-in probes did not run.** The response-header and
63
+ WebSocket-transport probes only run with `--url`, and they are the two checks that can
64
+ see what no in-process check can. Being silent about skipping them meant the people who
65
+ most needed them were the ones with no way to learn they existed — a team running
66
+ `doctor` after every deploy for months never saw the duplicate-header check.
67
+
68
+ A line, not a new default: auto-probing `app.url` would print "✓ No duplicated security
69
+ headers" for a site that was merely unreachable, which is the same false confidence the
70
+ secure-headers check was fixed for in 1.15.0.
71
+
72
+ ## [1.15.0] — 2026-09-04
73
+
74
+ ### Changed — **BREAKING**
75
+
76
+ - **BREAKING: a `_middleware.ts` that cannot apply now stops the boot.** Two ways one could
77
+ fail were silent, and both left the routes it was written to guard live and unguarded.
78
+ An app where either was happening will now refuse to boot until the file is fixed —
79
+ which is the point: it was not guarding anything before, and nothing said so. A directory
80
+ with no `_middleware` file is unchanged and stays silent. See the
81
+ [Upgrade Guide](/docs/upgrade#114-to-115).
82
+
83
+ ### Fixed
84
+
85
+ - **A `_middleware.ts` that default-exported its list left the subtree unguarded.** The
86
+ loader read only `export const middleware`, so `export default [EmployerMiddleware]` was
87
+ ignored — no warning, no boot error, nothing in `route:list`. The app started, the routes
88
+ answered, and the directory the file was written to protect served to anyone. `export
89
+ default` is the natural guess: every route file in the same directory default-exports its
90
+ handler.
91
+
92
+ The file now has to export a `middleware` array, and boot fails naming the file and the
93
+ fix when it does not. Nothing rejected here has ever applied, so no working app changes.
94
+
95
+ - **An import error in a `_middleware.ts` was swallowed with the missing-file case.** Both
96
+ shared one `catch`, so a typo, a bad import path, a circular import or a `SyntaxError` all
97
+ read as "this directory has no middleware" and the routes beneath went on serving — on a
98
+ hot-reload, a guard that had been there a second earlier.
99
+
100
+ Absence and failure are now separate: no file is silent, as the convention intends, and a
101
+ file that threw stops the boot with the original error and its path. Under `zt dev` the
102
+ reload aborts and the previously compiled routes keep serving, so the guard never drops.
103
+
104
+ - **`zt doctor`'s secure-headers check failed on any app behind a reverse proxy.** It read
105
+ `app.secureHeaders.secure` and inferred the response from it, so a deployment where Caddy
106
+ or nginx terminates TLS and sets HSTS — the most common production topology for a Bun app
107
+ — was reported as downgradeable while `curl -I` showed the header present. A check that is
108
+ wrong on the ordinary case is one people learn to skip.
109
+
110
+ It now makes one request to `app.url` on a deployment and reports what came back. When it
111
+ cannot reach the site, it warns with what it actually knows — the app does not send HSTS,
112
+ confirm the proxy does — instead of asserting a header is missing. Unreachable is no
113
+ longer reported as absent.
114
+
115
+ ### Added
116
+
117
+ - **`zt route:types` regenerates every type file the app generates from its file tree**, not
118
+ only `types/routes.generated.ts`. `@zerotal/inertia` registers its page registry through
119
+ the new `registerTypeGenerator`, so adding a page and running the command whose name says
120
+ it generates types now does. Previously it regenerated the routes half, `Inertia.render`
121
+ still failed with `TS2345: … not assignable to 'PageName'`, and the error reads as a typo
122
+ in the page name rather than a registry that has not been rebuilt. `--check` gates on all
123
+ of them.
124
+
125
+ - **`zt test` no longer inherits `.env`'s mail, queue, session and cache drivers.** Bun loads
126
+ `.env` into every process, so a developer with `MAIL_DRIVER=smtp` pointed at a local
127
+ Postfix got a suite where every path that sends mail opened a real SMTP connection — one
128
+ test going from milliseconds to a five-second timeout, failing only when run with its
129
+ siblings, and invisible to anyone without a mail server configured.
130
+
131
+ These four now get their in-process defaults (`log`, `sync`, `cookie`, `memory`), which is
132
+ what the app's own config already defaults to. A value set in the shell still wins, so
133
+ `MAIL_DRIVER=smtp bun zt test` works; `.env.test` is read and merged last; `--keep-env`
134
+ turns the whole thing off. The command prints what it changed.
135
+
11
136
  ## [1.14.3] — 2026-09-01
12
137
 
13
138
  ### Fixed
package/api-surface.md CHANGED
@@ -3,6 +3,9 @@
3
3
  <!-- AUTO-GENERATED by scripts/api-surface.ts. Do not edit by hand.
4
4
  Run `bun run api:surface` to regenerate after an intentional API change. -->
5
5
 
6
+ > Not a guide — this is a flat, alphabetical snapshot used to detect unintended
7
+ > API changes. For how to use these, read the [documentation](https://zerotal.dev/docs).
8
+
6
9
  ## . `(./src/index.ts)`
7
10
 
8
11
  class AppBooted = {
@@ -3780,6 +3783,8 @@ function Fragment = ({ children }: { children?: unknown;}) => SafeHtml
3780
3783
 
3781
3784
  function Raw = ({ html }: { html: string;}) => SafeHtml
3782
3785
 
3786
+ function registerViewFileRouteResolver = () => void
3787
+
3783
3788
  function safe = (html: string) => SafeHtml
3784
3789
 
3785
3790
  type Children = unknown
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.14.3",
3
+ "version": "1.15.1",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The registry of generated-type files that `bun zt route:types` refreshes.
3
+ *
4
+ * Route names are not the only thing a Zerotal app generates from its own
5
+ * filesystem — `@zerotal/inertia` emits a page registry that gives
6
+ * `Inertia.render(name, props)` its typed page names. Both answer the same
7
+ * question ("what does the tree contain, as types?"), both go stale the moment
8
+ * a file is added, and `route:types` is the command whose name says it fixes
9
+ * that. It used to fix only half of it: adding a page and running `route:types`
10
+ * left the page registry stale, and the resulting error —
11
+ *
12
+ * TS2345: Argument of type '"ops/orders"' is not assignable to 'PageName'
13
+ *
14
+ * — reads as "you typed the name wrong" rather than "nothing has regenerated
15
+ * the registry since you added the file", which sends you looking at the name.
16
+ *
17
+ * A view package registers its routine here from provider boot, so
18
+ * `@zerotal/core` never imports `@zerotal/inertia` (which would be circular).
19
+ * This mirrors `registerDevBuildHook` in `../dev/DevBuildHook.ts`, for the same
20
+ * reason and with the same shape.
21
+ */
22
+
23
+ /**
24
+ * What one generator wrote, for the command's summary output.
25
+ *
26
+ * @internal Part of the {@link registerTypeGenerator} contract, like
27
+ * {@link TypeGeneratorFn} — framework-package plumbing, not app-facing API.
28
+ */
29
+ export interface TypeGeneratorResult {
30
+ /** The file written, relative to the project root. */
31
+ file: string;
32
+ /** What it contains, phrased for a summary line (e.g. `12 pages`). */
33
+ summary: string;
34
+ /**
35
+ * Whether the file on disk differed from what the routine produced. Drives
36
+ * `route:types --check`, and lets the command say "Unchanged" rather than
37
+ * claiming a write it did not need to make.
38
+ */
39
+ changed: boolean;
40
+ }
41
+
42
+ /**
43
+ * A generated-types routine. `check` asks it to compare against disk and report,
44
+ * without writing — the CI gate that keeps a committed generated file honest.
45
+ *
46
+ * @internal
47
+ */
48
+ export type TypeGeneratorFn = (options: { check: boolean }) => Promise<TypeGeneratorResult>;
49
+
50
+ const _generators = new Map<string, TypeGeneratorFn>();
51
+
52
+ /**
53
+ * @internal Exported for view packages to wire their codegen into `route:types`.
54
+ *
55
+ * Register a generated-types routine under a package name. Registering the same
56
+ * name twice replaces the earlier routine, so a provider may safely register on
57
+ * every boot.
58
+ */
59
+ export function registerTypeGenerator(name: string, fn: TypeGeneratorFn): void {
60
+ _generators.set(name, fn);
61
+ }
62
+
63
+ /** @internal Forget every registered routine (tests only). */
64
+ export function _resetTypeGenerators(): void {
65
+ _generators.clear();
66
+ }
67
+
68
+ /**
69
+ * @internal Run every registered routine, in registration order.
70
+ *
71
+ * One routine throwing does not stop the others — a broken page registry should
72
+ * not cost you your route types — but the error is returned rather than
73
+ * swallowed, so the command can report it and exit non-zero.
74
+ */
75
+ export async function _runTypeGenerators(options: {
76
+ check: boolean;
77
+ }): Promise<{ name: string; result?: TypeGeneratorResult; error?: unknown }[]> {
78
+ const outcomes: { name: string; result?: TypeGeneratorResult; error?: unknown }[] = [];
79
+ for (const [name, fn] of _generators) {
80
+ try {
81
+ outcomes.push({ name, result: await fn(options) });
82
+ } catch (error) {
83
+ outcomes.push({ name, error });
84
+ }
85
+ }
86
+ return outcomes;
87
+ }
@@ -39,6 +39,7 @@ export class DoctorCommand extends Command {
39
39
 
40
40
  const probeFailures = await this._probeTransport(app);
41
41
  const headerFindings = await this._probeHeaders();
42
+ this._noteSkippedProbes();
42
43
 
43
44
  const warns = report.filter((e) => e.result.status === "warn").length + headerFindings.warnings;
44
45
  const fails =
@@ -104,6 +105,30 @@ export class DoctorCommand extends Command {
104
105
  * are a warning, because they are a conflict waiting for someone to edit one
105
106
  * side.
106
107
  */
108
+ /**
109
+ * Say that the outside-in probes did not run, when they did not.
110
+ *
111
+ * They are the two checks that can see what no in-process check can — a header the
112
+ * proxy also sets, a WebSocket the proxy drops — and they are behind a flag, so the
113
+ * people who most need them are the ones who do not know the flag exists. A team
114
+ * running `doctor` after every deploy for months never saw the duplicate-header
115
+ * check and had no way to find out it was there.
116
+ *
117
+ * A line, not a default. Auto-probing `app.url` would print "✓ No duplicated
118
+ * security headers" for a site that was simply unreachable, which is a worse
119
+ * failure than the silence: it is the same false confidence the secure-headers
120
+ * check was fixed for.
121
+ */
122
+ private _noteSkippedProbes(): void {
123
+ if (this.flags["url"]) return;
124
+ this.newLine();
125
+ this.dim(
126
+ "Not checked: response headers and WebSocket transport, which are only visible " +
127
+ "from outside.\n Add --url https://your-site to read the deployed app through " +
128
+ "its proxy.",
129
+ );
130
+ }
131
+
107
132
  private async _probeHeaders(): Promise<{ failures: number; warnings: number }> {
108
133
  const url = this.flags["url"] as string | undefined;
109
134
  if (!url) return { failures: 0, warnings: 0 };
@@ -2,25 +2,34 @@ import { Command } from "../Command.ts";
2
2
  import type { FlagDef } from "../Command.ts";
3
3
  import { Router } from "../../router/Router.ts";
4
4
  import { writeRouteTypes, ROUTE_TYPES_FILE } from "../../router/routeTypes.ts";
5
+ import { _runTypeGenerators } from "../TypeGenerators.ts";
5
6
 
6
7
  /**
7
- * `bun zt route:types` — write `types/routes.generated.ts`, the name → pattern
8
- * map that makes `route()` type-checked.
8
+ * `bun zt route:types` — refresh every type file the app generates from its own
9
+ * filesystem: `types/routes.generated.ts`, the name → pattern map that makes
10
+ * `route()` type-checked, plus whatever the installed view packages register
11
+ * (`@zerotal/inertia` adds its page registry, which types `Inertia.render`).
12
+ *
13
+ * It generates more than routes because a developer who adds a page and gets a
14
+ * type error about its name reaches for the command whose name says it
15
+ * generates types — and used to get the same error back, having regenerated the
16
+ * wrong half. Both files answer "what does the tree contain, as types?", and
17
+ * both go stale on the same edits.
9
18
  *
10
19
  * Boots the application (`needsApp`) and reads `Router.namedRoutes`, so routes
11
20
  * a provider registers programmatically and names set via a route file's
12
21
  * `export const meta` are included — not just what the file-router's naming
13
22
  * convention would produce.
14
23
  *
15
- * Commit the generated file: editors and CI need it without booting the app.
16
- * `zt dev` refreshes it on every restart, and `--check` fails when the file on
17
- * disk no longer matches the routes, which is the CI gate that keeps it honest.
24
+ * Commit the generated files: editors and CI need them without booting the app.
25
+ * `zt dev` refreshes them on every restart, and `--check` fails when a file on
26
+ * disk no longer matches, which is the CI gate that keeps them honest.
18
27
  *
19
28
  * @category Diagnostics
20
29
  */
21
30
  export class RouteTypesCommand extends Command {
22
31
  static commandName = "route:types";
23
- static description = "Generate types/routes.generated.ts for typed route() names";
32
+ static description = "Generate the app's type files (routes, and any view package's registry)";
24
33
  static needsApp = true;
25
34
  static args = [];
26
35
  static flags: FlagDef[] = [
@@ -35,23 +44,51 @@ export class RouteTypesCommand extends Command {
35
44
  async run(): Promise<void> {
36
45
  const check = this.flags["check"] as boolean;
37
46
 
38
- const result = await writeRouteTypes(Router.namedRoutes, { check });
47
+ const routes = await writeRouteTypes(Router.namedRoutes, { check });
48
+
49
+ // The routes file, then whatever the installed view packages register — the
50
+ // Inertia page registry, today. Collected into one list so the command reports
51
+ // every generated file the same way, and so a failure in one does not cost
52
+ // you the other: half-regenerated types are how this got confusing to begin with.
53
+ const files: { file: string; summary: string; changed: boolean }[] = [
54
+ {
55
+ file: ROUTE_TYPES_FILE,
56
+ summary: `${routes.count} named routes`,
57
+ changed: routes.changed,
58
+ },
59
+ ];
60
+ let failed = false;
61
+
62
+ for (const { name, result, error } of await _runTypeGenerators({ check })) {
63
+ if (error) {
64
+ failed = true;
65
+ this.error(`${name}: ${error instanceof Error ? error.message : String(error)}`);
66
+ continue;
67
+ }
68
+ if (result) files.push(result);
69
+ }
39
70
 
40
71
  if (check) {
41
- if (result.changed) {
42
- this.error(`${result.path} is out of date. Run: bun zt route:types`);
43
- throw new Error("Route types are out of date.");
72
+ const stale = files.filter((f) => f.changed);
73
+ for (const f of files) {
74
+ if (f.changed) this.error(`${f.file} is out of date. Run: bun zt route:types`);
75
+ else this.info(`${f.file} is up to date (${f.summary}).`);
76
+ }
77
+ if (stale.length > 0) {
78
+ throw new Error(`Generated types are out of date: ${stale.map((f) => f.file).join(", ")}`);
44
79
  }
45
- this.info(`${result.path} is up to date (${result.count} named routes).`);
80
+ if (failed) throw new Error("A type generator failed.");
46
81
  return;
47
82
  }
48
83
 
49
- if (result.count === 0) {
84
+ if (routes.count === 0) {
50
85
  this.warn("No named routes found — the generated map is empty.");
51
86
  }
52
87
 
53
- this.info(
54
- `${result.changed ? "Wrote" : "Unchanged"}: ${ROUTE_TYPES_FILE} (${result.count} named routes)`,
55
- );
88
+ for (const f of files) {
89
+ this.info(`${f.changed ? "Wrote" : "Unchanged"}: ${f.file} (${f.summary})`);
90
+ }
91
+
92
+ if (failed) throw new Error("A type generator failed.");
56
93
  }
57
94
  }
@@ -1,5 +1,6 @@
1
1
  import { Command } from "../Command.ts";
2
2
  import { bunBinary } from "../../support/runtime.ts";
3
+ import { testEnvOverrides } from "../testEnv.ts";
3
4
 
4
5
  /**
5
6
  * `bun zt test [pattern] [flags]` — runs the test suite in the test environment.
@@ -11,6 +12,9 @@ import { bunBinary } from "../../support/runtime.ts";
11
12
  * withDatabase() and DB.table() work without manual beforeAll boilerplate.
12
13
  * - If @zerotal/testing is not installed the preload is silently skipped —
13
14
  * tests that use createTestApp() still work without it.
15
+ * - Resets the driver keys `.env` would otherwise lend the suite (mail, queue,
16
+ * session, cache) to their in-process defaults, and loads `.env.test` when
17
+ * the app has one. See `../testEnv.ts` for why, and `--keep-env` to opt out.
14
18
  *
15
19
  * All positional args and recognised flags are forwarded to bun test.
16
20
  *
@@ -72,6 +76,12 @@ export class TestCommand extends Command {
72
76
  description: "Run database/migrations against the test database before the suite",
73
77
  default: false,
74
78
  },
79
+ {
80
+ name: "keep-env",
81
+ type: "boolean" as const,
82
+ description: "Inherit .env's mail/queue/session/cache drivers instead of resetting them",
83
+ default: false,
84
+ },
75
85
  ];
76
86
 
77
87
  async run(): Promise<void> {
@@ -122,7 +132,18 @@ export class TestCommand extends Command {
122
132
  const timeout = (this.flags["timeout"] as number | undefined) || TestCommand.DEFAULT_TIMEOUT_MS;
123
133
  bunArguments.push(`--timeout=${timeout}`);
124
134
 
135
+ // `.env` is written for `zt dev`; a test run should decide for itself which
136
+ // outbound services it reaches. Reported rather than applied silently — a
137
+ // suite that behaves differently from the same code under `zt dev` has to
138
+ // say so, or the next person debugs the difference instead of reading it.
139
+ const { overrides, neutralised } = this.flags["keep-env"]
140
+ ? { overrides: {}, neutralised: [] as string[] }
141
+ : await testEnvOverrides(process.cwd(), Bun.env as Record<string, string | undefined>);
142
+
125
143
  this.dim(`APP_ENV=test ZT_DB_URL=${dbUrl} bun ${Bun.version}`);
144
+ if (neutralised.length > 0) {
145
+ this.dim(`Reset for tests: ${neutralised.join(" ")} (keep .env's with --keep-env)`);
146
+ }
126
147
  this.dim(`bun ${bunArguments.join(" ")}\n`);
127
148
 
128
149
  // `bunBinary()`, not `"bun"`. The child of a command whose entire job is to run
@@ -136,6 +157,7 @@ export class TestCommand extends Command {
136
157
  stderr: "inherit",
137
158
  env: {
138
159
  ...Bun.env,
160
+ ...overrides,
139
161
  APP_ENV: "test",
140
162
  ZT_DB_URL: dbUrl,
141
163
  } as Record<string, string | undefined>,
@@ -60,3 +60,10 @@ export { AssetsBuildCommand } from "./AssetsBuildCommand.ts";
60
60
  export { AssetsPruneCommand } from "./AssetsPruneCommand.ts";
61
61
  export { LintPackagesCommand } from "./LintPackagesCommand.ts";
62
62
  export { MakePackageCommand } from "./MakePackageCommand.ts";
63
+
64
+ // Extension point rather than a command: lets a view package add its own
65
+ // generated-types file to `zt route:types`, so one command refreshes every type
66
+ // file an app generates from its file tree. `@zerotal/inertia` registers its page
67
+ // registry here. See ../TypeGenerators.ts.
68
+ export { registerTypeGenerator } from "../TypeGenerators.ts";
69
+ export type { TypeGeneratorFn, TypeGeneratorResult } from "../TypeGenerators.ts";
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The environment `bun zt test` hands its child, and the one thing it takes away.
3
+ *
4
+ * Bun loads `.env` into every process it starts, so a test run inherits the
5
+ * developer's whole local setup — including the driver keys that decide whether
6
+ * a code path talks to something real. A developer with `MAIL_DRIVER=smtp`
7
+ * pointed at a local Postfix gets a suite where every path that sends mail opens
8
+ * an SMTP connection: one confirmed payment that issues an invitation goes from
9
+ * milliseconds to a five-second timeout, and fails only when run with its
10
+ * siblings. A developer with no mail server configured never sees it, which
11
+ * makes it precisely the kind of failure that lands on one person and looks
12
+ * like flakiness to everyone else.
13
+ *
14
+ * So the drivers below are reset for the test child. Not because `.env` is
15
+ * wrong — it is right for `zt dev`, which is what it was written for — but
16
+ * because "which outbound service does this reach" is a question a test run
17
+ * should answer for itself. The app's own config already defaults to the same
18
+ * safe values; it is only `.env` that overrides them.
19
+ *
20
+ * Two things still win over this:
21
+ * - a value set in the shell (`MAIL_DRIVER=smtp bun zt test`), which is
22
+ * someone deliberately testing that path;
23
+ * - a value in `.env.test`, which is the same intent written down. Bun only
24
+ * loads that file when `NODE_ENV=test`, so it is read here explicitly rather
25
+ * than left as a file the docs mention and nothing loads.
26
+ */
27
+
28
+ /**
29
+ * Driver keys reset for a test run, and what they are reset to. Each default is
30
+ * the in-process option: no socket, no external service, no shared state between
31
+ * runs — and each is already the app config's own default.
32
+ */
33
+ export const TEST_SAFE_DRIVERS: Readonly<Record<string, string>> = {
34
+ /** Writes the message to the log instead of opening an SMTP connection. */
35
+ MAIL_DRIVER: "log",
36
+ /** Runs a dispatched job inline, so a test sees its effects without a worker. */
37
+ QUEUE_DRIVER: "sync",
38
+ /** Keeps the session in the cookie rather than reaching for Redis. */
39
+ SESSION_DRIVER: "cookie",
40
+ /** In-process cache, so one test file cannot see another's entries. */
41
+ CACHE_DRIVER: "memory",
42
+ };
43
+
44
+ /**
45
+ * Parse a dotenv file into a map. Deliberately small: this is used to find out
46
+ * *which keys a file mentions*, not to load anything — Bun has already loaded
47
+ * `.env` by the time this runs, and re-implementing its full semantics here
48
+ * would only produce a second, disagreeing answer.
49
+ *
50
+ * @param text - Raw file contents.
51
+ * @returns Key → value, with surrounding quotes stripped.
52
+ */
53
+ export function parseDotenv(text: string): Map<string, string> {
54
+ const entries = new Map<string, string>();
55
+ for (const raw of text.split(/\r?\n/)) {
56
+ const line = raw.trim();
57
+ if (line === "" || line.startsWith("#")) continue;
58
+
59
+ const eq = line.indexOf("=");
60
+ if (eq <= 0) continue;
61
+
62
+ const key = line
63
+ .slice(0, eq)
64
+ .trim()
65
+ .replace(/^export\s+/, "");
66
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
67
+
68
+ let value = line.slice(eq + 1).trim();
69
+ const quote = value[0];
70
+ if ((quote === '"' || quote === "'") && value.endsWith(quote) && value.length > 1) {
71
+ value = value.slice(1, -1);
72
+ } else {
73
+ // Unquoted values run to a trailing comment, matching dotenv.
74
+ value = value.replace(/\s+#.*$/, "");
75
+ }
76
+ entries.set(key, value);
77
+ }
78
+ return entries;
79
+ }
80
+
81
+ /** Read and parse a dotenv file, or an empty map when it does not exist. */
82
+ async function _read(path: string): Promise<Map<string, string>> {
83
+ const text = await Bun.file(path)
84
+ .text()
85
+ .catch(() => null);
86
+ return text === null ? new Map() : parseDotenv(text);
87
+ }
88
+
89
+ /**
90
+ * Work out what to change about the child process's environment.
91
+ *
92
+ * @param cwd - Project root holding the dotenv files.
93
+ * @param env - The parent's environment, as the child would otherwise inherit it.
94
+ * @returns Keys to override, and the subset of those that were neutralised drivers (for reporting).
95
+ */
96
+ export async function testEnvOverrides(
97
+ cwd: string,
98
+ env: Record<string, string | undefined>,
99
+ ): Promise<{ overrides: Record<string, string>; neutralised: string[] }> {
100
+ const [dotenv, dotenvLocal, dotenvTest] = await Promise.all([
101
+ _read(`${cwd}/.env`),
102
+ _read(`${cwd}/.env.local`),
103
+ _read(`${cwd}/.env.test`),
104
+ ]);
105
+
106
+ const overrides: Record<string, string> = {};
107
+ const neutralised: string[] = [];
108
+
109
+ for (const [key, safe] of Object.entries(TEST_SAFE_DRIVERS)) {
110
+ const current = env[key];
111
+
112
+ // Unset means the app config's default applies, which is already the safe
113
+ // one — setting it anyway would only paper over a config the app changed
114
+ // on purpose.
115
+ if (current === undefined) continue;
116
+
117
+ // Already safe: nothing to say, and nothing to report.
118
+ if (current === safe) continue;
119
+
120
+ // Set in the shell rather than in a file — someone testing this path on
121
+ // purpose. Inferred by comparing against what the files declare, which is
122
+ // the only handle available: Bun merges dotenv into the environment before
123
+ // any of this runs, and does not record where a value came from.
124
+ const fromFile = dotenv.get(key) === current || dotenvLocal.get(key) === current;
125
+ if (!fromFile) continue;
126
+
127
+ overrides[key] = safe;
128
+ neutralised.push(`${key}=${safe}`);
129
+ }
130
+
131
+ // `.env.test` last: it is the app saying what a test run should use, which
132
+ // outranks both the inherited value and the default chosen here.
133
+ for (const [key, value] of dotenvTest) {
134
+ overrides[key] = value;
135
+ const index = neutralised.findIndex((entry) => entry.startsWith(`${key}=`));
136
+ if (index !== -1) neutralised.splice(index, 1);
137
+ }
138
+
139
+ return { overrides, neutralised };
140
+ }
@@ -351,22 +351,88 @@ const corsWildcardCheck: DoctorCheck = {
351
351
  * false and has never had production auto-detection. A deployment that never set it
352
352
  * serves no `Strict-Transport-Security` at all, so a visitor's first request each
353
353
  * time stays downgradeable to plain HTTP.
354
+ *
355
+ * The header is the only thing that settles this, and the header is not always the
356
+ * app's to send. Reading `app.secureHeaders.secure` and inferring the response from
357
+ * it failed on the most ordinary production topology there is: a reverse proxy
358
+ * terminating TLS and setting HSTS itself. The app's config is then correctly
359
+ * false, the header is correctly present, and the check said the site could be
360
+ * downgraded — a wrong answer on a check that is otherwise reliable enough to be
361
+ * read, which is how a check becomes the line people learn to skip.
362
+ *
363
+ * So it probes when it can (`--url`, or `app.url` on a deployment: the response is
364
+ * the only thing that answers the question) and, when it cannot, says what it
365
+ * actually knows — the app does not send HSTS — rather than what it was guessing.
354
366
  */
355
367
  const secureHeadersCheck: DoctorCheck = {
356
368
  id: "secure-headers",
357
369
  label: "Secure headers",
358
- run(app) {
370
+ async run(app) {
359
371
  const secure = _config(app, "app.secureHeaders.secure") === true;
360
372
  if (secure) return ok("HSTS enabled.");
361
373
  if (!_isProductionEnv(app)) return ok("HSTS off — expected outside a deployment.");
362
- return fail(
363
- "app.secureHeaders.secure is not true, so no Strict-Transport-Security header is " +
364
- "sent and the first request of each visit can be downgraded to HTTP.",
365
- "Set app.secureHeaders.secure: true in config/app.ts once the site is served over HTTPS.",
374
+
375
+ // One request to the public URL, which is the only view that includes the
376
+ // proxy. A loopback `app.url` on a deployment is its own finding, already
377
+ // reported by the transport-origins check.
378
+ const url = _config(app, "app.url");
379
+ if (typeof url === "string" && url !== "" && !_isLoopback(url)) {
380
+ const observed = await _observedHsts(url);
381
+ if (observed === true) {
382
+ return ok(`HSTS served by the proxy (${url}) — the app does not set it.`);
383
+ }
384
+ if (observed === false) {
385
+ return fail(
386
+ `No Strict-Transport-Security on ${url}: neither the app nor anything in ` +
387
+ `front of it sends it, so the first request of each visit can be downgraded ` +
388
+ `to HTTP.`,
389
+ "Set app.secureHeaders.secure: true in config/app.ts, or add the header at the proxy.",
390
+ );
391
+ }
392
+ // Unreachable: the deployment may not be up yet, or `doctor` may be running
393
+ // somewhere that cannot see the public URL. That is not evidence either way.
394
+ }
395
+
396
+ return warn(
397
+ "app.secureHeaders.secure is not true, so the app does not send " +
398
+ "Strict-Transport-Security. If a reverse proxy terminates TLS it may be sending " +
399
+ "it — confirm there; if nothing does, the first request of each visit can be " +
400
+ "downgraded to HTTP.",
401
+ `Check with: curl -sI ${typeof url === "string" && url !== "" ? url : "https://your-site"} | grep -i strict-transport-security\n` +
402
+ " Then either set app.secureHeaders.secure: true in config/app.ts, or leave it " +
403
+ "to the proxy — but not both, which sends the header twice.",
366
404
  );
367
405
  },
368
406
  };
369
407
 
408
+ /**
409
+ * Whether the deployed site answers with `Strict-Transport-Security`.
410
+ *
411
+ * `null` when the request could not be made at all, which is different from "no
412
+ * header" and must not be reported as one.
413
+ */
414
+ async function _observedHsts(url: string): Promise<boolean | null> {
415
+ try {
416
+ const response = await fetch(url, {
417
+ method: "HEAD",
418
+ // `redirect: "manual"`: the redirect's own response is the one the proxy wrote,
419
+ // and following it would report the destination's headers instead.
420
+ redirect: "manual",
421
+ // `doctor` is what people run immediately after a deploy, so it may not hang on
422
+ // an unreachable host — a diagnostic that stops responding is worse than one
423
+ // that says it could not tell. A timeout lands in the `catch` as `null`, which
424
+ // is already "no evidence either way" rather than "no header".
425
+ signal: AbortSignal.timeout(_PROBE_TIMEOUT_MS),
426
+ });
427
+ return response.headers.get("strict-transport-security") !== null;
428
+ } catch {
429
+ return null;
430
+ }
431
+ }
432
+
433
+ /** How long the HSTS probe waits before reporting that it could not tell. */
434
+ const _PROBE_TIMEOUT_MS = 5_000;
435
+
370
436
  /**
371
437
  * A rate limiter behind a proxy that was never told about the proxy keys every
372
438
  * visitor to the same bucket, because the socket address is the proxy's. It then
package/src/index.ts CHANGED
@@ -245,6 +245,9 @@ export type {
245
245
  } from "./router/Route.ts";
246
246
  export { scanFileRoutes, registerFileRouteResolver } from "./router/FileRouter.ts";
247
247
  export type { FileRouteResolver, FileRouteContext } from "./router/FileRouter.ts";
248
+ // `registerTypeGenerator` — the hook that lets a view package add its own
249
+ // generated-types file to `zt route:types` — lives on `@zerotal/core/commands`,
250
+ // not here: it is command plumbing, and this barrel is the frozen lean kernel.
248
251
  export type {
249
252
  FileHandler,
250
253
  RouteModule,
@@ -312,25 +312,73 @@ async function _collectMiddleware(
312
312
  // sibling routes stayed live and unguarded, with no error and no log. A convention
313
313
  // that fails open is worse than one that does not exist.
314
314
  const middlewarePath = await _findMiddlewareFile(baseDir, dir);
315
+ // No file is the convention working — most directories have none. Everything past
316
+ // this point is a file somebody wrote in order to guard a subtree, so every way it
317
+ // can fail to apply is loud. These two cases used to share one `catch`, which made
318
+ // "there is no middleware here" and "the middleware is broken" indistinguishable.
319
+ if (!middlewarePath) continue;
320
+
321
+ // Convert to a file:// URL so dynamic import works on Windows (raw
322
+ // absolute paths with backslashes are not valid import specifiers).
323
+ // Append ?t=<reloadId> to bust the module cache on hot-reload.
324
+ const href = pathToFileURL(middlewarePath).href;
325
+ const importUrl = reloadId ? `${href}?t=${reloadId}` : href;
326
+
327
+ let loadedModule: MiddlewareModule & { default?: unknown };
315
328
  try {
316
- if (middlewarePath) {
317
- // Convert to a file:// URL so dynamic import works on Windows (raw
318
- // absolute paths with backslashes are not valid import specifiers).
319
- // Append ?t=<reloadId> to bust the module cache on hot-reload.
320
- const href = pathToFileURL(middlewarePath).href;
321
- const importUrl = reloadId ? `${href}?t=${reloadId}` : href;
322
- const loadedModule = (await import(importUrl)) as MiddlewareModule;
323
- if (Array.isArray(loadedModule.middleware)) {
324
- result.push(...loadedModule.middleware);
325
- }
326
- }
327
- } catch {
328
- // The file is missing or failed to import — skip it.
329
+ loadedModule = (await import(importUrl)) as MiddlewareModule & { default?: unknown };
330
+ } catch (error) {
331
+ // A typo, a bad import path, a circular import, a SyntaxError — swallowing any of
332
+ // these left the subtree beneath serving normally with its guard silently gone,
333
+ // and on a hot-reload, gone a second after it was there. Boot stops instead.
334
+ throw new Error(
335
+ `[Zerotal] Failed to import ${middlewarePath}: ` +
336
+ `${error instanceof Error ? error.message : String(error)}\n` +
337
+ `Routes under this directory would serve unguarded, so boot stops here. ` +
338
+ `Fix the file, or delete it if the subtree needs no middleware.`,
339
+ { cause: error },
340
+ );
329
341
  }
342
+
343
+ if (Array.isArray(loadedModule.middleware)) {
344
+ result.push(...loadedModule.middleware);
345
+ continue;
346
+ }
347
+
348
+ // The file loaded and exports nothing the loader can apply — the same fail-open in
349
+ // a different disguise. `export default` is the common one: every route file in the
350
+ // same directory default-exports its handler, so it is the natural guess, and it
351
+ // used to leave the subtree open while looking exactly like a guarded one. Throwing
352
+ // cannot break a working app, because no shape rejected here has ever applied.
353
+ throw new Error(_middlewareExportError(middlewarePath, loadedModule));
330
354
  }
331
355
  return result;
332
356
  }
333
357
 
358
+ /**
359
+ * The boot error for a `_middleware` file that exports nothing the loader can apply,
360
+ * naming the specific mistake so the message carries its own fix.
361
+ */
362
+ function _middlewareExportError(
363
+ path: string,
364
+ loadedModule: MiddlewareModule & { default?: unknown },
365
+ ): string {
366
+ const fix = " export const middleware = [YourMiddleware];";
367
+ const head = `[Zerotal] ${path} does not export a \`middleware\` array, so it guards nothing.`;
368
+
369
+ if (loadedModule.default !== undefined) {
370
+ return (
371
+ `${head}\n` +
372
+ `It has a \`default\` export, which this file does not use — route files ` +
373
+ `default-export their handler, but a _middleware file is read by name:\n${fix}`
374
+ );
375
+ }
376
+ if (loadedModule.middleware !== undefined) {
377
+ return `${head}\n\`middleware\` must be an array, even for a single entry:\n${fix}`;
378
+ }
379
+ return `${head}\nAdd the export, or delete the file if the subtree needs no middleware:\n${fix}`;
380
+ }
381
+
334
382
  /**
335
383
  * Combine the stacked `_middleware.ts` chain (`base`) with a route file's own
336
384
  * `export const middleware` for a specific verb. Array form applies to every method;
@@ -366,18 +414,44 @@ async function _collectLayout(
366
414
  const layoutPath = dir
367
415
  ? join(baseDir, dir, `_layout${extension}`)
368
416
  : join(baseDir, `_layout${extension}`);
417
+ // No file at this spelling is the ordinary case — most directories have no
418
+ // layout, and the walk continues outward to find the nearest one that does.
419
+ if (!(await Bun.file(layoutPath).exists())) continue;
420
+
421
+ const href = pathToFileURL(layoutPath).href;
422
+ const importUrl = reloadId ? `${href}?t=${reloadId}` : href;
423
+
424
+ let loadedModule: { default?: unknown };
369
425
  try {
370
- if (await Bun.file(layoutPath).exists()) {
371
- const href = pathToFileURL(layoutPath).href;
372
- const importUrl = reloadId ? `${href}?t=${reloadId}` : href;
373
- const loadedModule = (await import(importUrl)) as { default?: unknown };
374
- if (typeof loadedModule.default === "function") {
375
- return loadedModule.default as ViewComponent;
376
- }
377
- }
378
- } catch {
379
- // A page module that fails to import is skipped — component resolution is best-effort.
426
+ loadedModule = (await import(importUrl)) as { default?: unknown };
427
+ } catch (error) {
428
+ // This was swallowed with the not-found case, under "component resolution is
429
+ // best-effort". Best-effort is right for *absence*; it is not right for a file
430
+ // that exists and threw. A `_layout.tsx` with a typo in it rendered every page
431
+ // beneath without its chrome — no error, no log, and on a hot-reload a layout
432
+ // that was there a moment ago. Same failure the `_middleware` loader had, one
433
+ // function up, minus the part where the missing thing was a guard.
434
+ throw new Error(
435
+ `[Zerotal] Failed to import ${layoutPath}: ` +
436
+ `${error instanceof Error ? error.message : String(error)}\n` +
437
+ `Pages under this directory would render without their layout, so boot ` +
438
+ `stops here. Fix the file, or delete it if the directory needs no layout.`,
439
+ { cause: error },
440
+ );
441
+ }
442
+
443
+ if (typeof loadedModule.default === "function") {
444
+ return loadedModule.default as ViewComponent;
380
445
  }
446
+
447
+ // The file exists and exports no component. Continuing the walk would silently
448
+ // hand these pages an outer directory's layout instead — plausible-looking output
449
+ // that is not what the file says should happen.
450
+ throw new Error(
451
+ `[Zerotal] ${layoutPath} does not default-export a component, so it lays out ` +
452
+ `nothing.\nA layout file is read by its default export:\n` +
453
+ ` export default (ctx, { children }) => \`<main>\${children}</main>\`;`,
454
+ );
381
455
  }
382
456
  }
383
457
  return undefined;
@@ -19,12 +19,36 @@ function isClass(fn: Function): boolean {
19
19
  }
20
20
 
21
21
  /**
22
- * Opt into rendering `.tsx`/`.jsx` file-route pages: a route file's default
23
- * export that is a view component (or any function tagged via the view marker)
24
- * is rendered to HTML, wrapped in the nearest `_layout`, and registered as a GET
25
- * route. Call from a provider's `onRegister()` before file routes are scanned.
22
+ * Opt into server-rendered file-route pages.
26
23
  *
27
- * @internal
24
+ * With this registered, a route file whose default export is a view component
25
+ * (or any function tagged by {@link definePage}) is rendered to HTML, wrapped in
26
+ * the nearest `_layout`, and registered as a `GET` route. Without it, neither
27
+ * happens: the default export is registered as an ordinary file handler and the
28
+ * `_layout` convention is switched off entirely.
29
+ *
30
+ * Call it before file routes are scanned — a provider's `onRegister()`, or
31
+ * `bootstrap/app.ts` before `fileBasedRouting()`:
32
+ *
33
+ * ```ts
34
+ * // bootstrap/app.ts
35
+ * import { registerViewFileRouteResolver } from "@zerotal/core/view";
36
+ *
37
+ * registerViewFileRouteResolver();
38
+ *
39
+ * export default Application.create({ providers }).fileBasedRouting({
40
+ * web: basePath("app/views"),
41
+ * });
42
+ * ```
43
+ *
44
+ * **Why it is opt-in rather than on by default.** It claims every `.tsx` route
45
+ * file's default export, which an app using file routes for plain handlers has
46
+ * not asked for — and it turns on `_layout` discovery, which walks the directory
47
+ * tree on every scanned route. An app that wants JSX pages says so.
48
+ *
49
+ * It carried an internal marker until 1.15, which made both features unreachable
50
+ * through the public API while the documentation described them as automatic.
51
+ * The marker was wrong, not the design.
28
52
  */
29
53
  export function registerViewFileRouteResolver(): void {
30
54
  enableFileRouteLayouts();