@zerotal/inertia 1.5.1 → 1.6.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 +35 -0
- package/package.json +2 -2
- package/src/config.ts +68 -0
- package/src/devtools/api.ts +68 -0
- package/src/devtools/enabled.ts +64 -0
- package/src/devtools/middleware.ts +84 -0
- package/src/devtools/recorder.ts +270 -0
- package/src/devtools/redact.ts +129 -0
- package/src/devtools/store.ts +126 -0
- package/src/devtools/types.ts +165 -0
- package/src/index.ts +17 -1
- package/src/inertia.ts +5 -0
- package/src/provider/InertiaProvider.ts +16 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,41 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.6.0] — 2026-08-15
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Inertia DevTools support.** Zerotal now implements the server half of the
|
|
16
|
+
[Inertia DevTools protocol](https://inertiajs.com/docs/v3/advanced/devtools-protocol), so the
|
|
17
|
+
browser extension shows a timeline of every request: the component that rendered, the route
|
|
18
|
+
that matched by name, every prop tagged with the wrapper that produced it (`defer` and its
|
|
19
|
+
group, `optional`, `always`, `merge` and its direction, `once`, `scroll`, and which props came
|
|
20
|
+
from `share()`), the resolved values, headers, status, and server time. Follow-up requests are
|
|
21
|
+
grouped with the navigation that caused them, so a page whose deferred props arrive in three
|
|
22
|
+
later requests reads as one batch.
|
|
23
|
+
|
|
24
|
+
On in development, off everywhere else — it follows `devSurfacesEnabled()`, the same gate as the
|
|
25
|
+
stack-trace error page, so a production deploy records nothing and registers no endpoints.
|
|
26
|
+
`INERTIA_DEVTOOLS_ENABLED` or `inertia.devtools.enabled` overrides it.
|
|
27
|
+
|
|
28
|
+
Sensitive values are redacted **before** an entry is stored rather than when it is served, so a
|
|
29
|
+
withheld value is never written down: any key containing `password`, `token`, `secret`, and the
|
|
30
|
+
rest of the built-in list, plus the `authorization` and `cookie` headers. Matching is a
|
|
31
|
+
case-insensitive substring, so `password` also covers `password_confirmation`. Uploads are
|
|
32
|
+
summarised instead of inlined, and a prop graph with a cycle records `[Circular]` rather than
|
|
33
|
+
failing the request. Add your own patterns with `devtools.redact` / `devtools.redactHeaders`.
|
|
34
|
+
|
|
35
|
+
Entries live in a bounded in-memory ring (`devtools.maxEntries`, default 200) in the process
|
|
36
|
+
that recorded them — no disk IO on the request path, no pruning job, and nothing to leak from a
|
|
37
|
+
directory later. `devtools.except` keeps chosen paths out of the timeline; the read API always
|
|
38
|
+
excludes itself. Enabling the recorder outside a development process requires a `devtools.gate`,
|
|
39
|
+
and without one the read API refuses every request rather than defaulting to open.
|
|
40
|
+
|
|
41
|
+
- **`route()` for pages.** `route`, `defineRoutes`, and `hasRoute` are re-exported from this
|
|
42
|
+
package, so a page component imports the URL helper from the package it already uses. See
|
|
43
|
+
`@zerotal/core`'s entry for the full feature. Note that `inertiaRoute()` is a different thing:
|
|
44
|
+
it **registers** a page route on the server, where `route()` **generates a URL** for one.
|
|
45
|
+
|
|
11
46
|
## [1.5.0] — 2026-08-15
|
|
12
47
|
|
|
13
48
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/inertia",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"typecheck": "tsc --noEmit"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@zerotal/core": "1.
|
|
35
|
+
"@zerotal/core": "1.6.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"react": "^18 || ^19",
|
package/src/config.ts
CHANGED
|
@@ -44,6 +44,56 @@ export interface InertiaConfigShape {
|
|
|
44
44
|
* per request via `Inertia.encryptHistory()` / `clearHistory()`. Default: false.
|
|
45
45
|
*/
|
|
46
46
|
encryptHistory: boolean;
|
|
47
|
+
/** DevTools recorder settings. See {@link InertiaDevtoolsConfig}. */
|
|
48
|
+
devtools: InertiaDevtoolsConfig;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Server-side recorder for the Inertia DevTools browser extension.
|
|
53
|
+
*
|
|
54
|
+
* Off unless this process already exposes dev surfaces (`devSurfacesEnabled()` —
|
|
55
|
+
* the same gate as the stack-trace error page). The recorder holds resolved
|
|
56
|
+
* props and request headers in memory and serves them over an unauthenticated
|
|
57
|
+
* local endpoint, so it has to fail closed: a production deploy that sets no
|
|
58
|
+
* `APP_ENV` records nothing and registers no routes.
|
|
59
|
+
*
|
|
60
|
+
* @see https://inertiajs.com/docs/v3/advanced/devtools
|
|
61
|
+
*/
|
|
62
|
+
export interface InertiaDevtoolsConfig {
|
|
63
|
+
/**
|
|
64
|
+
* Turn the recorder on or off explicitly. `null` (the default) follows
|
|
65
|
+
* `devSurfacesEnabled()`, which is what `INERTIA_DEVTOOLS_ENABLED` sets when
|
|
66
|
+
* present. Setting `true` here enables it even in production — do that only
|
|
67
|
+
* behind {@link InertiaDevtoolsConfig.gate}.
|
|
68
|
+
*/
|
|
69
|
+
enabled: boolean | null;
|
|
70
|
+
/** How many entries to keep before the oldest is dropped. Default: 200. */
|
|
71
|
+
maxEntries: number;
|
|
72
|
+
/**
|
|
73
|
+
* Extra prop/body key patterns to redact, on top of the built-in list
|
|
74
|
+
* (`password`, `token`, `secret`, …). Matched as case-insensitive substrings.
|
|
75
|
+
*/
|
|
76
|
+
redact: string[];
|
|
77
|
+
/**
|
|
78
|
+
* Extra header names to redact, on top of the built-in list (`authorization`,
|
|
79
|
+
* `cookie`, …). Matched case-insensitively.
|
|
80
|
+
*/
|
|
81
|
+
redactHeaders: string[];
|
|
82
|
+
/**
|
|
83
|
+
* Path prefixes that are never recorded. The DevTools read API excludes
|
|
84
|
+
* itself regardless; this is for the health checks, metrics scrapes, and
|
|
85
|
+
* dashboards that would otherwise bury the timeline in noise.
|
|
86
|
+
*/
|
|
87
|
+
except: string[];
|
|
88
|
+
/**
|
|
89
|
+
* Authorisation for the read API when the recorder runs outside a dev
|
|
90
|
+
* process. Receives the request; return `true` to allow.
|
|
91
|
+
*
|
|
92
|
+
* Never consulted while `devSurfacesEnabled()` is true, so a developer cannot
|
|
93
|
+
* lock themselves out of their own machine — which is also why enabling the
|
|
94
|
+
* recorder in production without setting this is refused at boot.
|
|
95
|
+
*/
|
|
96
|
+
gate: ((request: Request) => boolean | Promise<boolean>) | null;
|
|
47
97
|
}
|
|
48
98
|
|
|
49
99
|
/** Default directory (relative to the project root) for Inertia page components. */
|
|
@@ -57,8 +107,26 @@ const defaults: InertiaConfigShape = {
|
|
|
57
107
|
ssr: false,
|
|
58
108
|
ssrSecret: "",
|
|
59
109
|
encryptHistory: false,
|
|
110
|
+
devtools: {
|
|
111
|
+
// `null`, not `false`: the recorder follows the process's dev-surface gate
|
|
112
|
+
// unless an app overrides it, so it is on for `zt dev` and off in
|
|
113
|
+
// production without anyone configuring anything.
|
|
114
|
+
enabled: _envFlag("INERTIA_DEVTOOLS_ENABLED"),
|
|
115
|
+
maxEntries: 200,
|
|
116
|
+
redact: [],
|
|
117
|
+
redactHeaders: [],
|
|
118
|
+
except: [],
|
|
119
|
+
gate: null,
|
|
120
|
+
},
|
|
60
121
|
};
|
|
61
122
|
|
|
123
|
+
/** Read a tri-state boolean env var: unset stays `null` so the dev-surface gate decides. */
|
|
124
|
+
function _envFlag(name: string): boolean | null {
|
|
125
|
+
const raw = Bun.env[name];
|
|
126
|
+
if (raw === undefined || raw === "") return null;
|
|
127
|
+
return !["0", "false", "off", "no"].includes(raw.toLowerCase());
|
|
128
|
+
}
|
|
129
|
+
|
|
62
130
|
/**
|
|
63
131
|
* Create a typed Inertia configuration object with defaults.
|
|
64
132
|
*
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read API the extension polls: two endpoints, both JSON, both gated.
|
|
3
|
+
*
|
|
4
|
+
* Registered as raw routes — no middleware pipeline. These are read by a browser
|
|
5
|
+
* extension rather than by the application, so the session, CSRF, and Inertia
|
|
6
|
+
* middleware have nothing to contribute, and running `InertiaDevtoolsMiddleware`
|
|
7
|
+
* over them would record the act of reading the recordings.
|
|
8
|
+
*/
|
|
9
|
+
import { Router } from "@zerotal/core";
|
|
10
|
+
import { devtoolsAuthorized } from "./enabled.ts";
|
|
11
|
+
import { getEntry, listEntries, parseListQuery, clearEntries } from "./store.ts";
|
|
12
|
+
import { DEVTOOLS_API_PREFIX } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
function json(body: unknown, status = 200): Response {
|
|
15
|
+
return new Response(JSON.stringify(body), {
|
|
16
|
+
status,
|
|
17
|
+
headers: {
|
|
18
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
19
|
+
// The timeline changes every request; a cached copy is always the wrong one.
|
|
20
|
+
"Cache-Control": "no-store",
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Register `GET /_inertia/devtools/entries` and `.../entries/:id`.
|
|
27
|
+
*
|
|
28
|
+
* Called by `InertiaProvider` only when the recorder is enabled, so a production
|
|
29
|
+
* process that records nothing also serves nothing — the endpoints do not exist
|
|
30
|
+
* to be probed.
|
|
31
|
+
*/
|
|
32
|
+
export function registerDevtoolsApi(): void {
|
|
33
|
+
Router.raw(
|
|
34
|
+
"GET",
|
|
35
|
+
`${DEVTOOLS_API_PREFIX}/entries`,
|
|
36
|
+
async (request: Request): Promise<Response> => {
|
|
37
|
+
if (!(await devtoolsAuthorized(request))) return json({ error: "Forbidden" }, 403);
|
|
38
|
+
const url = new URL(request.url);
|
|
39
|
+
return json(listEntries(parseListQuery(url.searchParams)));
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
Router.raw(
|
|
44
|
+
"GET",
|
|
45
|
+
`${DEVTOOLS_API_PREFIX}/entries/:id`,
|
|
46
|
+
async (request: Request): Promise<Response> => {
|
|
47
|
+
if (!(await devtoolsAuthorized(request))) return json({ error: "Forbidden" }, 403);
|
|
48
|
+
|
|
49
|
+
// Read the id from the URL rather than from route params: this is a raw
|
|
50
|
+
// route, so nothing has parsed the pattern for us.
|
|
51
|
+
const id = new URL(request.url).pathname.slice(`${DEVTOOLS_API_PREFIX}/entries/`.length);
|
|
52
|
+
const entry = getEntry(decodeURIComponent(id));
|
|
53
|
+
return entry ? json(entry) : json({ error: "Not found" }, 404);
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Not in the protocol, but the panel's "clear" action needs somewhere to go,
|
|
58
|
+
// and a recorder you cannot reset is one you restart the server to clear.
|
|
59
|
+
Router.raw(
|
|
60
|
+
"DELETE",
|
|
61
|
+
`${DEVTOOLS_API_PREFIX}/entries`,
|
|
62
|
+
async (request: Request): Promise<Response> => {
|
|
63
|
+
if (!(await devtoolsAuthorized(request))) return json({ error: "Forbidden" }, 403);
|
|
64
|
+
clearEntries();
|
|
65
|
+
return new Response(null, { status: 204 });
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One place that answers "is the recorder on, and for whom".
|
|
3
|
+
*
|
|
4
|
+
* Separated from the middleware and the read API because both need the same
|
|
5
|
+
* answer, and two gates that can disagree is how a dev-only surface ends up
|
|
6
|
+
* serving request headers in production.
|
|
7
|
+
*/
|
|
8
|
+
import { config, devSurfacesEnabled } from "@zerotal/core";
|
|
9
|
+
import type { InertiaDevtoolsConfig } from "../config.ts";
|
|
10
|
+
import { DEVTOOLS_API_PREFIX } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
const FALLBACK: InertiaDevtoolsConfig = {
|
|
13
|
+
enabled: null,
|
|
14
|
+
maxEntries: 200,
|
|
15
|
+
redact: [],
|
|
16
|
+
redactHeaders: [],
|
|
17
|
+
except: [],
|
|
18
|
+
gate: null,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The `inertia.devtools` config block, with defaults when config is not loaded. */
|
|
22
|
+
export function devtoolsSettings(): InertiaDevtoolsConfig {
|
|
23
|
+
return { ...FALLBACK, ...config.safe("inertia.devtools", FALLBACK) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether the recorder should run at all.
|
|
28
|
+
*
|
|
29
|
+
* `enabled: null` (the default) defers to `devSurfacesEnabled()` — the same gate
|
|
30
|
+
* as the stack-trace error page — so the recorder is on under `zt dev` and off
|
|
31
|
+
* in a production deploy without anyone configuring it. An explicit `true` or
|
|
32
|
+
* `false` wins, which is what makes the recorder testable and what lets an app
|
|
33
|
+
* run it on a staging box behind a gate.
|
|
34
|
+
*/
|
|
35
|
+
export function devtoolsEnabled(): boolean {
|
|
36
|
+
return devtoolsSettings().enabled ?? devSurfacesEnabled();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether this path is recorded.
|
|
41
|
+
*
|
|
42
|
+
* The read API always excludes itself: the extension polls it, and recording
|
|
43
|
+
* those polls would fill the timeline with the act of reading the timeline.
|
|
44
|
+
*/
|
|
45
|
+
export function isRecordablePath(pathname: string): boolean {
|
|
46
|
+
if (pathname.startsWith(DEVTOOLS_API_PREFIX)) return false;
|
|
47
|
+
return !devtoolsSettings().except.some((prefix) => pathname.startsWith(prefix));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether this request may read the recorded entries.
|
|
52
|
+
*
|
|
53
|
+
* A dev process always may — a gate that can lock a developer out of their own
|
|
54
|
+
* machine gets switched off, and then nothing is gated. Anywhere else the app's
|
|
55
|
+
* `gate` decides, and the absence of one is a refusal rather than a default
|
|
56
|
+
* allow: an app that turned the recorder on in production without saying who
|
|
57
|
+
* may read it has not made a decision this code should make for it.
|
|
58
|
+
*/
|
|
59
|
+
export async function devtoolsAuthorized(request: Request): Promise<boolean> {
|
|
60
|
+
if (devSurfacesEnabled()) return true;
|
|
61
|
+
const gate = devtoolsSettings().gate;
|
|
62
|
+
if (!gate) return false;
|
|
63
|
+
return await gate(request);
|
|
64
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The middleware half of the DevTools protocol: open a recording, stamp the
|
|
3
|
+
* correlation headers on the way out, and inject the discovery script tag into
|
|
4
|
+
* the initial HTML.
|
|
5
|
+
*
|
|
6
|
+
* Registered automatically by `InertiaProvider` when the recorder is enabled, so
|
|
7
|
+
* an app adds nothing to its middleware stack. It sits outside `InertiaMiddleware`
|
|
8
|
+
* so the status it records is the one actually sent — the 302→303 rewrite and the
|
|
9
|
+
* version-mismatch 409 both happen in there.
|
|
10
|
+
*/
|
|
11
|
+
import type { HttpContext, NextFn } from "@zerotal/core";
|
|
12
|
+
import { BaseMiddleware } from "@zerotal/core";
|
|
13
|
+
import { beginRecording, finishRecording } from "./recorder.ts";
|
|
14
|
+
import { devtoolsEnabled, isRecordablePath } from "./enabled.ts";
|
|
15
|
+
import { DEVTOOLS_REQUEST_HEADERS, DEVTOOLS_RESPONSE_HEADERS } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The discovery script tag for the initial full-page response.
|
|
19
|
+
*
|
|
20
|
+
* The extension needs the entry id before any XHR happens, and the first
|
|
21
|
+
* document load has no earlier response to have carried it. The id is a UUID,
|
|
22
|
+
* so it needs no escaping — but it is JSON-encoded anyway, because the protocol
|
|
23
|
+
* specifies a JSON string and a bare id would not parse as one.
|
|
24
|
+
*/
|
|
25
|
+
export function devtoolsScriptTag(id: string): string {
|
|
26
|
+
return `<script data-inertia-devtools-id type="application/json">${JSON.stringify(id)}</script>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Insert the discovery tag before `</head>`, or before `</body>` if there is no head. */
|
|
30
|
+
function injectScriptTag(html: string, id: string): string {
|
|
31
|
+
const tag = devtoolsScriptTag(id);
|
|
32
|
+
if (html.includes("</head>")) return html.replace("</head>", `${tag}</head>`);
|
|
33
|
+
if (html.includes("</body>")) return html.replace("</body>", `${tag}</body>`);
|
|
34
|
+
return html + tag;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Records each request for the Inertia DevTools extension.
|
|
39
|
+
*
|
|
40
|
+
* @see https://inertiajs.com/docs/v3/advanced/devtools-protocol
|
|
41
|
+
*/
|
|
42
|
+
export class InertiaDevtoolsMiddleware extends BaseMiddleware {
|
|
43
|
+
protected options: Record<string, never> = {};
|
|
44
|
+
|
|
45
|
+
async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
|
|
46
|
+
if (!devtoolsEnabled() || !isRecordablePath(http.url.pathname)) return next();
|
|
47
|
+
|
|
48
|
+
const id = beginRecording(http);
|
|
49
|
+
|
|
50
|
+
// The batch root: the id the client says started this chain, or this entry
|
|
51
|
+
// itself when it is the start. Echoing it back lets the extension group a
|
|
52
|
+
// navigation with the deferred-prop requests it triggers.
|
|
53
|
+
const parentOut = http.request.headers.get(DEVTOOLS_REQUEST_HEADERS.parent) ?? id;
|
|
54
|
+
|
|
55
|
+
const response = await next();
|
|
56
|
+
if (!response) return;
|
|
57
|
+
|
|
58
|
+
const contentType = response.headers.get("Content-Type") ?? "";
|
|
59
|
+
|
|
60
|
+
// Never touch a stream: rebuilding the Response to add a header would
|
|
61
|
+
// transfer the body and can stall a long-lived SSE connection.
|
|
62
|
+
if (contentType.startsWith("text/event-stream")) return response;
|
|
63
|
+
|
|
64
|
+
let out = response;
|
|
65
|
+
|
|
66
|
+
// Inject discovery into the initial document. Reading the body is safe here
|
|
67
|
+
// — an HTML page is buffered, not streamed — and it is the only way the
|
|
68
|
+
// extension learns the id before the first XHR.
|
|
69
|
+
if (contentType.includes("text/html")) {
|
|
70
|
+
const html = await response.text();
|
|
71
|
+
out = new Response(injectScriptTag(html, id), {
|
|
72
|
+
status: response.status,
|
|
73
|
+
statusText: response.statusText,
|
|
74
|
+
headers: response.headers,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
out.headers.set(DEVTOOLS_RESPONSE_HEADERS.id, id);
|
|
79
|
+
out.headers.set(DEVTOOLS_RESPONSE_HEADERS.parentOut, parentOut);
|
|
80
|
+
|
|
81
|
+
finishRecording(http, out, parentOut);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recorder: turns one request/response pair into one {@link DevtoolsEntry}.
|
|
3
|
+
*
|
|
4
|
+
* Recording is split across the request because the data is. The middleware
|
|
5
|
+
* knows the method, URL, timing and status; only `buildPageObject` knows the
|
|
6
|
+
* component and which prop wrapper produced which key, and it runs in the
|
|
7
|
+
* middle. So the middleware opens a recording, the page builder enriches it
|
|
8
|
+
* while it has the props in hand, and the middleware closes and stores it.
|
|
9
|
+
*
|
|
10
|
+
* The in-flight recording is parked on the `HttpContext`, not in a module
|
|
11
|
+
* variable: concurrent requests share this process, and a module-level "current
|
|
12
|
+
* entry" would attribute one request's props to another's response under any
|
|
13
|
+
* real load.
|
|
14
|
+
*/
|
|
15
|
+
import { RequestContext, Router, config } from "@zerotal/core";
|
|
16
|
+
import type { HttpContext } from "@zerotal/core";
|
|
17
|
+
import {
|
|
18
|
+
AlwaysProp,
|
|
19
|
+
DeferProp,
|
|
20
|
+
InertiaProp,
|
|
21
|
+
InfiniteScrollProp,
|
|
22
|
+
MergeProp,
|
|
23
|
+
OptionalProp,
|
|
24
|
+
} from "../props/PropTypes.ts";
|
|
25
|
+
import { allSharedKeys } from "../share.ts";
|
|
26
|
+
import {
|
|
27
|
+
DEFAULT_REDACTED_HEADERS,
|
|
28
|
+
DEFAULT_REDACTED_KEYS,
|
|
29
|
+
redactHeaders,
|
|
30
|
+
redactValue,
|
|
31
|
+
} from "./redact.ts";
|
|
32
|
+
import { putEntry } from "./store.ts";
|
|
33
|
+
import { DEVTOOLS_REQUEST_HEADERS, type BodyCapture } from "./types.ts";
|
|
34
|
+
import type { DevtoolsEntry, DevtoolsRequestType, PropMeta } from "./types.ts";
|
|
35
|
+
import { devtoolsSettings } from "./enabled.ts";
|
|
36
|
+
|
|
37
|
+
/** Key under which the in-flight recording is parked on the HttpContext. */
|
|
38
|
+
const RECORDING_KEY = "inertia:devtools:recording";
|
|
39
|
+
|
|
40
|
+
/** The mutable half of an entry, filled in as the request proceeds. */
|
|
41
|
+
interface Recording {
|
|
42
|
+
id: string;
|
|
43
|
+
startedAt: number;
|
|
44
|
+
timestamp: string;
|
|
45
|
+
utime: number;
|
|
46
|
+
component: string | null;
|
|
47
|
+
props: Record<string, PropMeta>;
|
|
48
|
+
propValues: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A collision-resistant id. `crypto.randomUUID()` rather than a ULID: the
|
|
53
|
+
* protocol asks only for collision resistance, and this needs no dependency.
|
|
54
|
+
*/
|
|
55
|
+
function newId(): string {
|
|
56
|
+
return crypto.randomUUID();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Classify the request from the headers the client sent. */
|
|
60
|
+
function requestTypeOf(request: Request, headers: Headers): DevtoolsRequestType {
|
|
61
|
+
if (headers.get(DEVTOOLS_REQUEST_HEADERS.deferred) === "1") return "deferred";
|
|
62
|
+
if (headers.get(DEVTOOLS_REQUEST_HEADERS.poll) === "1") return "poll";
|
|
63
|
+
if (headers.get("X-Inertia-Precognition") === "true") return "precognition";
|
|
64
|
+
if (headers.get("X-Inertia-Prefetch") === "true") return "prefetch";
|
|
65
|
+
if (headers.get("X-Inertia-Partial-Component")) return "partial";
|
|
66
|
+
// No `X-Inertia` at all means the browser asked for the document itself —
|
|
67
|
+
// the first load, before the client adapter exists to set the header.
|
|
68
|
+
if (headers.get("X-Inertia") !== "true") return "initial";
|
|
69
|
+
return request.method === "GET" ? "navigate" : "http";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Begin recording. Returns the entry id, which the caller sets as a response header. */
|
|
73
|
+
export function beginRecording(http: HttpContext): string {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const recording: Recording = {
|
|
76
|
+
id: newId(),
|
|
77
|
+
startedAt: performance.now(),
|
|
78
|
+
timestamp: new Date(now).toISOString(),
|
|
79
|
+
utime: now / 1000,
|
|
80
|
+
component: null,
|
|
81
|
+
props: {},
|
|
82
|
+
propValues: {},
|
|
83
|
+
};
|
|
84
|
+
http.setInternal(RECORDING_KEY, recording);
|
|
85
|
+
return recording.id;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The in-flight recording for this request, if one was opened. */
|
|
89
|
+
function currentRecording(): Recording | undefined {
|
|
90
|
+
return RequestContext.tryGet()?.getInternal<Recording>(RECORDING_KEY);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Describe one prop from the wrapper that produced it.
|
|
95
|
+
*
|
|
96
|
+
* Mirrors the branches in `resolveProps`; a prop the resolver treats specially
|
|
97
|
+
* and this does not would show up in the panel as an ordinary prop, which is
|
|
98
|
+
* the kind of quiet wrongness a debugging tool must not have.
|
|
99
|
+
*/
|
|
100
|
+
function describeProp(value: unknown, key: string, shared: Set<string>): PropMeta {
|
|
101
|
+
const meta: PropMeta = {};
|
|
102
|
+
if (shared.has(key)) meta.shared = true;
|
|
103
|
+
|
|
104
|
+
if (!(value instanceof InertiaProp)) return meta;
|
|
105
|
+
|
|
106
|
+
if (value instanceof AlwaysProp) meta.inertiaType = "always";
|
|
107
|
+
else if (value instanceof DeferProp) {
|
|
108
|
+
meta.inertiaType = "defer";
|
|
109
|
+
meta.deferGroup = value.group;
|
|
110
|
+
} else if (value instanceof InfiniteScrollProp) meta.inertiaType = "scroll";
|
|
111
|
+
else if (value instanceof MergeProp) meta.inertiaType = "merge";
|
|
112
|
+
else if (value instanceof OptionalProp) meta.inertiaType = "optional";
|
|
113
|
+
|
|
114
|
+
if (value.isOnce) {
|
|
115
|
+
// `once` is a modifier, not a wrapper — a prop can be both `merge()` and
|
|
116
|
+
// `once()`. Recorded as the flag so the wrapper's own type survives, and
|
|
117
|
+
// only claimed as the *type* when nothing else did.
|
|
118
|
+
meta.once = true;
|
|
119
|
+
meta.inertiaType ??= "once";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (value.shouldMerge) {
|
|
123
|
+
const cfg = value.mergeConfig();
|
|
124
|
+
if (cfg.deep) meta.deepMerge = true;
|
|
125
|
+
meta.mergeDirection = cfg.prependRoot || cfg.prependPaths.length > 0 ? "prepend" : "append";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return meta;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Attach the component and prop metadata to the in-flight recording.
|
|
133
|
+
*
|
|
134
|
+
* Called from `buildPageObject`, which is the only place that sees the raw prop
|
|
135
|
+
* wrappers *and* the resolved values. A no-op when nothing is recording, so the
|
|
136
|
+
* call site needs no guard of its own.
|
|
137
|
+
*
|
|
138
|
+
* @param component - The page component name.
|
|
139
|
+
* @param raw - The unresolved prop map (shared + controller), wrappers intact.
|
|
140
|
+
* @param resolved - The resolved values actually sent to the client.
|
|
141
|
+
* @param rescued - Keys reported in `rescuedProps`.
|
|
142
|
+
*/
|
|
143
|
+
export function recordPage(
|
|
144
|
+
component: string,
|
|
145
|
+
raw: Record<string, unknown>,
|
|
146
|
+
resolved: Record<string, unknown>,
|
|
147
|
+
rescued: readonly string[] = [],
|
|
148
|
+
): void {
|
|
149
|
+
const recording = currentRecording();
|
|
150
|
+
if (!recording) return;
|
|
151
|
+
|
|
152
|
+
const settings = devtoolsSettings();
|
|
153
|
+
const keyPatterns = [...DEFAULT_REDACTED_KEYS, ...settings.redact];
|
|
154
|
+
const shared = new Set(allSharedKeys());
|
|
155
|
+
const rescuedSet = new Set(rescued);
|
|
156
|
+
|
|
157
|
+
recording.component = component;
|
|
158
|
+
|
|
159
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
160
|
+
const meta = describeProp(value, key, shared);
|
|
161
|
+
if (rescuedSet.has(key)) meta.rescued = true;
|
|
162
|
+
recording.props[key] = meta;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
166
|
+
recording.propValues[key] = redactValue(value, keyPatterns);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Look up the matched route by path, for the entry's `route` block. */
|
|
171
|
+
function routeInfoFor(http: HttpContext): DevtoolsEntry["route"] {
|
|
172
|
+
const pathname = http.url.pathname;
|
|
173
|
+
|
|
174
|
+
// `namedRoutes` is name → pattern; the panel wants the reverse. Prefer an
|
|
175
|
+
// exact pattern match, then the first pattern whose params fit — a named
|
|
176
|
+
// route is more useful in the panel than a bare URI, and the lookup happens
|
|
177
|
+
// once per recorded request.
|
|
178
|
+
let name: string | null = null;
|
|
179
|
+
let uri = pathname;
|
|
180
|
+
for (const [routeName, pattern] of Router.namedRoutes) {
|
|
181
|
+
if (pattern === pathname) {
|
|
182
|
+
name = routeName;
|
|
183
|
+
uri = pattern;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
const regex = new RegExp(
|
|
187
|
+
`^${pattern.replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, "[^/]+").replace(/\*/g, ".*")}$`,
|
|
188
|
+
);
|
|
189
|
+
if (name === null && regex.test(pathname)) {
|
|
190
|
+
name = routeName;
|
|
191
|
+
uri = pattern;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { uri, name, action: null };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Capture a response body, refusing anything that should not be inlined. */
|
|
199
|
+
function captureResponseBody(response: Response, isInertia: boolean): BodyCapture {
|
|
200
|
+
if (!isInertia) return { status: "omitted", reason: "non-inertia-response" };
|
|
201
|
+
|
|
202
|
+
const type = response.headers.get("Content-Type") ?? "";
|
|
203
|
+
if (type.startsWith("text/event-stream")) return { status: "omitted", reason: "streamed" };
|
|
204
|
+
if (!type.includes("json")) return { status: "omitted", reason: "non-textual" };
|
|
205
|
+
|
|
206
|
+
// The body is not read here: consuming the stream would empty it before the
|
|
207
|
+
// client got it, and cloning a response on every request costs more than the
|
|
208
|
+
// panel gains. The page object is already recorded field-by-field in `props`
|
|
209
|
+
// and `propValues`, which is the part anyone actually reads.
|
|
210
|
+
return { status: "empty" };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Close the recording and store it.
|
|
215
|
+
*
|
|
216
|
+
* @param http - The request context the recording was opened on.
|
|
217
|
+
* @param response - The response about to be returned.
|
|
218
|
+
* @param parentOut - The batch root id, echoed into `__meta.batchId`.
|
|
219
|
+
*/
|
|
220
|
+
export function finishRecording(
|
|
221
|
+
http: HttpContext,
|
|
222
|
+
response: Response,
|
|
223
|
+
parentOut: string | null,
|
|
224
|
+
): void {
|
|
225
|
+
const recording = currentRecording();
|
|
226
|
+
if (!recording) return;
|
|
227
|
+
|
|
228
|
+
const settings = devtoolsSettings();
|
|
229
|
+
const headerPatterns = [...DEFAULT_REDACTED_HEADERS, ...settings.redactHeaders];
|
|
230
|
+
const requestHeaders = http.request.headers;
|
|
231
|
+
const isInertia =
|
|
232
|
+
requestHeaders.get("X-Inertia") === "true" ||
|
|
233
|
+
(response.headers.get("Content-Type") ?? "").includes("json");
|
|
234
|
+
|
|
235
|
+
const entry: DevtoolsEntry = {
|
|
236
|
+
__meta: {
|
|
237
|
+
id: recording.id,
|
|
238
|
+
method: http.request.method,
|
|
239
|
+
url: http.url.href,
|
|
240
|
+
status: response.status,
|
|
241
|
+
requestType: requestTypeOf(http.request, requestHeaders),
|
|
242
|
+
component: recording.component,
|
|
243
|
+
timestamp: recording.timestamp,
|
|
244
|
+
utime: recording.utime,
|
|
245
|
+
tabUuid: requestHeaders.get(DEVTOOLS_REQUEST_HEADERS.tab),
|
|
246
|
+
batchId: parentOut,
|
|
247
|
+
serverTimingMs: Math.round((performance.now() - recording.startedAt) * 1000) / 1000,
|
|
248
|
+
redirectLocation: response.headers.get("Location"),
|
|
249
|
+
visitId: requestHeaders.get(DEVTOOLS_REQUEST_HEADERS.visit),
|
|
250
|
+
},
|
|
251
|
+
http: {
|
|
252
|
+
requestHeaders: redactHeaders(requestHeaders, headerPatterns),
|
|
253
|
+
responseHeaders: redactHeaders(response.headers, headerPatterns),
|
|
254
|
+
// Same reasoning as the response body: reading the request stream here
|
|
255
|
+
// would consume it, and by this point the handler has already had it.
|
|
256
|
+
requestBody: { status: "omitted", reason: "non-inertia-request" },
|
|
257
|
+
responseBody: captureResponseBody(response, isInertia),
|
|
258
|
+
},
|
|
259
|
+
props: recording.props,
|
|
260
|
+
route: routeInfoFor(http),
|
|
261
|
+
renderSource: null,
|
|
262
|
+
componentPath: recording.component
|
|
263
|
+
? `${config.safe("inertia.pagesDir", "resources/js/pages")}/${recording.component}`
|
|
264
|
+
: null,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
if (Object.keys(recording.propValues).length > 0) entry.propValues = recording.propValues;
|
|
268
|
+
|
|
269
|
+
putEntry(entry);
|
|
270
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction — the part of the recorder that decides what never reaches the panel.
|
|
3
|
+
*
|
|
4
|
+
* DevTools entries are readable by anyone who can reach the read API, and they
|
|
5
|
+
* are recorded from live requests, so they would otherwise contain the session
|
|
6
|
+
* cookie, the `Authorization` header, and whatever a login form just posted.
|
|
7
|
+
* Redaction runs before an entry is stored, not when it is served: an entry that
|
|
8
|
+
* was never written cannot leak from a store that is later exposed by mistake.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The marker the protocol uses for a value that was withheld. */
|
|
12
|
+
export const REDACTED = "[REDACTED]";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Header names always withheld. Compared case-insensitively — HTTP header names
|
|
16
|
+
* are case-insensitive and `Headers` normalises to lower case, but an adapter
|
|
17
|
+
* that only matched the canonical spelling would be one `AUTHORIZATION` away
|
|
18
|
+
* from leaking a bearer token.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_REDACTED_HEADERS: readonly string[] = [
|
|
21
|
+
"authorization",
|
|
22
|
+
"cookie",
|
|
23
|
+
"set-cookie",
|
|
24
|
+
"proxy-authorization",
|
|
25
|
+
"x-api-key",
|
|
26
|
+
"x-csrf-token",
|
|
27
|
+
"x-xsrf-token",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Prop and body keys always withheld. Matched as case-insensitive *substrings*
|
|
32
|
+
* so `password`, `password_confirmation`, and `currentPassword` are all caught
|
|
33
|
+
* by one entry — an exact-match list is a list of the spellings someone thought
|
|
34
|
+
* of, and the one that leaks is always the one they did not.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_REDACTED_KEYS: readonly string[] = [
|
|
37
|
+
"password",
|
|
38
|
+
"secret",
|
|
39
|
+
"token",
|
|
40
|
+
"authorization",
|
|
41
|
+
"api_key",
|
|
42
|
+
"apikey",
|
|
43
|
+
"credit_card",
|
|
44
|
+
"card_number",
|
|
45
|
+
"cvv",
|
|
46
|
+
"ssn",
|
|
47
|
+
"private_key",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** True when `key` matches any of `patterns` as a case-insensitive substring. */
|
|
51
|
+
export function isSensitiveKey(key: string, patterns: readonly string[]): boolean {
|
|
52
|
+
const lower = key.toLowerCase();
|
|
53
|
+
return patterns.some((pattern) => lower.includes(pattern.toLowerCase()));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Copy a `Headers` into a plain object, replacing sensitive values.
|
|
58
|
+
*
|
|
59
|
+
* @param headers - The request or response headers.
|
|
60
|
+
* @param patterns - Header names to withhold, case-insensitive.
|
|
61
|
+
*/
|
|
62
|
+
export function redactHeaders(
|
|
63
|
+
headers: Headers,
|
|
64
|
+
patterns: readonly string[] = DEFAULT_REDACTED_HEADERS,
|
|
65
|
+
): Record<string, string> {
|
|
66
|
+
const out: Record<string, string> = {};
|
|
67
|
+
headers.forEach((value, key) => {
|
|
68
|
+
out[key] = isSensitiveKey(key, patterns) ? REDACTED : value;
|
|
69
|
+
});
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Deep-copy a value, replacing sensitive keys and summarising things that must
|
|
75
|
+
* not be inlined.
|
|
76
|
+
*
|
|
77
|
+
* Guards three separate hazards, all of which have to be handled before the
|
|
78
|
+
* value is serialised:
|
|
79
|
+
*
|
|
80
|
+
* - **Sensitive keys** become `[REDACTED]`, at any depth.
|
|
81
|
+
* - **Cycles** become `"[Circular]"`. A prop bag holding a model with a
|
|
82
|
+
* back-reference to its parent is ordinary, and `JSON.stringify` throws on it.
|
|
83
|
+
* - **Files and blobs** become a short summary. A 4 MB upload does not belong
|
|
84
|
+
* in a debug entry, and `File` does not survive `JSON.stringify` anyway.
|
|
85
|
+
*
|
|
86
|
+
* @param value - Any prop value.
|
|
87
|
+
* @param patterns - Key patterns to withhold.
|
|
88
|
+
* @param seen - Internal: the ancestor set for cycle detection.
|
|
89
|
+
* @param depth - Internal: current depth, bounded to keep a deep graph from stalling the request.
|
|
90
|
+
*/
|
|
91
|
+
export function redactValue(
|
|
92
|
+
value: unknown,
|
|
93
|
+
patterns: readonly string[] = DEFAULT_REDACTED_KEYS,
|
|
94
|
+
seen: WeakSet<object> = new WeakSet(),
|
|
95
|
+
depth = 0,
|
|
96
|
+
): unknown {
|
|
97
|
+
if (value === null || typeof value !== "object") return value;
|
|
98
|
+
|
|
99
|
+
// Bounded rather than unbounded: recording is on the request path, and a
|
|
100
|
+
// pathological object graph should slow nothing down.
|
|
101
|
+
if (depth > 12) return "[Max depth]";
|
|
102
|
+
|
|
103
|
+
if (value instanceof Date) return value.toISOString();
|
|
104
|
+
if (value instanceof File) {
|
|
105
|
+
return `[File: ${value.name}, ${value.size} bytes, ${value.type || "unknown"}]`;
|
|
106
|
+
}
|
|
107
|
+
if (value instanceof Blob) return `[Blob: ${value.size} bytes, ${value.type || "unknown"}]`;
|
|
108
|
+
|
|
109
|
+
if (seen.has(value)) return "[Circular]";
|
|
110
|
+
seen.add(value);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
if (Array.isArray(value)) {
|
|
114
|
+
return value.map((item) => redactValue(item, patterns, seen, depth + 1));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const out: Record<string, unknown> = {};
|
|
118
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
119
|
+
out[key] = isSensitiveKey(key, patterns)
|
|
120
|
+
? REDACTED
|
|
121
|
+
: redactValue(entry, patterns, seen, depth + 1);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
} finally {
|
|
125
|
+
// Released on the way out so a value that legitimately appears twice as a
|
|
126
|
+
// sibling is not mistaken for a cycle — only true ancestors count.
|
|
127
|
+
seen.delete(value);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where recorded entries live between being written and being read.
|
|
3
|
+
*
|
|
4
|
+
* A bounded in-memory ring, not a directory of files. The Laravel adapter
|
|
5
|
+
* persists to `storage/inertia-devtools` because a PHP process does not outlive
|
|
6
|
+
* the request that created the entry; a Bun server does, so the entries are
|
|
7
|
+
* simply kept in the process that recorded them. That removes disk IO from the
|
|
8
|
+
* request path, removes a pruning job, and removes the question of what happens
|
|
9
|
+
* when two workers write the same directory.
|
|
10
|
+
*
|
|
11
|
+
* What it costs: entries do not survive a restart, so a crash takes its own
|
|
12
|
+
* timeline with it. For a tool you read while the server is up, that is the
|
|
13
|
+
* right trade — and `zt dev` restarts on every save, which would invalidate a
|
|
14
|
+
* persisted timeline anyway.
|
|
15
|
+
*
|
|
16
|
+
* The store is capped by count. An unbounded recorder is a memory leak with a
|
|
17
|
+
* friendly name: a long-lived dev server serving a busy SPA would hold every
|
|
18
|
+
* prop bag it ever rendered.
|
|
19
|
+
*/
|
|
20
|
+
import type { DevtoolsEntry, DevtoolsListQuery, DevtoolsRequestType } from "./types.ts";
|
|
21
|
+
|
|
22
|
+
/** How many entries are kept before the oldest is dropped. */
|
|
23
|
+
export const DEFAULT_MAX_ENTRIES = 200;
|
|
24
|
+
|
|
25
|
+
let _entries: DevtoolsEntry[] = [];
|
|
26
|
+
let _max = DEFAULT_MAX_ENTRIES;
|
|
27
|
+
|
|
28
|
+
/** Set the cap and drop anything already over it. */
|
|
29
|
+
export function setMaxEntries(max: number): void {
|
|
30
|
+
_max = Math.max(1, Math.floor(max));
|
|
31
|
+
if (_entries.length > _max) _entries = _entries.slice(-_max);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The current cap. */
|
|
35
|
+
export function maxEntries(): number {
|
|
36
|
+
return _max;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Store an entry, dropping the oldest once the cap is reached. */
|
|
40
|
+
export function putEntry(entry: DevtoolsEntry): void {
|
|
41
|
+
_entries.push(entry);
|
|
42
|
+
if (_entries.length > _max) _entries.shift();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Look one entry up by id. */
|
|
46
|
+
export function getEntry(id: string): DevtoolsEntry | undefined {
|
|
47
|
+
return _entries.find((entry) => entry.__meta.id === id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Drop everything. Used between tests and by the panel's "clear" action. */
|
|
51
|
+
export function clearEntries(): void {
|
|
52
|
+
_entries = [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** How many entries are currently held. */
|
|
56
|
+
export function entryCount(): number {
|
|
57
|
+
return _entries.length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* List entries newest-first, applying the read API's filters.
|
|
62
|
+
*
|
|
63
|
+
* `exclude` is applied after `type` so a request naming both keeps the
|
|
64
|
+
* intersection, which is what the extension expects when it asks for "all
|
|
65
|
+
* navigations except prefetches".
|
|
66
|
+
*
|
|
67
|
+
* @param query - Filters from the request's query string.
|
|
68
|
+
*/
|
|
69
|
+
export function listEntries(query: DevtoolsListQuery = {}): DevtoolsEntry[] {
|
|
70
|
+
let out = _entries.slice().reverse();
|
|
71
|
+
|
|
72
|
+
if (query.component !== undefined) {
|
|
73
|
+
out = out.filter((entry) => entry.__meta.component === query.component);
|
|
74
|
+
}
|
|
75
|
+
if (query.type && query.type.length > 0) {
|
|
76
|
+
const keep = new Set<string>(query.type);
|
|
77
|
+
out = out.filter((entry) => keep.has(entry.__meta.requestType));
|
|
78
|
+
}
|
|
79
|
+
if (query.exclude && query.exclude.length > 0) {
|
|
80
|
+
const drop = new Set<string>(query.exclude);
|
|
81
|
+
out = out.filter((entry) => !drop.has(entry.__meta.requestType));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Slice last: offset and limit page the *filtered* list, so paging through a
|
|
85
|
+
// filtered view does not skip entries the filter already removed.
|
|
86
|
+
const offset = Math.max(0, query.offset ?? 0);
|
|
87
|
+
const limit = query.limit;
|
|
88
|
+
return limit === undefined ? out.slice(offset) : out.slice(offset, offset + Math.max(0, limit));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Parse the read API's query string into a {@link DevtoolsListQuery}. */
|
|
92
|
+
export function parseListQuery(params: URLSearchParams): DevtoolsListQuery {
|
|
93
|
+
const query: DevtoolsListQuery = {};
|
|
94
|
+
|
|
95
|
+
const component = params.get("component");
|
|
96
|
+
if (component !== null) query.component = component;
|
|
97
|
+
|
|
98
|
+
const csv = (value: string | null): string[] | undefined => {
|
|
99
|
+
if (value === null) return undefined;
|
|
100
|
+
const parts = value
|
|
101
|
+
.split(",")
|
|
102
|
+
.map((part) => part.trim())
|
|
103
|
+
.filter(Boolean);
|
|
104
|
+
return parts.length > 0 ? parts : undefined;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Cast to the element type, not to `DevtoolsListQuery["type"]` — that includes
|
|
108
|
+
// `undefined`, which re-widens the value the `if` just narrowed.
|
|
109
|
+
const type = csv(params.get("type"));
|
|
110
|
+
if (type) query.type = type as DevtoolsRequestType[];
|
|
111
|
+
const exclude = csv(params.get("exclude"));
|
|
112
|
+
if (exclude) query.exclude = exclude as DevtoolsRequestType[];
|
|
113
|
+
|
|
114
|
+
const num = (value: string | null): number | undefined => {
|
|
115
|
+
if (value === null) return undefined;
|
|
116
|
+
const parsed = Number(value);
|
|
117
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const offset = num(params.get("offset"));
|
|
121
|
+
if (offset !== undefined) query.offset = offset;
|
|
122
|
+
const limit = num(params.get("limit"));
|
|
123
|
+
if (limit !== undefined) query.limit = limit;
|
|
124
|
+
|
|
125
|
+
return query;
|
|
126
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Inertia DevTools protocol, as types.
|
|
3
|
+
*
|
|
4
|
+
* These names are not ours to choose — they are the wire contract the browser
|
|
5
|
+
* extension reads, so every key here matches the published protocol exactly,
|
|
6
|
+
* including the `__meta` prefix and the `snake`/`camel` inconsistencies. When
|
|
7
|
+
* something looks oddly named, that is why; do not tidy it.
|
|
8
|
+
*
|
|
9
|
+
* @see https://inertiajs.com/docs/v3/advanced/devtools-protocol
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Request headers the extension sets so entries can be correlated into one timeline. */
|
|
13
|
+
export const DEVTOOLS_REQUEST_HEADERS = {
|
|
14
|
+
/** Per-tab UUID; recorded as `__meta.tabUuid`. */
|
|
15
|
+
tab: "X-Inertia-Devtools-Tab",
|
|
16
|
+
/** Client visit id; recorded as `__meta.visitId`. */
|
|
17
|
+
visit: "X-Inertia-Devtools-Visit",
|
|
18
|
+
/** Id of the entry that started this batch; becomes `__meta.batchId`. */
|
|
19
|
+
parent: "X-Inertia-Devtools-Parent",
|
|
20
|
+
/** `"1"` when this request is a deferred-prop follow-up. */
|
|
21
|
+
deferred: "X-Inertia-Devtools-Deferred",
|
|
22
|
+
/** `"1"` when this request is a polling tick. */
|
|
23
|
+
poll: "X-Inertia-Devtools-Poll",
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
/** Response headers the adapter sets so the extension can find the entry it just caused. */
|
|
27
|
+
export const DEVTOOLS_RESPONSE_HEADERS = {
|
|
28
|
+
/** The id generated for this entry. Set on every response. */
|
|
29
|
+
id: "X-Inertia-Devtools-Id",
|
|
30
|
+
/** The batch root id, so the extension can chain follow-up requests to their origin. */
|
|
31
|
+
parentOut: "X-Inertia-Devtools-Parent-Out",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
/** Path prefix of the read API the extension polls. */
|
|
35
|
+
export const DEVTOOLS_API_PREFIX = "/_inertia/devtools";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How the request reached the adapter. The client-only values (`client-visit`,
|
|
39
|
+
* `cache-hit`) never originate here — they are listed because the extension
|
|
40
|
+
* merges client-recorded entries into the same timeline, and a reader of this
|
|
41
|
+
* type should not think the set is smaller than it is.
|
|
42
|
+
*/
|
|
43
|
+
export type DevtoolsRequestType =
|
|
44
|
+
| "precognition"
|
|
45
|
+
| "initial"
|
|
46
|
+
| "http"
|
|
47
|
+
| "deferred"
|
|
48
|
+
| "poll"
|
|
49
|
+
| "partial"
|
|
50
|
+
| "prefetch"
|
|
51
|
+
| "navigate"
|
|
52
|
+
| "client-visit"
|
|
53
|
+
| "cache-hit";
|
|
54
|
+
|
|
55
|
+
/** A source location, where one could be resolved. */
|
|
56
|
+
export interface SourceLocation {
|
|
57
|
+
file: string;
|
|
58
|
+
line: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What the adapter knows about one prop, keyed in `Entry.props` by its dotted
|
|
63
|
+
* path. Every field is optional: a plain prop with nothing special about it is
|
|
64
|
+
* recorded as `{}`, which is meaningfully different from being absent.
|
|
65
|
+
*/
|
|
66
|
+
export interface PropMeta {
|
|
67
|
+
/** Which prop wrapper produced it, if any. */
|
|
68
|
+
inertiaType?: "always" | "defer" | "optional" | "merge" | "scroll" | "once";
|
|
69
|
+
/** True when the prop came from `share()` rather than the controller. */
|
|
70
|
+
shared?: boolean;
|
|
71
|
+
/** The `defer()` group this prop loads with. */
|
|
72
|
+
deferGroup?: string;
|
|
73
|
+
/** True when the client asked for this prop to be reset rather than merged. */
|
|
74
|
+
reset?: boolean;
|
|
75
|
+
/** True for a `once()` prop. */
|
|
76
|
+
once?: boolean;
|
|
77
|
+
/** Which end of the existing value a merge prop is applied to. */
|
|
78
|
+
mergeDirection?: "append" | "prepend";
|
|
79
|
+
/** True when the merge is deep rather than shallow. */
|
|
80
|
+
deepMerge?: boolean;
|
|
81
|
+
/** True when a `defer(..., { rescue: true })` prop threw and was omitted. */
|
|
82
|
+
rescued?: boolean;
|
|
83
|
+
/** Where the prop's value was produced. */
|
|
84
|
+
renderSource?: SourceLocation;
|
|
85
|
+
/** Where a shared prop was registered. */
|
|
86
|
+
shareSource?: SourceLocation;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Why a body was not captured. */
|
|
90
|
+
export type BodyOmittedReason =
|
|
91
|
+
| "non-inertia-response"
|
|
92
|
+
| "non-inertia-request"
|
|
93
|
+
| "non-textual"
|
|
94
|
+
| "streamed"
|
|
95
|
+
| "too-large"
|
|
96
|
+
| "unserializable"
|
|
97
|
+
| "binary";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A captured request or response body. A tagged union rather than a nullable
|
|
101
|
+
* value so the panel can say *why* something is missing — "too large" and
|
|
102
|
+
* "there was no body" look identical otherwise.
|
|
103
|
+
*/
|
|
104
|
+
export type BodyCapture =
|
|
105
|
+
| { status: "empty" }
|
|
106
|
+
| { status: "present"; value: unknown }
|
|
107
|
+
| { status: "omitted"; reason: BodyOmittedReason };
|
|
108
|
+
|
|
109
|
+
/** The route that matched, as far as the adapter can describe it. */
|
|
110
|
+
export interface RouteInfo {
|
|
111
|
+
/** The URL pattern, e.g. `/posts/:slug`. */
|
|
112
|
+
uri: string;
|
|
113
|
+
/** The registered route name, or `null` for an unnamed route. */
|
|
114
|
+
name: string | null;
|
|
115
|
+
/** The controller/handler, e.g. `PostController@show`. */
|
|
116
|
+
action: string | null;
|
|
117
|
+
/** Where the handler is defined, when it can be resolved. */
|
|
118
|
+
actionSource?: SourceLocation;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Everything the panel shows about one recorded request. */
|
|
122
|
+
export interface DevtoolsEntry {
|
|
123
|
+
__meta: {
|
|
124
|
+
id: string;
|
|
125
|
+
method: string;
|
|
126
|
+
url: string;
|
|
127
|
+
status: number;
|
|
128
|
+
requestType: DevtoolsRequestType;
|
|
129
|
+
component: string | null;
|
|
130
|
+
/** ISO 8601. */
|
|
131
|
+
timestamp: string;
|
|
132
|
+
/** Unix time in float seconds. */
|
|
133
|
+
utime: number;
|
|
134
|
+
tabUuid: string | null;
|
|
135
|
+
batchId: string | null;
|
|
136
|
+
serverTimingMs: number | null;
|
|
137
|
+
redirectLocation?: string | null;
|
|
138
|
+
visitId?: string | null;
|
|
139
|
+
};
|
|
140
|
+
http: {
|
|
141
|
+
requestHeaders: Record<string, string>;
|
|
142
|
+
responseHeaders: Record<string, string>;
|
|
143
|
+
requestBody: BodyCapture;
|
|
144
|
+
responseBody: BodyCapture;
|
|
145
|
+
};
|
|
146
|
+
/** Dotted prop path → what is known about it. */
|
|
147
|
+
props: Record<string, PropMeta>;
|
|
148
|
+
/** Dotted prop path → the resolved value, redacted. */
|
|
149
|
+
propValues?: Record<string, unknown>;
|
|
150
|
+
route: RouteInfo;
|
|
151
|
+
renderSource: SourceLocation | null;
|
|
152
|
+
componentPath: string | null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Filters accepted by `GET /_inertia/devtools/entries`. */
|
|
156
|
+
export interface DevtoolsListQuery {
|
|
157
|
+
/** Exact component-name match. */
|
|
158
|
+
component?: string;
|
|
159
|
+
/** Request types to keep. */
|
|
160
|
+
type?: DevtoolsRequestType[];
|
|
161
|
+
/** Request types to drop; applied after `type`. */
|
|
162
|
+
exclude?: DevtoolsRequestType[];
|
|
163
|
+
offset?: number;
|
|
164
|
+
limit?: number;
|
|
165
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,13 @@ import "./augment.ts";
|
|
|
43
43
|
|
|
44
44
|
export { inertiaRoute } from "./route.ts";
|
|
45
45
|
|
|
46
|
+
// `route()` for pages — the browser/SSR helper from `@zerotal/core/routes`,
|
|
47
|
+
// re-exported so a page component imports it from the package it already uses.
|
|
48
|
+
// Note the two are different things: `inertiaRoute()` above *registers* a page
|
|
49
|
+
// route on the server, `route()` *generates a URL* for one.
|
|
50
|
+
export { route, defineRoutes, hasRoute, resetRoutes } from "@zerotal/core/routes";
|
|
51
|
+
export type { RouteTable } from "@zerotal/core/routes";
|
|
52
|
+
|
|
46
53
|
export {
|
|
47
54
|
inertia,
|
|
48
55
|
inertiaStream,
|
|
@@ -106,7 +113,16 @@ export { location } from "./location.ts";
|
|
|
106
113
|
|
|
107
114
|
// Config factory
|
|
108
115
|
export { InertiaConfig } from "./config.ts";
|
|
109
|
-
export type { InertiaConfigShape } from "./config.ts";
|
|
116
|
+
export type { InertiaConfigShape, InertiaDevtoolsConfig } from "./config.ts";
|
|
117
|
+
|
|
118
|
+
// ── DevTools ─────────────────────────────────────────────────────────────────
|
|
119
|
+
// Server-side recorder for the Inertia DevTools browser extension. InertiaProvider
|
|
120
|
+
// wires all of this up when the recorder is enabled; an app needs none of it
|
|
121
|
+
// unless it is registering the recorder itself.
|
|
122
|
+
export { InertiaDevtoolsMiddleware } from "./devtools/middleware.ts";
|
|
123
|
+
export { devtoolsEnabled } from "./devtools/enabled.ts";
|
|
124
|
+
export { DEVTOOLS_API_PREFIX } from "./devtools/types.ts";
|
|
125
|
+
export type { DevtoolsEntry } from "./devtools/types.ts";
|
|
110
126
|
|
|
111
127
|
// SSR handler (for direct use or testing)
|
|
112
128
|
export { SsrHandler } from "./SsrHandler.ts";
|
package/src/inertia.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { assetVersion } from "./version.ts";
|
|
|
7
7
|
import { resolveProps } from "./props/resolveProps.ts";
|
|
8
8
|
import { readHistoryFlags } from "./historyState.ts";
|
|
9
9
|
import { allSharedKeys } from "./share.ts";
|
|
10
|
+
import { recordPage } from "./devtools/recorder.ts";
|
|
10
11
|
import { resolvePageModule, renderInertiaPage } from "./ssr/renderPage.ts";
|
|
11
12
|
import type { PageObject } from "./types.ts";
|
|
12
13
|
import type { PageTarget, RenderArgs } from "./pages.ts";
|
|
@@ -76,6 +77,10 @@ export async function buildPageObject(
|
|
|
76
77
|
const sharedPresent = allSharedKeys().filter((k) => k in resolved.props);
|
|
77
78
|
if (sharedPresent.length) page.sharedProps = sharedPresent;
|
|
78
79
|
|
|
80
|
+
// The only point that sees the raw wrappers and the resolved values together;
|
|
81
|
+
// a no-op unless the DevTools recorder opened a recording for this request.
|
|
82
|
+
recordPage(component, merged, resolved.props, resolved.rescuedProps);
|
|
83
|
+
|
|
79
84
|
const history = readHistoryFlags();
|
|
80
85
|
if (history.encryptHistory) page.encryptHistory = true;
|
|
81
86
|
if (history.clearHistory) page.clearHistory = true;
|
|
@@ -12,6 +12,10 @@ import { detectCssPlugins } from "../css.ts";
|
|
|
12
12
|
import { detectVuePlugin, registerVueRuntimeLoader } from "../vuePlugin.ts";
|
|
13
13
|
import { InertiaMiddleware } from "../middleware/InertiaMiddleware.ts";
|
|
14
14
|
import { inertiaRoute } from "../route.ts";
|
|
15
|
+
import { InertiaDevtoolsMiddleware } from "../devtools/middleware.ts";
|
|
16
|
+
import { devtoolsEnabled, devtoolsSettings } from "../devtools/enabled.ts";
|
|
17
|
+
import { registerDevtoolsApi } from "../devtools/api.ts";
|
|
18
|
+
import { setMaxEntries } from "../devtools/store.ts";
|
|
15
19
|
|
|
16
20
|
export class InertiaProvider extends ServiceProvider {
|
|
17
21
|
static override environments: AppEnvironment[] = ["web", "console", "test"];
|
|
@@ -22,7 +26,18 @@ export class InertiaProvider extends ServiceProvider {
|
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
override async onBooting(): Promise<void> {
|
|
25
|
-
this.app.useOnce(InertiaMiddleware
|
|
29
|
+
this.app.useOnce(InertiaMiddleware);
|
|
30
|
+
|
|
31
|
+
// DevTools recorder. Registered before InertiaMiddleware runs its response
|
|
32
|
+
// rewrites so the status it records is the one the client actually sees
|
|
33
|
+
// (302→303, and the version-mismatch 409, both happen in there). Nothing is
|
|
34
|
+
// registered at all when the recorder is off, so a production process has
|
|
35
|
+
// no read API to probe.
|
|
36
|
+
if (devtoolsEnabled()) {
|
|
37
|
+
setMaxEntries(devtoolsSettings().maxEntries);
|
|
38
|
+
this.app.useOnce(InertiaDevtoolsMiddleware);
|
|
39
|
+
registerDevtoolsApi();
|
|
40
|
+
}
|
|
26
41
|
|
|
27
42
|
const config = this.app.container.makeSync("config") as ConfigManager;
|
|
28
43
|
|