@zerotal/core 1.15.0 → 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 +61 -0
- package/api-surface.md +2 -0
- package/package.json +1 -1
- package/src/command/builtin/DoctorCommand.ts +25 -0
- package/src/router/FileRouter.ts +36 -10
- package/src/view/FileRouteResolver.ts +29 -5
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,67 @@ 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
|
+
|
|
11
72
|
## [1.15.0] — 2026-09-04
|
|
12
73
|
|
|
13
74
|
### Changed — **BREAKING**
|
package/api-surface.md
CHANGED
|
@@ -3783,6 +3783,8 @@ function Fragment = ({ children }: { children?: unknown;}) => SafeHtml
|
|
|
3783
3783
|
|
|
3784
3784
|
function Raw = ({ html }: { html: string;}) => SafeHtml
|
|
3785
3785
|
|
|
3786
|
+
function registerViewFileRouteResolver = () => void
|
|
3787
|
+
|
|
3786
3788
|
function safe = (html: string) => SafeHtml
|
|
3787
3789
|
|
|
3788
3790
|
type Children = unknown
|
package/package.json
CHANGED
|
@@ -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 };
|
package/src/router/FileRouter.ts
CHANGED
|
@@ -414,18 +414,44 @@ async function _collectLayout(
|
|
|
414
414
|
const layoutPath = dir
|
|
415
415
|
? join(baseDir, dir, `_layout${extension}`)
|
|
416
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 };
|
|
417
425
|
try {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
+
);
|
|
428
441
|
}
|
|
442
|
+
|
|
443
|
+
if (typeof loadedModule.default === "function") {
|
|
444
|
+
return loadedModule.default as ViewComponent;
|
|
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
|
+
);
|
|
429
455
|
}
|
|
430
456
|
}
|
|
431
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();
|