@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/src/index.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Inertia.js adapter for Zerotal — build React or Vue single-page apps with
|
|
3
|
+
* no separate API layer.
|
|
4
|
+
*
|
|
5
|
+
* Controllers return {@link inertia | `inertia('PageName', props)`} instead of
|
|
6
|
+
* JSON or HTML: on a full page load the adapter renders the HTML shell, and on
|
|
7
|
+
* subsequent visits it returns a JSON page object that the Inertia client swaps
|
|
8
|
+
* into the current page component. {@link share | Shared props} expose global
|
|
9
|
+
* data (the authenticated user, flash messages) to every page, the
|
|
10
|
+
* {@link optional}/{@link lazy}/{@link defer} prop helpers control what is sent
|
|
11
|
+
* on partial reloads, and {@link SsrHandler | SSR} renders the first paint on
|
|
12
|
+
* the server.
|
|
13
|
+
*
|
|
14
|
+
* @example Return an Inertia page from a controller
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { inertia } from "@zerotal/inertia";
|
|
17
|
+
*
|
|
18
|
+
* export async function index(ctx) {
|
|
19
|
+
* const users = await User.query().get();
|
|
20
|
+
* return inertia("Users/Index", { users });
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @example Share global props at boot
|
|
25
|
+
* ```ts
|
|
26
|
+
* import { share } from "@zerotal/inertia";
|
|
27
|
+
*
|
|
28
|
+
* share("appName", () => config("app.name"));
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* Register `InertiaProvider`. React consumers need `react`/`react-dom`; Vue
|
|
33
|
+
* consumers need `vue`/`@inertiajs/vue3` (declare whichever you use). Requires
|
|
34
|
+
* **Bun ≥ 1.1**.
|
|
35
|
+
*
|
|
36
|
+
* @packageDocumentation
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// @zerotal/inertia — public API
|
|
40
|
+
|
|
41
|
+
// Side-effect import: augments @zerotal/core RouterMacros so Router.inertia() is typed.
|
|
42
|
+
import "./augment.ts";
|
|
43
|
+
|
|
44
|
+
export { inertiaRoute } from "./route.ts";
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
inertia,
|
|
48
|
+
inertiaStream,
|
|
49
|
+
buildPageObject,
|
|
50
|
+
_setHtmlTemplate,
|
|
51
|
+
_getHtmlTemplate,
|
|
52
|
+
} from "./inertia.ts";
|
|
53
|
+
export { InertiaProvider } from "./provider/InertiaProvider.ts";
|
|
54
|
+
export { InertiaMiddleware } from "./middleware/InertiaMiddleware.ts";
|
|
55
|
+
export { PrecognitionMiddleware } from "./middleware/PrecognitionMiddleware.ts";
|
|
56
|
+
export { sharedProps } from "./SharedProps.ts";
|
|
57
|
+
export { assetVersion, setAssetVersion } from "./version.ts";
|
|
58
|
+
export { generatePageRegistry } from "./PageRegistry.ts";
|
|
59
|
+
export { detectVuePlugin } from "./vuePlugin.ts";
|
|
60
|
+
export type { PageObject, InertiaProviderOptions } from "./types.ts";
|
|
61
|
+
|
|
62
|
+
// Unified facade
|
|
63
|
+
export { Inertia } from "./facades/Inertia.ts";
|
|
64
|
+
|
|
65
|
+
// Prop wrappers + factories (v3 data props)
|
|
66
|
+
export {
|
|
67
|
+
InertiaProp,
|
|
68
|
+
OptionalProp,
|
|
69
|
+
AlwaysProp,
|
|
70
|
+
DeferProp,
|
|
71
|
+
MergeProp,
|
|
72
|
+
InfiniteScrollProp,
|
|
73
|
+
optional,
|
|
74
|
+
lazy,
|
|
75
|
+
always,
|
|
76
|
+
defer,
|
|
77
|
+
merge,
|
|
78
|
+
deepMerge,
|
|
79
|
+
scroll,
|
|
80
|
+
} from "./props/PropTypes.ts";
|
|
81
|
+
export type { PropFactory, MergeConfig, PaginatorLike, ScrollConfig } from "./props/PropTypes.ts";
|
|
82
|
+
export { resolveProps } from "./props/resolveProps.ts";
|
|
83
|
+
export type { ResolvedPage } from "./props/resolveProps.ts";
|
|
84
|
+
|
|
85
|
+
// Shared props registry
|
|
86
|
+
export { share } from "./share.ts";
|
|
87
|
+
|
|
88
|
+
// History encryption
|
|
89
|
+
export { encryptHistory, clearHistory, setHistoryEncryptionDefault } from "./historyState.ts";
|
|
90
|
+
|
|
91
|
+
// External / fragment redirects
|
|
92
|
+
export { location } from "./location.ts";
|
|
93
|
+
|
|
94
|
+
// Config factory
|
|
95
|
+
export { InertiaConfig } from "./config.ts";
|
|
96
|
+
export type { InertiaConfigShape } from "./config.ts";
|
|
97
|
+
|
|
98
|
+
// SSR handler (for direct use or testing)
|
|
99
|
+
export { SsrHandler } from "./SsrHandler.ts";
|
|
100
|
+
|
|
101
|
+
// Typed errors
|
|
102
|
+
export { InertiaError, InertiaTemplateNotLoadedError, InvalidComponentError } from "./errors.ts";
|
package/src/inertia.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { config, RequestContext } from "@zerotal/core";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { InertiaTemplateNotLoadedError, InvalidComponentError } from "./errors.ts";
|
|
4
|
+
import { DEFAULT_PAGES_DIR } from "./config.ts";
|
|
5
|
+
import { sharedProps } from "./SharedProps.ts";
|
|
6
|
+
import { assetVersion } from "./version.ts";
|
|
7
|
+
import { resolveProps } from "./props/resolveProps.ts";
|
|
8
|
+
import { readHistoryFlags } from "./historyState.ts";
|
|
9
|
+
import { allSharedKeys } from "./share.ts";
|
|
10
|
+
import { resolvePageModule, renderInertiaPage } from "./ssr/renderPage.ts";
|
|
11
|
+
import type { PageObject } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build the full Inertia page object for the current request: merges shared props, runs the v3
|
|
15
|
+
* prop-resolution pipeline (partial reloads, lazy/optional/always, defer, merge, once), and attaches
|
|
16
|
+
* history-encryption flags and the shared-prop key list.
|
|
17
|
+
*
|
|
18
|
+
* @param component - Page component name (path relative to the pages dir, without extension), e.g. `"Users/Index"`.
|
|
19
|
+
* @param props - Raw props from the controller; may include prop wrappers (`optional`/`defer`/`merge`/`scroll`/…) that the resolver evaluates.
|
|
20
|
+
* @returns The serialisable {@link PageObject} embedded into the HTML (first load) or returned as JSON (XHR visits).
|
|
21
|
+
* @internal Shared plumbing behind {@link inertia} and {@link inertiaStream}; app authors call those instead.
|
|
22
|
+
*/
|
|
23
|
+
export async function buildPageObject(
|
|
24
|
+
component: string,
|
|
25
|
+
props: Record<string, unknown>,
|
|
26
|
+
): Promise<PageObject> {
|
|
27
|
+
const ctx = RequestContext.get();
|
|
28
|
+
const merged = { ...sharedProps(), ...props };
|
|
29
|
+
const resolved = await resolveProps(merged, ctx.request.headers, component);
|
|
30
|
+
|
|
31
|
+
const page: PageObject = {
|
|
32
|
+
component,
|
|
33
|
+
props: resolved.props,
|
|
34
|
+
url: ctx.url.pathname + (ctx.url.search || ""),
|
|
35
|
+
version: assetVersion(),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (resolved.deferredProps) page.deferredProps = resolved.deferredProps;
|
|
39
|
+
if (resolved.mergeProps) page.mergeProps = resolved.mergeProps;
|
|
40
|
+
if (resolved.prependProps) page.prependProps = resolved.prependProps;
|
|
41
|
+
if (resolved.deepMergeProps) page.deepMergeProps = resolved.deepMergeProps;
|
|
42
|
+
if (resolved.matchPropsOn) page.matchPropsOn = resolved.matchPropsOn;
|
|
43
|
+
if (resolved.scrollProps) page.scrollProps = resolved.scrollProps;
|
|
44
|
+
if (resolved.onceProps) page.onceProps = resolved.onceProps;
|
|
45
|
+
if (resolved.rescuedProps) page.rescuedProps = resolved.rescuedProps;
|
|
46
|
+
|
|
47
|
+
const sharedPresent = allSharedKeys().filter((k) => k in resolved.props);
|
|
48
|
+
if (sharedPresent.length) page.sharedProps = sharedPresent;
|
|
49
|
+
|
|
50
|
+
const history = readHistoryFlags();
|
|
51
|
+
if (history.encryptHistory) page.encryptHistory = true;
|
|
52
|
+
if (history.clearHistory) page.clearHistory = true;
|
|
53
|
+
|
|
54
|
+
return page;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Loaded once at boot by InertiaProvider.onBooting()
|
|
58
|
+
// Never read from disk per-request
|
|
59
|
+
let _htmlTemplate = "";
|
|
60
|
+
let _pagesDir = "";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Cache the root HTML template (the `resources/app.html` shell containing the
|
|
64
|
+
* `<!-- @inertia -->` placeholder). Called once by `InertiaProvider` during boot
|
|
65
|
+
* so `inertia()` / `inertiaStream()` never touch disk per request.
|
|
66
|
+
*
|
|
67
|
+
* @param html - The full HTML document read from the configured `htmlTemplate`.
|
|
68
|
+
* @internal
|
|
69
|
+
*/
|
|
70
|
+
export function _setHtmlTemplate(html: string): void {
|
|
71
|
+
_htmlTemplate = html;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The cached HTML template (empty string until `InertiaProvider` boots).
|
|
76
|
+
*
|
|
77
|
+
* @returns The template string, or `""` if not yet loaded.
|
|
78
|
+
* @internal
|
|
79
|
+
*/
|
|
80
|
+
export function _getHtmlTemplate(): string {
|
|
81
|
+
return _htmlTemplate;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Override the pages directory (absolute path) used to resolve page modules for
|
|
86
|
+
* SSR / streaming. Normally derived from `inertia.pagesDir` config; set explicitly
|
|
87
|
+
* by `InertiaProvider` at boot.
|
|
88
|
+
*
|
|
89
|
+
* @internal
|
|
90
|
+
*/
|
|
91
|
+
export function _setPagesDir(dir: string): void {
|
|
92
|
+
_pagesDir = dir;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The absolute pages directory used to locate page components, falling back to
|
|
97
|
+
* `<cwd>/<inertia.pagesDir>` (default `resources/js/pages`).
|
|
98
|
+
*
|
|
99
|
+
* @internal
|
|
100
|
+
*/
|
|
101
|
+
export function _getPagesDir(): string {
|
|
102
|
+
return _pagesDir || `${process.cwd()}/${config.safe("inertia.pagesDir", DEFAULT_PAGES_DIR)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* In dev (`--dev-worker`) append a cache-busting `?v=<mtime>` to local JS/CSS
|
|
107
|
+
* asset URLs in the served HTML, so the browser fetches a freshly-rebuilt
|
|
108
|
+
* bundle instead of a stale cached copy.
|
|
109
|
+
*
|
|
110
|
+
* The token is the asset file's modification time: unchanged assets keep a
|
|
111
|
+
* stable URL (and stay cached), while a rebuild changes the URL and forces a
|
|
112
|
+
* re-fetch. No-op in production, where assets are served as-is.
|
|
113
|
+
*
|
|
114
|
+
* @internal
|
|
115
|
+
*/
|
|
116
|
+
function _devBustAssets(html: string): string {
|
|
117
|
+
if (!process.argv.includes("--dev-worker")) return html;
|
|
118
|
+
const root = `${process.cwd()}/public`;
|
|
119
|
+
return html.replace(
|
|
120
|
+
/((?:href|src)=")(\/[^"?]+\.(?:js|css))(")/g,
|
|
121
|
+
(match, pre: string, url: string, post: string) => {
|
|
122
|
+
try {
|
|
123
|
+
const mtime = Math.floor(statSync(`${root}${url}`).mtimeMs);
|
|
124
|
+
return `${pre}${url}?v=${mtime}${post}`;
|
|
125
|
+
} catch {
|
|
126
|
+
return match; // asset not found under public/ — leave the URL untouched
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Render an Inertia page server-side and return a streaming HTML Response.
|
|
134
|
+
*
|
|
135
|
+
* Splits the HTML template at `<!-- @inertia -->` and writes the server-rendered
|
|
136
|
+
* component between the prefix and suffix, improving TTFB over `inertia()` since
|
|
137
|
+
* the browser can start parsing the `<head>` before the component is sent.
|
|
138
|
+
*
|
|
139
|
+
* Framework-aware: React pages (`.tsx`) stream chunk-by-chunk via
|
|
140
|
+
* `react-dom/server`'s `renderToReadableStream`; Vue pages (`.vue`) are rendered
|
|
141
|
+
* to a string via `@inertiajs/vue3`'s SSR mode (head tags injected into
|
|
142
|
+
* `<head>`) and flushed after the prefix. The component is resolved from
|
|
143
|
+
* `<cwd>/<inertia.pagesDir>/<component>.{vue,tsx}` (defaults to `resources/js/pages`).
|
|
144
|
+
*
|
|
145
|
+
* Like {@link inertia}, this reads the current request from `RequestContext` and
|
|
146
|
+
* writes the streaming `Response` onto `ctx.response` as a side effect (returns `void`).
|
|
147
|
+
* Reach it from a controller via `Inertia.stream(...)`.
|
|
148
|
+
*
|
|
149
|
+
* @param component - Page component name (path relative to the pages dir, no extension), e.g. `"Posts/Show"`.
|
|
150
|
+
* @param props - Props passed to the page; may include prop wrappers (`optional`/`defer`/`merge`/…).
|
|
151
|
+
* @throws {@link InertiaTemplateNotLoadedError} When the HTML template has not been loaded (InertiaProvider not registered).
|
|
152
|
+
* @throws {@link InvalidComponentError} When the component name contains path-traversal sequences.
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```ts
|
|
156
|
+
* async show(http: HttpContext): Promise<void> {
|
|
157
|
+
* const post = await Post.findOrFail(http.params.id);
|
|
158
|
+
* return Inertia.stream('Posts/Show', { post });
|
|
159
|
+
* }
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
export async function inertiaStream(
|
|
163
|
+
component: string,
|
|
164
|
+
props: Record<string, unknown> = {},
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
const ctx = RequestContext.get();
|
|
167
|
+
|
|
168
|
+
if (!_htmlTemplate) {
|
|
169
|
+
throw new InertiaTemplateNotLoadedError();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (component.includes("..") || component.startsWith("/")) {
|
|
173
|
+
throw new InvalidComponentError(component, "contains an unsafe path");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const pageObject = await buildPageObject(component, props);
|
|
177
|
+
|
|
178
|
+
const [prefix = "", suffix = ""] = _devBustAssets(_htmlTemplate).split("<!-- @inertia -->");
|
|
179
|
+
|
|
180
|
+
const { modPath, framework } = await resolvePageModule(_getPagesDir(), component);
|
|
181
|
+
const encoder = new TextEncoder();
|
|
182
|
+
const responseInit = {
|
|
183
|
+
headers: { "Content-Type": "text/html; charset=utf-8", Vary: "X-Inertia" },
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (framework === "react") {
|
|
187
|
+
// React's stream is just the component's inner HTML, so Zerotal wraps it in
|
|
188
|
+
// the app root and serialises the pageObject into the data-page script.
|
|
189
|
+
const safeJson = JSON.stringify(pageObject)
|
|
190
|
+
.replace(/</g, "\\u003c")
|
|
191
|
+
.replace(/>/g, "\\u003e")
|
|
192
|
+
.replace(/&/g, "\\u0026")
|
|
193
|
+
.replace(/\//g, "\\/");
|
|
194
|
+
const openTag = `<div id="app">`;
|
|
195
|
+
const closeBlock = `</div>\n <script type="application/json" data-page="app">${safeJson}</script>`;
|
|
196
|
+
|
|
197
|
+
const [reactMod, serverMod, pageMod] = await Promise.all([
|
|
198
|
+
import("react") as Promise<{ createElement(type: unknown, props: unknown): unknown }>,
|
|
199
|
+
import("react-dom/server") as Promise<{
|
|
200
|
+
renderToReadableStream(el: unknown): Promise<ReadableStream<Uint8Array>>;
|
|
201
|
+
}>,
|
|
202
|
+
import(modPath) as Promise<{ default: unknown }>,
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
const element = reactMod.createElement(pageMod.default, pageObject.props);
|
|
206
|
+
const reactStream = await serverMod.renderToReadableStream(element);
|
|
207
|
+
|
|
208
|
+
const readable = new ReadableStream<Uint8Array>({
|
|
209
|
+
async start(controller) {
|
|
210
|
+
controller.enqueue(encoder.encode(prefix + openTag));
|
|
211
|
+
const reader = reactStream.getReader();
|
|
212
|
+
try {
|
|
213
|
+
while (true) {
|
|
214
|
+
const { done, value } = await reader.read();
|
|
215
|
+
if (done) break;
|
|
216
|
+
controller.enqueue(value);
|
|
217
|
+
}
|
|
218
|
+
} finally {
|
|
219
|
+
reader.releaseLock();
|
|
220
|
+
}
|
|
221
|
+
controller.enqueue(encoder.encode(closeBlock + suffix));
|
|
222
|
+
controller.close();
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
ctx.response = new Response(readable, responseInit);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Vue: @inertiajs/vue3's SSR mode already emits the full
|
|
231
|
+
// `<div id="app" data-page="…">…</div>` root (the complete pageObject is
|
|
232
|
+
// serialised into data-page), so inject it directly in place of the
|
|
233
|
+
// placeholder — no extra app-div wrapper or data-page script. Any <Head>
|
|
234
|
+
// tags are injected into <head>.
|
|
235
|
+
const { body, head } = await renderInertiaPage(pageObject, modPath, framework);
|
|
236
|
+
const prefixWithHead =
|
|
237
|
+
head.length > 0 ? prefix.replace("</head>", `${head.join("")}</head>`) : prefix;
|
|
238
|
+
|
|
239
|
+
const readable = new ReadableStream<Uint8Array>({
|
|
240
|
+
start(controller) {
|
|
241
|
+
controller.enqueue(encoder.encode(prefixWithHead + body + suffix));
|
|
242
|
+
controller.close();
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
ctx.response = new Response(readable, responseInit);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Render an Inertia page from a controller action — the primary way to return a
|
|
251
|
+
* page from Zerotal's Inertia adapter.
|
|
252
|
+
*
|
|
253
|
+
* You name a client-side page component and hand it a bag of props; Inertia takes
|
|
254
|
+
* care of showing that component with those props, so you build a React/Vue SPA
|
|
255
|
+
* without writing a separate JSON API. The component name is a path (relative to
|
|
256
|
+
* the configured pages dir, without extension) resolving to a page under
|
|
257
|
+
* `resources/js/pages`, e.g. `"Users/Index"` → `resources/js/pages/Users/Index.tsx`.
|
|
258
|
+
*
|
|
259
|
+
* @remarks
|
|
260
|
+
* Reads the current request from `RequestContext` (AsyncLocalStorage) — no `ctx`
|
|
261
|
+
* argument needed — and assigns the outgoing `Response` to `ctx.response` as a side
|
|
262
|
+
* effect, hence the `Promise<void>` return. The two response shapes it produces are
|
|
263
|
+
* what "Inertia" means on the wire:
|
|
264
|
+
*
|
|
265
|
+
* - **First / full-page load** (no `X-Inertia` header): the full HTML shell from
|
|
266
|
+
* the app template with the page object serialised into a `<script data-page>`
|
|
267
|
+
* tag (HTML-escaped so it can't break out of the script). The client-side
|
|
268
|
+
* Inertia runtime boots from it.
|
|
269
|
+
* - **Subsequent visits** (`X-Inertia: true` XHR): a JSON body containing only the
|
|
270
|
+
* page object, carrying `X-Inertia: true` and `Vary: X-Inertia` (the latter stops
|
|
271
|
+
* the browser from caching JSON as HTML and rendering raw JSON on Back/Refresh).
|
|
272
|
+
*
|
|
273
|
+
* Before responding, props are run through the Inertia v3 resolution pipeline (see
|
|
274
|
+
* {@link buildPageObject} / `resolveProps`): the app's shared props (auth, flash,
|
|
275
|
+
* errors, plus anything from {@link share}) are merged in; **partial reloads** honour
|
|
276
|
+
* the client's `only`/`except` headers so a visit can refetch just a few props; and
|
|
277
|
+
* prop wrappers control evaluation — plain values are sent as-is, functions and
|
|
278
|
+
* `optional`/`lazy`/`defer` props are evaluated only when actually included (deferred
|
|
279
|
+
* ones load in a follow-up request), while `always`, `merge`/`deepMerge`, `scroll`,
|
|
280
|
+
* and `once` props are advertised so the client merges rather than replaces.
|
|
281
|
+
*
|
|
282
|
+
* For streaming SSR (better TTFB) use {@link inertiaStream} instead; to force a
|
|
283
|
+
* full-page/external redirect use {@link location}.
|
|
284
|
+
*
|
|
285
|
+
* @param component - Page component name/path relative to the pages dir, without extension (e.g. `"Users/Index"`).
|
|
286
|
+
* @param props - Props for the page. Values may be plain data or prop wrappers (`optional`/`defer`/`merge`/`scroll`/…). Defaults to `{}`.
|
|
287
|
+
* @returns A promise that resolves once `ctx.response` has been set (no value).
|
|
288
|
+
* @throws {@link InertiaTemplateNotLoadedError} On a full-page load when the HTML template has not been loaded (InertiaProvider not registered).
|
|
289
|
+
*
|
|
290
|
+
* @example
|
|
291
|
+
* ```ts
|
|
292
|
+
* // app/controllers/UserController.ts
|
|
293
|
+
* async index(http: HttpContext): Promise<void> {
|
|
294
|
+
* const users = await User.query().orderBy('name').get();
|
|
295
|
+
* return inertia('Users/Index', { users });
|
|
296
|
+
* }
|
|
297
|
+
* ```
|
|
298
|
+
*/
|
|
299
|
+
export async function inertia(
|
|
300
|
+
component: string,
|
|
301
|
+
props: Record<string, unknown> = {},
|
|
302
|
+
): Promise<void> {
|
|
303
|
+
const ctx = RequestContext.get();
|
|
304
|
+
const isInertiaRequest = ctx.request.headers.get("X-Inertia") === "true";
|
|
305
|
+
|
|
306
|
+
const pageObject = await buildPageObject(component, props);
|
|
307
|
+
|
|
308
|
+
if (isInertiaRequest) {
|
|
309
|
+
// Subsequent XHR navigation — JSON only
|
|
310
|
+
// Vary: X-Inertia is CRITICAL — prevents browser from caching
|
|
311
|
+
// JSON as HTML, which would show raw JSON on browser Back/Refresh
|
|
312
|
+
ctx.response = new Response(JSON.stringify(pageObject), {
|
|
313
|
+
headers: {
|
|
314
|
+
"Content-Type": "application/json",
|
|
315
|
+
"X-Inertia": "true",
|
|
316
|
+
Vary: "X-Inertia",
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!_htmlTemplate) {
|
|
323
|
+
throw new InertiaTemplateNotLoadedError();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Inject pageObject into the HTML template.
|
|
327
|
+
// Escape characters that would break HTML parsing inside a script tag.
|
|
328
|
+
// </script> → <\/script>, < → <, > → >
|
|
329
|
+
const safeJson = JSON.stringify(pageObject)
|
|
330
|
+
.replace(/</g, "\\u003c")
|
|
331
|
+
.replace(/>/g, "\\u003e")
|
|
332
|
+
.replace(/&/g, "\\u0026")
|
|
333
|
+
.replace(/\//g, "\\/");
|
|
334
|
+
|
|
335
|
+
const html = _devBustAssets(_htmlTemplate).replace(
|
|
336
|
+
"<!-- @inertia -->",
|
|
337
|
+
`<div id="app"></div>\n ` +
|
|
338
|
+
`<script type="application/json" data-page="app">${safeJson}</script>`,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
ctx.response = new Response(html, {
|
|
342
|
+
headers: {
|
|
343
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
344
|
+
Vary: "X-Inertia",
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
}
|
package/src/location.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { RequestContext } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Redirect to an external URL, or force a full-page (non-Inertia) visit to an internal one.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* A normal Inertia visit expects a JSON page object, so it cannot follow an ordinary
|
|
8
|
+
* redirect off the SPA. For an Inertia XHR this helper responds `409 Conflict` with an
|
|
9
|
+
* `X-Inertia-Location` header, which
|
|
10
|
+
* tells the client to do a hard `window.location` navigation; for a plain request it
|
|
11
|
+
* responds with a standard `302` redirect. Use it for third-party URLs (payment
|
|
12
|
+
* portals, OAuth providers) or any target that must leave the SPA.
|
|
13
|
+
*
|
|
14
|
+
* Reads the current request from `RequestContext` and sets `ctx.response` as a side
|
|
15
|
+
* effect (returns `void`), like {@link inertia}.
|
|
16
|
+
*
|
|
17
|
+
* @param url - The absolute or relative URL to send the browser to.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* // Send the user off to an external billing portal.
|
|
22
|
+
* async billing(http: HttpContext): Promise<void> {
|
|
23
|
+
* const session = await stripe.createBillingSession(Auth.id());
|
|
24
|
+
* return location(session.url);
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function location(url: string): void {
|
|
29
|
+
const ctx = RequestContext.get();
|
|
30
|
+
const isInertia = ctx.request.headers.get("X-Inertia") === "true";
|
|
31
|
+
|
|
32
|
+
ctx.response = isInertia
|
|
33
|
+
? new Response(null, { status: 409, headers: { "X-Inertia-Location": url } })
|
|
34
|
+
: new Response(null, { status: 302, headers: { Location: url } });
|
|
35
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { NextFn, HttpContext } from "@zerotal/core";
|
|
2
|
+
import { BaseMiddleware, withHeaders } from "@zerotal/core";
|
|
3
|
+
import { assetVersion } from "../version.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Must be registered as global middleware in Application.use()
|
|
7
|
+
* or in AppServiceProvider before other middleware.
|
|
8
|
+
*
|
|
9
|
+
* Handles the Inertia protocol requirements:
|
|
10
|
+
*
|
|
11
|
+
* 1. 303 redirect after POST/PUT/DELETE
|
|
12
|
+
* When a non-GET Inertia request results in a standard 302 redirect,
|
|
13
|
+
* Inertia requires a 303 to force the browser to GET the redirect target.
|
|
14
|
+
* Without this, browsers repeat the original POST method on the redirect.
|
|
15
|
+
*
|
|
16
|
+
* 2. Asset version mismatch (409 Conflict)
|
|
17
|
+
* If the client sends X-Inertia-Version that differs from the server's
|
|
18
|
+
* current asset version, respond with 409 and X-Inertia-Location header.
|
|
19
|
+
* This triggers a full page reload on the client to pick up new assets.
|
|
20
|
+
*
|
|
21
|
+
* 3. Always set Vary: X-Inertia on all responses
|
|
22
|
+
* Ensures browser cache treats HTML and JSON versions as distinct.
|
|
23
|
+
*/
|
|
24
|
+
export class InertiaMiddleware extends BaseMiddleware {
|
|
25
|
+
protected options: {} = {};
|
|
26
|
+
|
|
27
|
+
async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
|
|
28
|
+
const isInertia = http.request.headers.get("X-Inertia") === "true";
|
|
29
|
+
|
|
30
|
+
// Asset version check — only for Inertia XHR GET requests
|
|
31
|
+
if (isInertia && http.request.method === "GET") {
|
|
32
|
+
const clientVersion = http.request.headers.get("X-Inertia-Version");
|
|
33
|
+
const serverVersion = assetVersion();
|
|
34
|
+
|
|
35
|
+
if (clientVersion && serverVersion && clientVersion !== serverVersion) {
|
|
36
|
+
// Client has stale assets — force full reload
|
|
37
|
+
return new Response(null, {
|
|
38
|
+
status: 409,
|
|
39
|
+
headers: {
|
|
40
|
+
"X-Inertia-Location": http.url.href,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Run the rest of the pipeline
|
|
47
|
+
const response = await next();
|
|
48
|
+
|
|
49
|
+
// After the pipeline: apply response transformations
|
|
50
|
+
if (!response) return;
|
|
51
|
+
|
|
52
|
+
const status = response.status;
|
|
53
|
+
const method = http.request.method;
|
|
54
|
+
const isRedirect = [301, 302, 303].includes(status);
|
|
55
|
+
|
|
56
|
+
// Fragment redirects: a redirect whose target carries a URL fragment (#...) on an Inertia
|
|
57
|
+
// request becomes a 409 + X-Inertia-Redirect, so the client performs a standard Inertia visit
|
|
58
|
+
// (preserving the fragment) instead of a full reload.
|
|
59
|
+
if (isInertia && isRedirect) {
|
|
60
|
+
const target = response.headers.get("Location") ?? "";
|
|
61
|
+
if (target.includes("#")) {
|
|
62
|
+
// Same header-preservation concern as the 303 branch below.
|
|
63
|
+
const headers = new Headers(response.headers);
|
|
64
|
+
headers.delete("Location");
|
|
65
|
+
headers.set("X-Inertia-Redirect", target);
|
|
66
|
+
headers.set("X-Inertia", "true");
|
|
67
|
+
return new Response(null, { status: 409, headers });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Convert 302 to 303 for non-GET Inertia redirects
|
|
72
|
+
// Inertia requires 303 so browsers use GET on the redirect target
|
|
73
|
+
if (isInertia && [301, 302].includes(status) && method !== "GET") {
|
|
74
|
+
// Carry the original headers over. Rebuilding the Response from just `Location` dropped
|
|
75
|
+
// every other header the handler set — most importantly `Set-Cookie`, so `POST /login`
|
|
76
|
+
// returned a 303 to /dashboard with the session cookie discarded and the user still
|
|
77
|
+
// logged out.
|
|
78
|
+
const headers = new Headers(response.headers);
|
|
79
|
+
headers.set("Location", response.headers.get("Location") ?? "/");
|
|
80
|
+
headers.set("X-Inertia", "true");
|
|
81
|
+
return new Response(null, { status: 303, headers });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Never wrap streaming responses (e.g. SSE) — re-creating the Response
|
|
85
|
+
// object with new Headers would transfer and potentially disturb the
|
|
86
|
+
// ReadableStream body, breaking long-lived connections.
|
|
87
|
+
const contentType = response.headers.get("Content-Type") ?? "";
|
|
88
|
+
if (contentType.startsWith("text/event-stream")) return response;
|
|
89
|
+
|
|
90
|
+
// Ensure Vary: X-Inertia is present on all responses
|
|
91
|
+
// Required so browser cache does not confuse HTML and JSON versions
|
|
92
|
+
if (response.headers.has("Vary")) return response;
|
|
93
|
+
return withHeaders(response, { Vary: "X-Inertia" });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { NextFn, HttpContext } from "@zerotal/core";
|
|
2
|
+
import { BaseMiddleware, withHeaders } from "@zerotal/core";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Precognition support.
|
|
6
|
+
*
|
|
7
|
+
* Inertia "precognition" lets the client validate a form against the server's real rules
|
|
8
|
+
* without running the controller's side effects. The validation itself happens inside
|
|
9
|
+
* `FormRequest.validate()` (which short-circuits with a 204 or 422 when `Precognition: true`); this
|
|
10
|
+
* middleware just guarantees every response to a precognitive request carries `Vary: Precognition`
|
|
11
|
+
* so caches don't mix precognition and normal responses.
|
|
12
|
+
*
|
|
13
|
+
* Register it globally (before route middleware) when using precognitive forms.
|
|
14
|
+
*/
|
|
15
|
+
export class PrecognitionMiddleware extends BaseMiddleware {
|
|
16
|
+
protected options: {} = {};
|
|
17
|
+
|
|
18
|
+
async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
|
|
19
|
+
const isPrecognitive = http.request.headers.get("Precognition") === "true";
|
|
20
|
+
|
|
21
|
+
const response = await next();
|
|
22
|
+
|
|
23
|
+
if (isPrecognitive && response) {
|
|
24
|
+
const vary = response.headers.get("Vary");
|
|
25
|
+
const merged = !vary
|
|
26
|
+
? "Precognition"
|
|
27
|
+
: vary.split(",").some((v) => v.trim() === "Precognition")
|
|
28
|
+
? vary
|
|
29
|
+
: `${vary}, Precognition`;
|
|
30
|
+
return withHeaders(response, { Vary: merged });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|