@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 +17 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/package.json +80 -0
- package/src/PageRegistry.ts +101 -0
- package/src/SharedProps.ts +83 -0
- package/src/SsrHandler.ts +145 -0
- package/src/augment.ts +22 -0
- package/src/commands/InertiaBuildCommand.ts +82 -0
- package/src/commands/MakePageCommand.ts +207 -0
- package/src/commands/index.ts +3 -0
- package/src/config.ts +82 -0
- package/src/css.ts +7 -0
- package/src/errors.ts +40 -0
- package/src/facades/Inertia.ts +55 -0
- package/src/global.d.ts +70 -0
- package/src/historyState.ts +82 -0
- package/src/index.ts +102 -0
- package/src/inertia.ts +347 -0
- package/src/location.ts +35 -0
- package/src/middleware/InertiaMiddleware.ts +95 -0
- package/src/middleware/PrecognitionMiddleware.ts +33 -0
- package/src/props/PropTypes.ts +362 -0
- package/src/props/resolveProps.ts +189 -0
- package/src/provider/InertiaProvider.ts +154 -0
- package/src/route.ts +47 -0
- package/src/share.ts +65 -0
- package/src/ssr/renderPage.ts +114 -0
- package/src/types.ts +53 -0
- package/src/version.ts +22 -0
- package/src/vuePlugin.ts +144 -0
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 {};
|