@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/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
+ }
@@ -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
+ }