@zerotal/core 1.15.0 → 1.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,89 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.15.2] — 2026-10-23
12
+
13
+ ### Fixed
14
+
15
+ - **A production build ships React's production build.** `browserEnvDefines()` defined
16
+ `import.meta.env` but not `process.env.NODE_ENV`, which is the switch React and most of
17
+ npm read — and Bun.build leaves that expression to whatever the shell has. An app that ran
18
+ `inertia:build --production` without `NODE_ENV` in its environment shipped React's
19
+ development build: a 522 KB vendor chunk where 322 KB would do, the DevTools notice in
20
+ every visitor's console, and full hydration diffs. The define now states it from the same
21
+ flag. Found on a live consumer app (Trekly).
22
+
23
+ - **`back()` works behind a proxy that terminates TLS.** `safeRedirectPath` — which
24
+ `ctx.back()`, `validate()`'s redirect on failure, `FormRequest`, the intended-URL helpers
25
+ and flow all use — accepted a Referer only from the request's own origin. Behind Caddy or
26
+ nginx the request reaches the process as `http://…` while every browser sends
27
+ `https://…`, so every Referer was refused and "back" was always `/`: a form with one
28
+ mistake in it sent the user to the home page, its errors flashed to a page that never
29
+ shows them. The configured `app.url`'s origin now counts as the app's own as well; every
30
+ other site is still refused. Found on a live consumer app (Trekly), which had worked
31
+ round it in its exception handler.
32
+
33
+ ## [1.15.1] — 2026-09-13
34
+
35
+ ### Added
36
+
37
+ - **`registerViewFileRouteResolver` is public.** It is what turns on server-rendered
38
+ file-route pages — a route file's default export rendered to HTML and wrapped in the
39
+ nearest `_layout` — and it carried an internal marker, so nothing outside the framework
40
+ could call it. Both features were implemented, tested, and described in `docs/view.md` as
41
+ automatic, while being unreachable through the public API.
42
+
43
+ Promoted rather than removed: the marker was wrong, not the design. It stays opt-in, and
44
+ the docblock now says why — it claims every `.tsx` route file's default export, which an
45
+ app using file routes for plain handlers has not asked for, and it turns on a directory
46
+ walk per scanned route. `docs/view.md` documents the call and the `_layout` convention,
47
+ including that `_layout` is read by its **default** export where `_middleware` is read by
48
+ a named one.
49
+
50
+ ### Fixed
51
+
52
+ - **Four test suites left their fixture trees on disk after every run.** Each builds a
53
+ temporary package or asset tree and removes it in `afterAll`, and the removal reported
54
+ success without removing anything: on Bun 1.4.0 under Windows, a recursive `rm()` from
55
+ `node:fs/promises` resolves and deletes nothing when the path carries a `"./"` prefix,
56
+ which all four used. Written bare, the same call works.
57
+
58
+ Test-only, but it was not harmless. The abandoned trees contain deliberately
59
+ non-conformant fixture packages, `lint:ci` lints anything under `packages/**`, and the
60
+ gate is a ratchet against a committed count — so a full test run left the next lint run
61
+ one or two warnings over baseline, and the regression pointed at whatever change happened
62
+ to be in the tree. Previously read as fallout from interrupted runs, because an
63
+ interrupted run skips the hook and produces the same evidence.
64
+
65
+ - **A `_layout` file that could not apply rendered its pages without one.** The same
66
+ fail-open the `_middleware` loader had, one function up in the same file, and found by
67
+ looking for the shape rather than by hitting it: an import error was swallowed together
68
+ with the not-found case under "component resolution is best-effort". Best-effort is
69
+ right for _absence_ and wrong for a file that exists and threw — a `_layout.tsx` with a
70
+ typo rendered every page beneath it without its chrome, with no error and no log, and on
71
+ a hot-reload without chrome it had a moment earlier.
72
+
73
+ Absence stays silent and the walk continues outward, as before. An import error stops
74
+ the boot with the path and the original error, and a `_layout` that default-exports no
75
+ component now says so rather than silently handing those pages an outer directory's
76
+ layout instead.
77
+
78
+ Worth noting alongside the `_middleware` fix in 1.15.0: these two sibling conventions
79
+ disagreed about export shape — `_layout` reads `default` and ignored a named export,
80
+ `_middleware` read the named export and ignored `default` — and each failed open on the
81
+ other's spelling. That disagreement is why `export default` was the natural guess in a
82
+ `_middleware.ts` to begin with.
83
+
84
+ - **`zt doctor` says when its outside-in probes did not run.** The response-header and
85
+ WebSocket-transport probes only run with `--url`, and they are the two checks that can
86
+ see what no in-process check can. Being silent about skipping them meant the people who
87
+ most needed them were the ones with no way to learn they existed — a team running
88
+ `doctor` after every deploy for months never saw the duplicate-header check.
89
+
90
+ A line, not a new default: auto-probing `app.url` would print "✓ No duplicated security
91
+ headers" for a site that was merely unreachable, which is the same false confidence the
92
+ secure-headers check was fixed for in 1.15.0.
93
+
11
94
  ## [1.15.0] — 2026-09-04
