@zerotal/inertia 1.0.0

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 ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog — @zerotal/inertia
2
+
3
+ All notable changes to this package are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/); this package
5
+ follows the Zerotal monorepo's unified versioning.
6
+
7
+ **Maturity: `beta`**
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [1.0.0] — 2026-08-05
12
+
13
+ _First public release._
14
+
15
+ ### Notes
16
+
17
+ - Conforms to the Zerotal package conventions (provider in `src/provider/`, PascalCase config factory, `ZerotalError`-based errors, test coverage).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zerotal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # @zerotal/inertia
2
+
3
+ > A native, Bun-powered Inertia.js server adapter for React/Vue SPAs without a separate API.
4
+
5
+ Lets you build single-page apps using server-side routing and controllers — no
6
+ separate API layer, no client-side router. Controllers return Inertia page
7
+ responses; the stock `@inertiajs/react` / `@inertiajs/vue3` clients render the
8
+ matching component. Ships full Inertia v3 support: controller-less page routes,
9
+ auto-merged shared props, asset versioning, the data-props layer (partial reloads,
10
+ `optional`/`defer`/`merge`), history encryption, precognition, and optional
11
+ streaming SSR. Flash, errors, and old input are read from the request's session
12
+ when the app runs [`@zerotal/session`](../session/README.md) — no hard
13
+ dependency between the two packages.
14
+
15
+ Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ bun add @zerotal/inertia
21
+ # plus the client of your choice:
22
+ bun add @inertiajs/react react react-dom # React
23
+ # or
24
+ bun add @inertiajs/vue3 vue # Vue
25
+ ```
26
+
27
+ ## Setup
28
+
29
+ Register the provider in `bootstrap/providers.ts`:
30
+
31
+ ```ts
32
+ import { InertiaProvider } from "@zerotal/inertia";
33
+
34
+ export default [InertiaProvider];
35
+ ```
36
+
37
+ `InertiaProvider` loads the HTML template and asset version at boot,
38
+ auto-registers `InertiaMiddleware` (you do **not** add it to `.use()` manually),
39
+ and registers the `POST /__ssr` endpoint when `ssr: true`. Configure it in
40
+ `config/inertia.ts`:
41
+
42
+ ```ts
43
+ // config/inertia.ts
44
+ import { InertiaConfig } from "@zerotal/inertia";
45
+ import { env } from "@zerotal/core";
46
+
47
+ export default InertiaConfig({
48
+ htmlTemplate: "./resources/app.html", // must contain <!-- @inertia -->
49
+ version: env("ASSET_VERSION", "1"), // cache-bust string; bump on each deploy
50
+ assetsUrl: "/assets",
51
+ ssr: false, // set true to enable POST /__ssr
52
+ });
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ Return a page from any controller action. `inertia()` reads the active request
58
+ from context (no `ctx`/`http` argument) and sets the response as a side effect, so
59
+ the action returns `Promise<void>` — always `return inertia(...)`:
60
+
61
+ ```ts
62
+ import type { Context } from "@zerotal/core";
63
+ import { inertia } from "@zerotal/inertia";
64
+ import { Post } from "../models/Post.ts";
65
+
66
+ export class DashboardController {
67
+ async index({ http }: Context): Promise<void> {
68
+ const posts = await Post.query().latest().limit(5).get();
69
+ return inertia("Dashboard", { posts }); // → resources/pages/Dashboard.tsx
70
+ }
71
+ }
72
+ ```
73
+
74
+ Control which props are sent and when with the prop wrappers:
75
+
76
+ ```ts
77
+ import { inertia, optional, defer, merge } from "@zerotal/inertia";
78
+
79
+ return inertia("Users/Index", {
80
+ users: () => User.all(), // lazy — only evaluated when sent
81
+ roles: optional(() => Role.all()), // only on a partial reload that asks for it
82
+ stats: defer(() => computeStats()), // loaded after first paint
83
+ feed: merge(() => Post.paginate(15, page)), // appended on "load more"
84
+ });
85
+ ```
86
+
87
+ Render controller-less pages straight from a route, and send external/full-page
88
+ visits via the unified facade:
89
+
90
+ ```ts
91
+ import { Router } from "@zerotal/core";
92
+ import { Inertia } from "@zerotal/inertia";
93
+
94
+ Router.inertia("/about", "About/Index"); // no props
95
+ Router.inertia("/admin", "Admin/Dashboard", [AuthMiddleware]); // middleware shorthand
96
+
97
+ return Inertia.location("https://billing.stripe.com/session/abc"); // 409 / 302
98
+ ```
99
+
100
+ Register custom shared props once and they merge into every page:
101
+
102
+ ```ts
103
+ import { Inertia } from "@zerotal/inertia";
104
+
105
+ Inertia.share({
106
+ appName: "Acme",
107
+ year: () => new Date().getFullYear(), // evaluated per request
108
+ flags: Inertia.optional(() => FeatureFlag.all()),
109
+ });
110
+ ```
111
+
112
+ ## Exports
113
+
114
+ - `inertia`, `inertiaStream`, `buildPageObject` — render page responses.
115
+ - `Inertia` — unified facade for the Inertia protocol
116
+ (`render`, `share`, `optional`, `defer`, `merge`, `location`, …).
117
+ - `inertiaRoute` / `Router.inertia()` — controller-less page routes.
118
+ - `InertiaProvider`, `InertiaMiddleware`, `PrecognitionMiddleware` — wiring.
119
+ - `sharedProps`, `share` — shared-props registry.
120
+ - Prop wrappers + factories: `InertiaProp`, `OptionalProp`, `AlwaysProp`,
121
+ `DeferProp`, `MergeProp`, `InfiniteScrollProp`, `optional`, `lazy`, `always`,
122
+ `defer`, `merge`, `deepMerge`, `scroll`; plus `resolveProps`.
123
+ - `encryptHistory`, `clearHistory`, `setHistoryEncryptionDefault` — history encryption.
124
+ - `location` — external / fragment redirects.
125
+ - `assetVersion`, `setAssetVersion`, `generatePageRegistry`, `detectVuePlugin`,
126
+ `SsrHandler` — versioning, build, and SSR utilities.
127
+ - `InertiaConfig` / `InertiaConfigShape` — config factory and shape.
128
+ - Types: `PageObject`, `InertiaProviderOptions`, `PropFactory`, `MergeConfig`,
129
+ `PaginatorLike`, `ScrollConfig`, `ResolvedPage`.
130
+ - Errors: `InertiaError`, `InertiaTemplateNotLoadedError`, `InvalidComponentError`.
131
+
132
+ ## Documentation
133
+
134
+ - [Inertia overview](../../docs/inertia/index.md)
135
+ - [Rendering Pages](../../docs/inertia/rendering.md)
136
+ - [Props](../../docs/inertia/props.md)
137
+ - [Middleware & Versioning](../../docs/inertia/middleware.md)
138
+ - [Server-Side Rendering](../../docs/inertia/ssr.md)
139
+ - [CLI & Build](../../docs/inertia/build.md)
140
+ - [References](../../docs/inertia/references.md)
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@zerotal/inertia",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "maturity": "beta",
6
+ "private": false,
7
+ "type": "module",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "files": [
14
+ "CHANGELOG.md",
15
+ "src",
16
+ "!src/**/*.test.ts",
17
+ "!src/**/*.test.tsx",
18
+ "!src/**/*.spec.ts",
19
+ "!src/**/__fixtures__/**"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "engines": {
25
+ "bun": ">=1.3.14"
26
+ },
27
+ "sideEffects": [
28
+ "./src/augment.ts"
29
+ ],
30
+ "scripts": {
31
+ "test": "bun test",
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "dependencies": {
35
+ "@zerotal/core": "1.0.0"
36
+ },
37
+ "peerDependencies": {
38
+ "react": "^18 || ^19",
39
+ "react-dom": "^18 || ^19",
40
+ "vue": "^3",
41
+ "@inertiajs/react": "^1 || ^2",
42
+ "@inertiajs/vue3": "^1 || ^2"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "react": {
46
+ "optional": true
47
+ },
48
+ "react-dom": {
49
+ "optional": true
50
+ },
51
+ "vue": {
52
+ "optional": true
53
+ },
54
+ "@inertiajs/react": {
55
+ "optional": true
56
+ },
57
+ "@inertiajs/vue3": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "devDependencies": {
62
+ "react": "^19.2.7",
63
+ "react-dom": "^19.2.7",
64
+ "typescript": "^5.8.0"
65
+ },
66
+ "description": "Inertia.js adapter for Zerotal — server-driven React/Vue single-page apps.",
67
+ "keywords": [
68
+ "zerotal",
69
+ "bun",
70
+ "typescript",
71
+ "framework"
72
+ ],
73
+ "repository": {
74
+ "type": "git",
75
+ "url": "git+https://github.com/zerotaldev/zerotal.git",
76
+ "directory": "packages/inertia"
77
+ },
78
+ "homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/inertia#readme",
79
+ "bugs": "https://github.com/zerotaldev/zerotal/issues"
80
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Scans the configured pages directory (default `resources/js/pages`) for
3
+ * `**\/*.tsx` using Bun.Glob and generates `resources/js/pages.generated.ts` —
4
+ * a file of dynamic import thunks.
5
+ *
6
+ * Bun.build with splitting:true recognises dynamic import() calls and
7
+ * automatically creates a separate .js chunk per page component.
8
+ * Users download only the chunk for the page they are visiting.
9
+ *
10
+ * The directory is configurable via `inertia.pagesDir` in config/inertia.ts.
11
+ * Import paths in the generated file are computed relative to the generated
12
+ * file's own location (`resources/js/`), so any pages directory works.
13
+ *
14
+ * Run via: bun zt inertia:build
15
+ * Or automatically during InertiaProvider.onBooted() in production.
16
+ */
17
+ import { config } from "@zerotal/core";
18
+ import { frameworkLog } from "@zerotal/core/logger";
19
+ import { relative } from "node:path";
20
+ import { DEFAULT_PAGES_DIR } from "./config.ts";
21
+
22
+ // The generated registry always lives alongside app.tsx so it can be imported
23
+ // from there. Import specifiers are resolved relative to this directory.
24
+ const GENERATED_DIR = "resources/js";
25
+ const GENERATED_FILE = `${GENERATED_DIR}/pages.generated.ts`;
26
+
27
+ /**
28
+ * Scan the pages directory and (re)write `resources/js/pages.generated.ts`, a map of
29
+ * page name → dynamic `import()` thunk that `Bun.build({ splitting: true })` turns into
30
+ * one lazy-loaded chunk per page. Called by the `inertia:build` / `make:page` commands
31
+ * and by `InertiaProvider` at boot in production.
32
+ *
33
+ * @param cwd - Project root to scan and write relative to. Defaults to `process.cwd()`.
34
+ * @param pagesDir - Pages directory relative to `cwd`. Defaults to the `inertia.pagesDir` config (`resources/js/pages`).
35
+ * @returns Resolves once the registry file has been written.
36
+ * @internal Framework scaffolding; not called from application code.
37
+ */
38
+ export async function generatePageRegistry(
39
+ cwd = process.cwd(),
40
+ pagesDir: string = config.safe("inertia.pagesDir", DEFAULT_PAGES_DIR),
41
+ ): Promise<void> {
42
+ // Normalise to a cwd-relative POSIX path without a trailing slash.
43
+ const dir = pagesDir.replace(/\\/g, "/").replace(/\/+$/, "");
44
+
45
+ // Import base is the path from the generated file's directory to the pages
46
+ // directory, e.g. 'resources/js' → 'resources/js/pages' yields 'pages',
47
+ // and 'resources/js' → 'resources/pages' yields '../pages'.
48
+ const importBase = relative(GENERATED_DIR, dir).replace(/\\/g, "/") || ".";
49
+
50
+ // Match both React/JSX pages (.tsx) and Vue Single-File Components (.vue).
51
+ const glob = new Bun.Glob(`${dir}/**/*.{tsx,vue}`);
52
+ const thunks: string[] = [];
53
+
54
+ for await (const file of glob.scan({ cwd })) {
55
+ const normalised = file.replace(/\\/g, "/"); // normalise Windows backslashes
56
+
57
+ // 'resources/js/pages/Users/Index.tsx' → 'Users/Index' (extension dropped
58
+ // for the registry key, but preserved in the import specifier below).
59
+ const rel = normalised.slice(dir.length + 1); // strip the pages directory prefix
60
+ const name = rel.replace(/\.(tsx|vue)$/, "");
61
+
62
+ // Build a relative specifier (with its real extension); ensure a leading
63
+ // './' for same/child dirs so Bun resolves it and the .vue loader can fire.
64
+ const importPath = `${importBase}/${rel}`;
65
+ const specifier = importPath.startsWith(".") ? importPath : `./${importPath}`;
66
+
67
+ // Quote the key only when it isn't a bare identifier — a nested page yields
68
+ // `Users/Index`, which has to stay quoted to be valid.
69
+ const key = /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
70
+
71
+ // Dynamic thunk — NOT `import X from '...'`
72
+ // Bun splits each of these into a separate .js chunk automatically
73
+ thunks.push(` ${key}: () => import(${JSON.stringify(specifier)}),`);
74
+ }
75
+
76
+ if (thunks.length === 0) {
77
+ frameworkLog("inertia").warn("No page components found", { dir });
78
+ }
79
+
80
+ const content = [
81
+ "// Auto-generated by @zerotal/inertia — do not edit manually.",
82
+ "// Regenerate with: bun zt inertia:build",
83
+ "//",
84
+ "// IMPORTANT: These are dynamic import THUNKS, not static imports.",
85
+ "// Bun.build with splitting:true creates one .js chunk per page.",
86
+ "// Converting to static imports will bundle ALL pages into app.js.",
87
+ "",
88
+ "export const pages: Record<string, () => Promise<{ default: unknown }>> = {",
89
+ ...thunks,
90
+ "};",
91
+ // Trailing newline: the file is written on every dev rebuild, and without it
92
+ // a formatter in the app would rewrite it right back, forever.
93
+ "",
94
+ ].join("\n");
95
+
96
+ await Bun.write(`${cwd}/${GENERATED_FILE}`, content);
97
+
98
+ frameworkLog("inertia").info(`Generated page registry: ${thunks.length} pages`, {
99
+ pages: thunks.length,
100
+ });
101
+ }
@@ -0,0 +1,83 @@
1
+ import { RequestContext } from "@zerotal/core";
2
+ import { always } from "./props/PropTypes.ts";
3
+ import { registeredShared } from "./share.ts";
4
+
5
+ /**
6
+ * Data automatically merged into every Inertia page's props.
7
+ * Controllers never need to pass auth or flash — it's always there.
8
+ *
9
+ * `errors` is wrapped in `always()` so it survives partial reloads (the Inertia client always
10
+ * expects an `errors` bag). When the request carries an `X-Inertia-Error-Bag` header, errors are
11
+ * namespaced under that bag. Other shared props (auth/flash/old) are normal props and are therefore
12
+ * subject to partial-reload `only`/`except` filtering, matching Inertia's semantics.
13
+ *
14
+ * @returns The built-in shared props (`auth`, `flash`, `errors`, `old`) merged with anything registered via {@link share}.
15
+ * @internal Called by {@link buildPageObject} to seed every page; app code registers extras via `Inertia.share()`.
16
+ */
17
+ export function sharedProps(): Record<string, unknown> {
18
+ const ctx = RequestContext.get();
19
+ // Session may not exist outside HTTP context — use optional chaining
20
+ const session = (
21
+ ctx as unknown as {
22
+ session?: { get<T>(key: string): T | undefined };
23
+ }
24
+ ).session;
25
+
26
+ // `user` is set on the context by the auth/session middleware (typed via auth's augment).
27
+ const ctxUser = (ctx as unknown as { user?: unknown }).user;
28
+
29
+ const rawErrors = session?.get<Record<string, string>>("errors") ?? {};
30
+ const errorBag = ctx.request.headers.get("X-Inertia-Error-Bag");
31
+ const errors = errorBag ? { [errorBag]: rawErrors } : rawErrors;
32
+
33
+ return {
34
+ auth: {
35
+ // Serialize only scalar properties — model instances with un-loaded @hasMany
36
+ // relations will throw RelationNotLoadedError when JSON.stringify tries
37
+ // to access the relation getter. A plain object is always safe to serialize.
38
+ user: ctxUser ? _serializeUser(ctxUser as Record<string | symbol, unknown>) : null,
39
+ },
40
+ flash: {
41
+ success: session?.get<string>("success") ?? null,
42
+ error: session?.get<string>("error") ?? null,
43
+ },
44
+ errors: always(errors),
45
+ old: session?.get<Record<string, unknown>>("old") ?? {},
46
+ // App-registered shared props via Inertia.share(...).
47
+ ...registeredShared(),
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Convert an AuthenticatedUser (which may be a BaseModel instance) into a
53
+ * plain object containing only scalar values. This prevents JSON.stringify
54
+ * from triggering relation property getters that throw when not eager-loaded.
55
+ *
56
+ * @internal
57
+ */
58
+ function _serializeUser(user: Record<string | symbol, unknown>): Record<string, unknown> {
59
+ // Prefer the model's own toJSON(). That is the method which honours `static hidden`
60
+ // (password, rememberToken), skips `_`-prefixed internals such as `_original` — the
61
+ // untouched DB row — and applies casts. Walking Object.keys() instead meant every
62
+ // authenticated Inertia response embedded the user's password hash and remember token in the
63
+ // page JSON, where it was also cached in history.state.
64
+ if (typeof user["toJSON"] === "function") {
65
+ const json = (user as { toJSON(): unknown }).toJSON();
66
+ if (json && typeof json === "object") return json as Record<string, unknown>;
67
+ }
68
+
69
+ // Fallback for a plain object (a test double, or an app that does not use the ORM).
70
+ const out: Record<string, unknown> = {};
71
+ for (const key of Object.keys(user)) {
72
+ if (key.startsWith("_")) continue;
73
+ try {
74
+ const val = user[key];
75
+ // Skip functions — those are methods, not data.
76
+ if (typeof val === "function") continue;
77
+ out[key] = val;
78
+ } catch {
79
+ // Property getter throws (e.g. un-loaded @hasMany relation) — skip silently
80
+ }
81
+ }
82
+ return out;
83
+ }
@@ -0,0 +1,145 @@
1
+ import { config, safeEqual, type HttpContext } from "@zerotal/core";
2
+ import { Log } from "@zerotal/core/logger";
3
+ import { DEFAULT_PAGES_DIR } from "./config.ts";
4
+ import { resolvePageModule, renderInertiaPage } from "./ssr/renderPage.ts";
5
+
6
+ /** Header carrying the shared secret when the SSR renderer runs off-box. */
7
+ export const SSR_SECRET_HEADER = "x-inertia-ssr-secret";
8
+
9
+ /**
10
+ * Loopback addresses, in the forms Bun reports them.
11
+ *
12
+ * Upstream Inertia runs SSR as a separate process on a private port precisely because this
13
+ * endpoint takes an arbitrary component name and a props bag and does real rendering work
14
+ * with them. Zerotal serves it from the same process, so the equivalent boundary has to be
15
+ * enforced in the handler.
16
+ */
17
+ const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1", "localhost"]);
18
+
19
+ interface SsrRequestBody {
20
+ component: string;
21
+ props: Record<string, unknown>;
22
+ url: string;
23
+ }
24
+
25
+ interface SsrResponseBody {
26
+ body: string;
27
+ head: string[];
28
+ }
29
+
30
+ /**
31
+ * POST /__ssr — Inertia server-side rendering endpoint.
32
+ *
33
+ * Accepts the Inertia SSR wire format: { component, props, url }
34
+ * Returns { body, head } — the rendered HTML string and any head injections.
35
+ *
36
+ * The component is resolved from <cwd>/<pagesDir>/<component>.{vue,tsx}
37
+ * (pagesDir defaults to the configured `inertia.pagesDir`, i.e. resources/js/pages)
38
+ * and rendered with the matching framework runtime — Vue (`@inertiajs/vue3` +
39
+ * `vue/server-renderer`) for `.vue` files, React (`react-dom/server`) for `.tsx`.
40
+ *
41
+ * App authors never instantiate this directly — `InertiaProvider` registers the
42
+ * route when SSR is enabled. Turn it on by setting `ssr: true` in `config/inertia.ts`,
43
+ * and install the server renderer for the framework(s) in use.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * // config/inertia.ts
48
+ * export default InertiaConfig({ ssr: true }); // registers POST /__ssr
49
+ * ```
50
+ */
51
+ export class SsrHandler {
52
+ private readonly _pagesDir: string;
53
+
54
+ /**
55
+ * @param options - `pagesDir` overrides where page components are resolved from; defaults to `<cwd>/<inertia.pagesDir>`.
56
+ */
57
+ constructor(options: { pagesDir?: string } = {}) {
58
+ this._pagesDir =
59
+ options.pagesDir ?? `${process.cwd()}/${config.safe("inertia.pagesDir", DEFAULT_PAGES_DIR)}`;
60
+ }
61
+
62
+ /**
63
+ * Handle a `POST /__ssr` request: validate the `{ component, props, url }` body,
64
+ * reject path traversal, render the page with the matching framework runtime, and
65
+ * respond with `{ body, head }` (or a `4xx`/`500` JSON error).
66
+ *
67
+ * @param http - The request context; its `response` is set as a side effect.
68
+ * @internal Invoked by the router, not called directly.
69
+ */
70
+ async handle(http: HttpContext): Promise<void> {
71
+ if (!SsrHandler.isAuthorized(http)) {
72
+ http.response = Response.json({ message: "Not found." }, { status: 404 });
73
+ return;
74
+ }
75
+
76
+ let body: SsrRequestBody;
77
+ try {
78
+ body = (await http.request.json()) as SsrRequestBody;
79
+ } catch {
80
+ http.response = Response.json({ message: "Invalid JSON body." }, { status: 400 });
81
+ return;
82
+ }
83
+
84
+ const { component, props, url } = body;
85
+
86
+ if (!component) {
87
+ http.response = Response.json({ message: "component is required." }, { status: 422 });
88
+ return;
89
+ }
90
+
91
+ // Reject path traversal
92
+ if (component.includes("..") || component.startsWith("/")) {
93
+ http.response = Response.json({ message: "Invalid component name." }, { status: 422 });
94
+ return;
95
+ }
96
+
97
+ try {
98
+ // Detect the framework from the page file (.vue → Vue, .tsx → React) and
99
+ // render with the matching runtime.
100
+ const { modPath, framework } = await resolvePageModule(this._pagesDir, component);
101
+ const { body, head } = await renderInertiaPage({ component, props, url }, modPath, framework);
102
+
103
+ const response: SsrResponseBody = { body, head };
104
+ http.response = Response.json(response);
105
+ } catch (err) {
106
+ // The detail goes to the log, not the response. Reflecting it made the endpoint a
107
+ // filesystem-path oracle: a render failure named the absolute module path it tried.
108
+ // Logging is best-effort — an error path that can itself throw is worse than the
109
+ // leak it replaced, and the logger is container-resolved.
110
+ const detail = (err as Error).message ?? String(err);
111
+ try {
112
+ Log.error("[Inertia] SSR render failed", { component, error: detail });
113
+ } catch {
114
+ console.error(`[Inertia] SSR render failed for "${component}": ${detail}`);
115
+ }
116
+ http.response = Response.json({ message: "SSR render failed." }, { status: 500 });
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Whether this request may reach the SSR renderer.
122
+ *
123
+ * Two ways in, and nothing else:
124
+ * - the peer is on loopback — the normal case, where the SSR client is the app's own
125
+ * Node/Bun renderer talking to itself;
126
+ * - the request carries {@link SSR_SECRET_HEADER} matching `inertia.ssrSecret`, for a
127
+ * renderer running on another host.
128
+ *
129
+ * Everyone else gets a 404 rather than a 403, because whether this route exists is not
130
+ * information a stranger needs. Without the check the endpoint was unauthenticated,
131
+ * unthrottled, and cheap CPU amplification for anyone who found it.
132
+ *
133
+ * @param http - The request context.
134
+ * @returns `true` when the request is permitted.
135
+ */
136
+ static isAuthorized(http: HttpContext): boolean {
137
+ const secret = config.safe("inertia.ssrSecret", "");
138
+ if (secret) {
139
+ const presented = http.request.headers.get(SSR_SECRET_HEADER);
140
+ if (presented && safeEqual(presented, secret)) return true;
141
+ }
142
+ const ip = http.ip();
143
+ return ip !== undefined && ip !== null && LOOPBACK.has(ip);
144
+ }
145
+ }
package/src/augment.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { MiddlewareClass, RouteRegistration } from "@zerotal/core";
2
+
3
+ declare module "@zerotal/core" {
4
+ interface RouterMacros {
5
+ /**
6
+ * Register a GET route that renders an Inertia page without a controller.
7
+ *
8
+ * @example
9
+ * Router.inertia('/about', 'About/Index');
10
+ * Router.inertia('/home', 'Home/Index', { greeting: 'Hello' });
11
+ * Router.inertia('/admin', 'Admin/Dashboard', [AuthMiddleware]);
12
+ */
13
+ inertia(
14
+ path: string,
15
+ component: string,
16
+ props?: Record<string, unknown> | MiddlewareClass[],
17
+ middleware?: MiddlewareClass[],
18
+ ): RouteRegistration;
19
+ }
20
+ }
21
+
22
+ export {};