@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 +125 -0
- package/api-surface.md +5 -0
- package/package.json +1 -1
- package/src/command/TypeGenerators.ts +87 -0
- package/src/command/builtin/DoctorCommand.ts +25 -0
- package/src/command/builtin/RouteTypesCommand.ts +52 -15
- package/src/command/builtin/TestCommand.ts +22 -0
- package/src/command/builtin/index.ts +7 -0
- package/src/command/testEnv.ts +140 -0
- package/src/doctor/AppDoctor.ts +71 -5
- package/src/index.ts +3 -0
- package/src/router/FileRouter.ts +97 -23
- package/src/view/FileRouteResolver.ts +29 -5
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
|
@@ -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` —
|
|
8
|
-
* map that makes
|
|
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
|
|
16
|
-
* `zt dev` refreshes
|
|
17
|
-
* disk no longer matches
|
|
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
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
80
|
+
if (failed) throw new Error("A type generator failed.");
|
|
46
81
|
return;
|
|
47
82
|
}
|
|
48
83
|
|
|
49
|
-
if (
|
|
84
|
+
if (routes.count === 0) {
|
|
50
85
|
this.warn("No named routes found — the generated map is empty.");
|
|
51
86
|
}
|
|
52
87
|
|
|
53
|
-
|
|
54
|
-
`${
|
|
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
|
+
}
|
package/src/doctor/AppDoctor.ts
CHANGED
|
@@ -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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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,
|
package/src/router/FileRouter.ts
CHANGED
|
@@ -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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
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
|
-
*
|
|
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();
|