12
95
 
13
96
  ### 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.15.0",
3
+ "version": "1.15.2",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -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 };
@@ -37,5 +37,13 @@ export function browserEnvDefines(isProduction: boolean): Record<string, string>
37
37
  PROD: isProduction,
38
38
  MODE: isProduction ? "production" : "development",
39
39
  }),
40
+ // The other half of the convention, and the one that costs the most to miss.
41
+ // React and most of npm choose their production build with
42
+ // `process.env.NODE_ENV === "production"`, and Bun.build leaves the expression
43
+ // to the environment it runs in. A deploy that ran `inertia:build --production`
44
+ // without NODE_ENV set shipped React's development build — a 522 KB vendor
45
+ // chunk where 322 KB would do, the DevTools notice in every visitor's console,
46
+ // and full hydration diffs to anyone who looked. Stated here, the flag decides.
47
+ "process.env.NODE_ENV": JSON.stringify(isProduction ? "production" : "development"),
40
48
  };
41
49
  }
@@ -14,6 +14,7 @@ import {
14
14
  DEFAULT_MD_OPTIONS,
15
15
  type BunMarkdownOptions,
16
16
  } from "../helpers/markdown.ts";
17
+ import { config } from "../helpers/config.ts";
17
18
  import type { SessionContract, TransactionContext } from "../contracts/index.ts";
18
19
  import type { ContextRegistry, ContextKey } from "./ContextRegistry.ts";
19
20
 
@@ -868,14 +869,24 @@ const _prototypeMethods: ReadonlyArray<[string, (...args: unknown[]) => unknown]
868
869
  })();
869
870
 
870
871
  /**
871
- * Return `url` only when it belongs to the same `origin`, otherwise `undefined`.
872
+ * Return `url` only when it belongs to the app, otherwise `undefined`.
872
873
  * Guards `ctx.back()` and similar helpers against open-redirect attacks by
873
874
  * rejecting absent, malformed, or cross-origin URLs.
874
875
  *
876
+ * "Belongs to the app" is the request's own `origin` **or the configured
877
+ * `app.url`'s**. The second matters behind a reverse proxy that terminates TLS:
878
+ * the request reaches the process as `http://…`, while every browser sends its
879
+ * `Referer` as `https://…`, so a same-origin check alone refused every one of
880
+ * them and `back()` — and `validate()`'s redirect on failure — fell back to "/"
881
+ * on every request in production. A form with one mistake in it sent the user
882
+ * to the home page, its errors flashed to a page that never shows them. The
883
+ * configured URL is the app's own address by definition, so accepting it opens
884
+ * nothing to anyone else.
885
+ *
875
886
  * @param url - Candidate redirect target (e.g. a `Referer` header value).
876
887
  * @param origin - The current request origin to match against.
877
- * @returns The same-origin `url`, or `undefined` when it is absent, unparseable,
878
- * or points to a different origin.
888
+ * @returns The `url`, or `undefined` when it is absent, unparseable, or points
889
+ * anywhere but this app.
879
890
  *
880
891
  * @internal
881
892
  */
@@ -885,7 +896,20 @@ export function safeRedirectPath(
885
896
  ): string | undefined {
886
897
  if (!url) return undefined;
887
898
  try {
888
- return new URL(url).origin === origin ? url : undefined;
899
+ const target = new URL(url).origin;
900
+ if (target === origin) return url;
901
+ return target === configuredOrigin() ? url : undefined;
902
+ } catch {
903
+ return undefined;
904
+ }
905
+ }
906
+
907
+ /** The configured `app.url`'s origin, or `undefined` off-app or when it is unset or malformed. */
908
+ function configuredOrigin(): string | undefined {
909
+ const base = config.safe("app.url", "") as string;
910
+ if (!base) return undefined;
911
+ try {
912
+ return new URL(base).origin;
889
913
  } catch {
890
914
  return undefined;
891
915
  }
@@ -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
- if (await Bun.file(layoutPath).exists()) {
419
- const href = pathToFileURL(layoutPath).href;
420
- const importUrl = reloadId ? `${href}?t=${reloadId}` : href;
421
- const loadedModule = (await import(importUrl)) as { default?: unknown };
422
- if (typeof loadedModule.default === "function") {
423
- return loadedModule.default as ViewComponent;
424
- }
425
- }
426
- } catch {
427
- // 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
+ );
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 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();