@zerotal/inertia 1.6.2 → 1.7.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 CHANGED
@@ -6,7 +6,35 @@ follows the Zerotal monorepo's unified versioning.
6
6
 
7
7
  **Maturity: `stable`**
8
8
 
9
- ## [Unreleased]
9
+ ## [1.7.0] — 2026-08-16
10
+
11
+ ### Added
12
+
13
+ - **The recorder now also feeds Zerotal's own DevTools panel.** Everything it resolves —
14
+ which prop came from which wrapper, what kind of request this was, which batch it belongs
15
+ to — went to the browser-extension read API and nowhere else. So a developer running the
16
+ in-page panel could not see a single prop, and a developer running the extension could not
17
+ see a single SQL query; the two halves described the same request and never met.
18
+
19
+ Entries are now also pushed onto an `inertia` channel when `@zerotal/devtools` is
20
+ installed, where the panel draws the prop map as a tree, badges each prop with the wrapper
21
+ that produced it, and folds a visit together with the deferred loads it triggered. Because
22
+ the entry is recorded against the same `HttpContext` the queries were, one row shows the
23
+ props **and** the SQL that produced them — with no key to match and no way for the two to
24
+ disagree about which request they describe.
25
+
26
+ Deliberately a fan-out and not a migration: `DevtoolsEntry` and `/_inertia/devtools` are a
27
+ published contract and keep serving the extension either way. Nor is devtools a new
28
+ dependency — the sink is resolved by container key through a local structural interface, so
29
+ this package imports `@zerotal/devtools` nowhere and does nothing at all when it is absent.
30
+
31
+ ### Changed
32
+
33
+ - **`redactValue` runs `redactGraph` from `@zerotal/core/security`** rather than its own copy
34
+ of the same walk. The protocol markers (`[REDACTED]`, `[Circular]`, `[Max depth]`) and the
35
+ depth limit are unchanged — they are specified by the wire contract, not chosen here, so
36
+ only the traversal is shared. Its last two parameters (`seen`, `depth`) were internal
37
+ bookkeeping and are gone; no caller passed them.
10
38
 
11
39
  ## [1.6.1] — 2026-08-15
12
40
 
package/api-surface.md ADDED
@@ -0,0 +1,359 @@
1
+ # @zerotal/inertia — public API surface
2
+
3
+ <!-- AUTO-GENERATED by scripts/api-surface.ts. Do not edit by hand.
4
+ Run `bun run api:surface` to regenerate after an intentional API change. -->
5
+
6
+ ## . `(./src/index.ts)`
7
+
8
+ class AlwaysProp = {
9
+ new <T = unknown>(value: T | PropFactory<T>): AlwaysProp<T>
10
+ append: (paths?: string | string[]) => AlwaysProp<T>
11
+ deepMerge: () => AlwaysProp<T>
12
+ isOnce: boolean
13
+ matchOn: (paths: string | string[]) => AlwaysProp<T>
14
+ merge: () => AlwaysProp<T>
15
+ mergeConfig: () => MergeConfig
16
+ once: (expiresAt?: number | null) => AlwaysProp<T>
17
+ onceExpiresAt: number | null
18
+ prepend: (paths?: string | string[]) => AlwaysProp<T>
19
+ readonly ignoreFirstLoad: boolean
20
+ resolve: () => T | Promise<T>
21
+ shouldMerge: boolean
22
+ }
23
+
24
+ class DeferProp = {
25
+ new <T = unknown>(callback: PropFactory<T>, group?: string, rescue?: boolean): DeferProp<T>
26
+ append: (paths?: string | string[]) => DeferProp<T>
27
+ deepMerge: () => DeferProp<T>
28
+ isOnce: boolean
29
+ matchOn: (paths: string | string[]) => DeferProp<T>
30
+ merge: () => DeferProp<T>
31
+ mergeConfig: () => MergeConfig
32
+ once: (expiresAt?: number | null) => DeferProp<T>
33
+ onceExpiresAt: number | null
34
+ prepend: (paths?: string | string[]) => DeferProp<T>
35
+ readonly group: string
36
+ readonly ignoreFirstLoad: true
37
+ readonly rescue: boolean
38
+ resolve: () => T | Promise<T>
39
+ shouldMerge: boolean
40
+ }
41
+
42
+ class InertiaDevtoolsMiddleware = {
43
+ new (): InertiaDevtoolsMiddleware
44
+ static with: <T extends new (...args: any[]) => BaseMiddleware<any>, Opts = T extends new (...args: any[]) => BaseMiddleware<infer U> ? U : object>(this: T, options: DeepPartial<NoInfer<Opts>>) => new () => InstanceType<T>
45
+ afterResponse?: (ctx: HttpContext) => Promise<void>
46
+ handle: (http: HttpContext, next: NextFn) => Promise<Response | void>
47
+ onError?: (ctx: HttpContext, error: Error) => Promise<void>
48
+ }
49
+
50
+ class InertiaError = {
51
+ new (message: string, code?: string, status?: number, context?: Record<string, unknown>): InertiaError
52
+ readonly code: string
53
+ readonly context?: Record<string, unknown> | undefined
54
+ readonly status: number
55
+ }
56
+
57
+ class InertiaMiddleware = {
58
+ new (): InertiaMiddleware
59
+ static with: <T extends new (...args: any[]) => BaseMiddleware<any>, Opts = T extends new (...args: any[]) => BaseMiddleware<infer U> ? U : object>(this: T, options: DeepPartial<NoInfer<Opts>>) => new () => InstanceType<T>
60
+ afterResponse?: (ctx: HttpContext) => Promise<void>
61
+ handle: (http: HttpContext, next: NextFn) => Promise<Response | void>
62
+ onError?: (ctx: HttpContext, error: Error) => Promise<void>
63
+ }
64
+
65
+ class InertiaProp = {
66
+ new (): InertiaProp
67
+ append: (paths?: string | string[]) => InertiaProp
68
+ deepMerge: () => InertiaProp
69
+ isOnce: boolean
70
+ matchOn: (paths: string | string[]) => InertiaProp
71
+ merge: () => InertiaProp
72
+ mergeConfig: () => MergeConfig
73
+ once: (expiresAt?: number | null) => InertiaProp
74
+ onceExpiresAt: number | null
75
+ prepend: (paths?: string | string[]) => InertiaProp
76
+ readonly ignoreFirstLoad: boolean
77
+ resolve: () => unknown | Promise<unknown>
78
+ shouldMerge: boolean
79
+ }
80
+
81
+ class InertiaProvider = {
82
+ new (app: Application): InertiaProvider
83
+ static dependsOn?: (new (app: Application) => ServiceProvider)[]
84
+ static environments: AppEnvironment[]
85
+ static priority?: number
86
+ static provides?: readonly (keyof ContainerBindings)[]
87
+ devProcesses: () => DevProcessDefinition[]
88
+ doctorChecks: () => DoctorCheck[]
89
+ onBooted: () => Promise<void>
90
+ onBooting: () => Promise<void>
91
+ onRegister: () => void
92
+ onRequestProcessed: (_ctx: HttpContext) => Promise<void>
93
+ onRequestReceived: (_ctx: HttpContext) => Promise<void>
94
+ onResponseSent: (_ctx: HttpContext) => Promise<void>
95
+ onStarted: () => Promise<void>
96
+ onStarting: () => Promise<void>
97
+ onStopped: () => Promise<void>
98
+ onStopping: () => Promise<void>
99
+ replContext: () => Record<string, unknown>
100
+ }
101
+
102
+ class InertiaTemplateNotLoadedError = {
103
+ new (): InertiaTemplateNotLoadedError
104
+ readonly code: string
105
+ readonly context?: Record<string, unknown> | undefined
106
+ readonly status: number
107
+ }
108
+
109
+ class InfiniteScrollProp = {
110
+ new (value: PaginatorLike | PropFactory, pageName?: string, dataPath?: string): InfiniteScrollProp
111
+ append: (paths?: string | string[]) => InfiniteScrollProp
112
+ deepMerge: () => InfiniteScrollProp
113
+ isOnce: boolean
114
+ matchOn: (paths: string | string[]) => InfiniteScrollProp
115
+ merge: () => InfiniteScrollProp
116
+ mergeConfig: () => MergeConfig
117
+ once: (expiresAt?: number | null) => InfiniteScrollProp
118
+ onceExpiresAt: number | null
119
+ prepend: (paths?: string | string[]) => InfiniteScrollProp
120
+ readonly dataPath: string
121
+ readonly ignoreFirstLoad: boolean
122
+ readonly pageName: string
123
+ resolve: () => unknown | Promise<unknown>
124
+ scrollConfig: (resolved: unknown) => ScrollConfig
125
+ shouldMerge: boolean
126
+ }
127
+
128
+ class InvalidComponentError = {
129
+ new (component: string, reason?: string): InvalidComponentError
130
+ readonly code: string
131
+ readonly context?: Record<string, unknown> | undefined
132
+ readonly status: number
133
+ }
134
+
135
+ class MergeProp = {
136
+ new <T = unknown>(value: T | PropFactory<T>): MergeProp<T>
137
+ append: (paths?: string | string[]) => MergeProp<T>
138
+ deepMerge: () => MergeProp<T>
139
+ isOnce: boolean
140
+ matchOn: (paths: string | string[]) => MergeProp<T>
141
+ merge: () => MergeProp<T>
142
+ mergeConfig: () => MergeConfig
143
+ once: (expiresAt?: number | null) => MergeProp<T>
144
+ onceExpiresAt: number | null
145
+ prepend: (paths?: string | string[]) => MergeProp<T>
146
+ readonly ignoreFirstLoad: boolean
147
+ resolve: () => T | Promise<T>
148
+ shouldMerge: boolean
149
+ }
150
+
151
+ class OptionalProp = {
152
+ new <T = unknown>(callback: PropFactory<T>): OptionalProp<T>
153
+ append: (paths?: string | string[]) => OptionalProp<T>
154
+ deepMerge: () => OptionalProp<T>
155
+ isOnce: boolean
156
+ matchOn: (paths: string | string[]) => OptionalProp<T>
157
+ merge: () => OptionalProp<T>
158
+ mergeConfig: () => MergeConfig
159
+ once: (expiresAt?: number | null) => OptionalProp<T>
160
+ onceExpiresAt: number | null
161
+ prepend: (paths?: string | string[]) => OptionalProp<T>
162
+ readonly ignoreFirstLoad: true
163
+ resolve: () => T | Promise<T>
164
+ shouldMerge: boolean
165
+ }
166
+
167
+ class PrecognitionMiddleware = {
168
+ new (): PrecognitionMiddleware
169
+ static with: <T extends new (...args: any[]) => BaseMiddleware<any>, Opts = T extends new (...args: any[]) => BaseMiddleware<infer U> ? U : object>(this: T, options: DeepPartial<NoInfer<Opts>>) => new () => InstanceType<T>
170
+ afterResponse?: (ctx: HttpContext) => Promise<void>
171
+ handle: (http: HttpContext, next: NextFn) => Promise<Response | void>
172
+ onError?: (ctx: HttpContext, error: Error) => Promise<void>
173
+ }
174
+
175
+ class SsrHandler = {
176
+ new (options?: { pagesDir?: string; }): SsrHandler
177
+ static isAuthorized: (http: HttpContext) => boolean
178
+ handle: (http: HttpContext) => Promise<void>
179
+ }
180
+
181
+ const DEVTOOLS_API_PREFIX = '/_inertia/devtools'
182
+
183
+ const Inertia = { readonly render: PageRenderer; readonly stream: PageRenderer; readonly optional: <T>(callback: PropFactory<T>) => OptionalProp<T>; readonly lazy: <T>(callback: PropFactory<T>) => OptionalProp<T>; readonly always: { <T>(value: PropFactory<T>): AlwaysProp<T>; <T>(value: T): AlwaysProp<T>; }; readonly defer: <T>(callback: PropFactory<T>, group?: string, options?: { rescue?: boolean; }) => DeferProp<T>; readonly merge: { <T>(value: PropFactory<T>): MergeProp<T>; <T>(value: T): MergeProp<T>; }; readonly deepMerge: { <T>(value: PropFactory<T>): MergeProp<T>; <T>(value: T): MergeProp<T>; }; readonly scroll: (value: PaginatorLike | PropFactory, options?: { pageName?: string; dataPath?: string; }) => InfiniteScrollProp; readonly share: { (key: string, value: unknown): void; (values: Record<string, unknown>): void; }; readonly encryptHistory: (on?: boolean) => void; readonly clearHistory: () => void; readonly location: (url: string) => void;}
184
+
185
+ const inertia = PageRenderer
186
+
187
+ const inertiaStream = PageRenderer
188
+
189
+ const route = RouteBuilder
190
+
191
+ function _getHtmlTemplate = () => string
192
+
193
+ function _setHtmlTemplate = (html: string) => void
194
+
195
+ function always = { <T>(value: PropFactory<T>): AlwaysProp<T>; <T>(value: T): AlwaysProp<T>;}
196
+
197
+ function assetVersion = () => string
198
+
199
+ function buildPageObject = (component: string, props: Record<string, unknown>) => Promise<PageObject>
200
+
201
+ function clearHistory = () => void
202
+
203
+ function deepMerge = { <T>(value: PropFactory<T>): MergeProp<T>; <T>(value: T): MergeProp<T>;}
204
+
205
+ function defer = <T>(callback: PropFactory<T>, group?: string, options?: { rescue?: boolean;}) => DeferProp<T>
206
+
207
+ function defineRoutes = (table: RouteTable) => void
208
+
209
+ function detectVuePlugin = (cwd: string) => Promise<BunPlugin[]>
210
+
211
+ function devtoolsEnabled = () => boolean
212
+
213
+ function encryptHistory = (on?: boolean) => void
214
+
215
+ function generatePageRegistry = (cwd?: string, pagesDir?: string) => Promise<void>
216
+
217
+ function hasRoute = (name: string) => boolean
218
+
219
+ function InertiaConfig = (options?: Partial<InertiaConfigShape>) => InertiaConfigShape
220
+
221
+ function inertiaRoute = <N extends PageTarget>(path: string, component: N, props?: RenderProps<N> | MiddlewareClass[], middleware?: MiddlewareClass[]) => RouteRegistration
222
+
223
+ function lazy = <T>(callback: PropFactory<T>) => OptionalProp<T>
224
+
225
+ function location = (url: string) => void
226
+
227
+ function merge = { <T>(value: PropFactory<T>): MergeProp<T>; <T>(value: T): MergeProp<T>;}
228
+
229
+ function optional = <T>(callback: PropFactory<T>) => OptionalProp<T>
230
+
231
+ function resetRoutes = () => void
232
+
233
+ function resolveProps = (raw: Record<string, unknown>, headers: Headers, component: string) => Promise<ResolvedPage>
234
+
235
+ function scroll = (value: PaginatorLike | PropFactory, options?: { pageName?: string; dataPath?: string;}) => InfiniteScrollProp
236
+
237
+ function setAssetVersion = (v: string) => void
238
+
239
+ function setHistoryEncryptionDefault = (on: boolean) => void
240
+
241
+ function share = { (key: string, value: unknown): void; (values: Record<string, unknown>): void;}
242
+
243
+ function sharedProps = () => Record<string, unknown>
244
+
245
+ interface DevtoolsEntry = {
246
+ __meta: { id: string; method: string; url: string; status: number; requestType: DevtoolsRequestType; component: string | null; timestamp: string; utime: number; tabUuid: string | null; batchId: string | null; serverTimingMs: number | null; redirectLocation?: string | null; visitId?: string | null;}
247
+ componentPath: string | null
248
+ http: { requestHeaders: Record<string, string>; responseHeaders: Record<string, string>; requestBody: BodyCapture; responseBody: BodyCapture;}
249
+ propValues?: Record<string, unknown>
250
+ props: Record<string, PropMeta>
251
+ renderSource: SourceLocation | null
252
+ route: RouteInfo
253
+ }
254
+
255
+ interface InertiaConfigShape = {
256
+ assetsUrl: string
257
+ devtools: InertiaDevtoolsConfig
258
+ encryptHistory: boolean
259
+ htmlTemplate: string
260
+ pagesDir: string
261
+ ssr: boolean
262
+ ssrSecret: string
263
+ version: string
264
+ }
265
+
266
+ interface InertiaDevtoolsConfig = {
267
+ enabled: boolean | null
268
+ except: string[]
269
+ gate: ((request: Request) => boolean | Promise<boolean>) | null
270
+ maxEntries: number
271
+ redact: string[]
272
+ redactHeaders: string[]
273
+ }
274
+
275
+ interface InertiaPageRegistry = {}
276
+
277
+ interface InertiaProviderOptions = {
278
+ assetsUrl?: string
279
+ htmlTemplate?: string
280
+ version?: string
281
+ }
282
+
283
+ interface MergeConfig = {
284
+ appendPaths: string[]
285
+ deep: boolean
286
+ matchOn: string[]
287
+ prependPaths: string[]
288
+ prependRoot: boolean
289
+ }
290
+
291
+ interface PageObject = {
292
+ clearHistory?: boolean
293
+ component: string
294
+ deepMergeProps?: string[]
295
+ deferredProps?: Record<string, string[]>
296
+ encryptHistory?: boolean
297
+ matchPropsOn?: string[]
298
+ mergeProps?: string[]
299
+ onceProps?: Record<string, { prop: string; expiresAt: number | null;}>
300
+ prependProps?: string[]
301
+ preserveFragment?: boolean
302
+ props: Record<string, unknown>
303
+ rescuedProps?: string[]
304
+ scrollProps?: Record<string, unknown>
305
+ sharedProps?: string[]
306
+ url: string
307
+ version: string
308
+ }
309
+
310
+ interface PageRenderer = {
311
+ <N extends PageTarget>(component: N, props?: Record<string, unknown> | undefined): Promise<void>
312
+ dynamic: (component: string, props?: Record<string, unknown>) => Promise<void>
313
+ }
314
+
315
+ interface PaginatorLike = {
316
+ currentPage?: number
317
+ data: unknown[]
318
+ lastPage?: number
319
+ page?: number
320
+ perPage?: number
321
+ total?: number
322
+ }
323
+
324
+ interface ResolvedPage = {
325
+ deepMergeProps?: string[]
326
+ deferredProps?: Record<string, string[]>
327
+ matchPropsOn?: string[]
328
+ mergeProps?: string[]
329
+ onceProps?: Record<string, { prop: string; expiresAt: number | null;}>
330
+ prependProps?: string[]
331
+ props: Record<string, unknown>
332
+ rescuedProps?: string[]
333
+ scrollProps?: Record<string, ScrollConfig>
334
+ }
335
+
336
+ interface ScrollConfig = {
337
+ currentPage: number | null
338
+ nextPage: number | null
339
+ pageName: string
340
+ previousPage: number | null
341
+ }
342
+
343
+ interface SharedProps = {}
344
+
345
+ type PageName = never
346
+
347
+ type PageTarget = string
348
+
349
+ type PropFactory = () => T | Promise<T>
350
+
351
+ type PropInput = T | (() => T | Promise<T>) | AlwaysProp<T> | MergeProp<T> | (T extends PaginatorLike ? InfiniteScrollProp : never) | (undefined extends T ? OptionalProp<T> | DeferProp<T> : never)
352
+
353
+ type PropsOf = PageComponent<N> extends (props: infer Props, ...rest: any[]) => any ? Props : PageComponent<N> extends abstract new (props: infer Props, ...rest: any[]) => any ? Props : Record<string, unknown>
354
+
355
+ type RenderArgs = [props?: Record<string, unknown>]
356
+
357
+ type RenderProps = { [x: string]: unknown;}
358
+
359
+ type RouteTable = Readonly<Record<string, string>> | ReadonlyMap<string, string>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/inertia",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "files": [
14
14
  "CHANGELOG.md",
15
+ "api-surface.md",
15
16
  "src",
16
17
  "!src/**/*.test.ts",
17
18
  "!src/**/*.test.tsx",
@@ -32,7 +33,7 @@
32
33
  "typecheck": "tsc --noEmit"
33
34
  },
34
35
  "dependencies": {
35
- "@zerotal/core": "1.6.2"
36
+ "@zerotal/core": "1.7.0"
36
37
  },
37
38
  "peerDependencies": {
38
39
  "react": "^18 || ^19",
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Inertia → DevTools panel bridge.
3
+ *
4
+ * The recorder already resolves everything worth showing about an Inertia
5
+ * request — which prop came from which wrapper, what kind of request it was,
6
+ * which batch it belongs to. All of it went to the browser-extension read API
7
+ * and nowhere else, so a developer running the in-page panel could not see a
8
+ * single prop, and a developer running the extension could not see a single SQL
9
+ * query. The two halves described the same request and never met.
10
+ *
11
+ * This is the fan-out that joins them. The entry is recorded against the same
12
+ * `HttpContext` the SQL was, so the panel's own correlation puts both on one
13
+ * trace with no key to match — "is this page slow because of the query or the
14
+ * deferred prop" becomes one view rather than two tools.
15
+ *
16
+ * Deliberately a fan-out and not a migration: {@link DevtoolsEntry} and its read
17
+ * API are a published contract, and this package keeps serving them whether or
18
+ * not devtools is installed. Nor does installing devtools become a dependency —
19
+ * the sink is resolved by container key through a local structural interface,
20
+ * exactly as every other package's bridge does it, so `@zerotal/inertia` imports
21
+ * `@zerotal/devtools` nowhere.
22
+ *
23
+ * ## This package's cast boundary
24
+ *
25
+ * Listed under `boundaries` in `cast-baseline.json`. One cast lives here and it
26
+ * is the price of the independence described above: `devtools.trace` is not in
27
+ * `ContainerBindings` from this package, because the augmentation that adds it
28
+ * belongs to the package this one deliberately does not depend on. Typing the
29
+ * lookup properly would mean taking that dependency, which would cost more than
30
+ * the cast saves.
31
+ *
32
+ * The invariant it rests on: a missing binding is the ordinary case — devtools is
33
+ * not installed, or the app is in production — and `tryMake` returning
34
+ * `undefined` is handled on the next line.
35
+ */
36
+ import type { Application, HttpContext } from "@zerotal/core";
37
+ import type { DevtoolsEntry } from "./types.ts";
38
+
39
+ /** The subset of the devtools trace sink this bridge calls (bound as `devtools.trace`). */
40
+ interface DevtoolsSink {
41
+ channel(descriptor: {
42
+ id: string;
43
+ label: string;
44
+ badge?: string;
45
+ title?: string;
46
+ meta?: string[];
47
+ warn?: string;
48
+ order?: number;
49
+ render?: "rows" | "tree" | "table" | "kv" | "grouped";
50
+ treeField?: string;
51
+ treeBadge?: string;
52
+ groupBy?: string;
53
+ flags?: string[];
54
+ traceGroup?: string;
55
+ }): void;
56
+ record(ctx: object, channel: string, entry: Record<string, unknown>): void;
57
+ }
58
+
59
+ /** The sink, once the provider has found one. Null when devtools is not installed. */
60
+ let _sink: DevtoolsSink | null = null;
61
+
62
+ /**
63
+ * Point the recorder at the devtools panel, if there is one.
64
+ *
65
+ * Called from `InertiaProvider` when the recorder is enabled. Returns a disposer
66
+ * that detaches the bridge; call it from the provider's teardown so a suite that
67
+ * boots several apps does not leave one app's sink wired to the next one's.
68
+ *
69
+ * @param app - The application, for the container lookup.
70
+ */
71
+ export function installInertiaObservability(app: Application): () => void {
72
+ // Not in `ContainerBindings` from here — the binding is declared by the package
73
+ // that owns it, and this one deliberately depends on none of them. A missing
74
+ // binding is the ordinary case, not an error: it means devtools is not
75
+ // installed, or the app is in production, and neither is this package's business.
76
+ const sink = app.container.tryMake("devtools.trace" as never) as DevtoolsSink | undefined;
77
+ if (!sink) return () => {};
78
+
79
+ // Declared once, as data. The panel renders the prop map as a tree, badges each
80
+ // prop with the wrapper that produced it, and folds a visit together with the
81
+ // deferred loads it triggered — and devtools ships no Inertia-specific code to
82
+ // do any of it.
83
+ sink.channel({
84
+ id: "inertia",
85
+ label: "Inertia",
86
+ badge: "requestType",
87
+ title: "component",
88
+ meta: ["route", "url", "status", "props", "serverTimingMs"],
89
+ order: 15,
90
+ render: "tree",
91
+ treeField: "propMeta",
92
+ treeBadge: "inertiaType",
93
+ flags: ["shared", "once", "rescued", "reset", "deepMerge"],
94
+ // A partial reload and the visit that caused it are one thing the developer
95
+ // did; `batchId` is what the protocol already uses to say so.
96
+ traceGroup: "batchId",
97
+ });
98
+
99
+ _sink = sink;
100
+ return () => {
101
+ _sink = null;
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Push one finished entry onto the panel's `inertia` channel.
107
+ *
108
+ * A no-op when no sink was found, so the recorder calls it unguarded.
109
+ *
110
+ * The channel entry is a *view* of the protocol entry, not the entry itself: the
111
+ * panel wants flat, named fields it can badge and sort, while `DevtoolsEntry` is
112
+ * shaped by the wire contract. Copying the handful of fields worth showing keeps
113
+ * the two from constraining each other — and keeps the request headers and
114
+ * captured bodies out of a second store that has its own retention.
115
+ *
116
+ * @param http - The context the entry was recorded on; the join to its trace.
117
+ * @param entry - The finished protocol entry.
118
+ */
119
+ export function shareEntryWithDevtools(http: HttpContext, entry: DevtoolsEntry): void {
120
+ const sink = _sink;
121
+ if (!sink) return;
122
+
123
+ const meta = entry.__meta;
124
+ const propMeta = entry.props;
125
+
126
+ sink.record(http, "inertia", {
127
+ requestType: meta.requestType,
128
+ component: meta.component ?? "—",
129
+ // The name when the route has one, the pattern when it does not: a named
130
+ // route is what the developer wrote, and a bare URI is what is left.
131
+ route: entry.route.name ?? entry.route.uri,
132
+ url: meta.url,
133
+ status: meta.status,
134
+ props: Object.keys(propMeta).length,
135
+ serverTimingMs: meta.serverTimingMs,
136
+ batchId: meta.batchId,
137
+ // The id the extension knows this entry by, and the value of the
138
+ // `X-Inertia-Devtools-Id` response header — so a row in the panel and a row
139
+ // in the extension can be recognised as the same request.
140
+ entryId: meta.id,
141
+ componentPath: entry.componentPath,
142
+ propMeta,
143
+ ...(meta.redirectLocation ? { redirectLocation: meta.redirectLocation } : {}),
144
+ });
145
+ }
@@ -30,6 +30,7 @@ import {
30
30
  redactValue,
31
31
  } from "./redact.ts";
32
32
  import { putEntry } from "./store.ts";
33
+ import { shareEntryWithDevtools } from "./observability.ts";
33
34
  import { DEVTOOLS_REQUEST_HEADERS, type BodyCapture } from "./types.ts";
34
35
  import type { DevtoolsEntry, DevtoolsRequestType, PropMeta } from "./types.ts";
35
36
  import { devtoolsSettings } from "./enabled.ts";
@@ -267,4 +268,10 @@ export function finishRecording(
267
268
  if (Object.keys(recording.propValues).length > 0) entry.propValues = recording.propValues;
268
269
 
269
270
  putEntry(entry);
271
+
272
+ // Fan out to the in-page panel as well, when one is installed. Recorded against
273
+ // the same context, so it lands on the same request trace as that request's SQL
274
+ // — no key to match, and no way for the two to disagree about which request
275
+ // they describe. A no-op when devtools is absent.
276
+ shareEntryWithDevtools(http, entry);
270
277
  }
@@ -6,7 +6,14 @@
6
6
  * cookie, the `Authorization` header, and whatever a login form just posted.
7
7
  * Redaction runs before an entry is stored, not when it is served: an entry that
8
8
  * was never written cannot leak from a store that is later exposed by mistake.
9
+ *
10
+ * The walk is `redactGraph` from `@zerotal/core/security`; the *markers* below
11
+ * are not ours to choose. `[REDACTED]`, `[Circular]` and `[Max depth]` are what
12
+ * the published protocol specifies, so sharing an implementation with the
13
+ * devtools panel — which spells them `‹redacted›` — means sharing the traversal
14
+ * and nothing else.
9
15
  */
16
+ import { redactGraph } from "@zerotal/core/security";
10
17
 
11
18
  /** The marker the protocol uses for a value that was withheld. */
12
19
  export const REDACTED = "[REDACTED]";
@@ -85,45 +92,30 @@ export function redactHeaders(
85
92
  *
86
93
  * @param value - Any prop value.
87
94
  * @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
95
  */
91
96
  export function redactValue(
92
97
  value: unknown,
93
98
  patterns: readonly string[] = DEFAULT_REDACTED_KEYS,
94
- seen: WeakSet<object> = new WeakSet(),
95
- depth = 0,
96
99
  ): 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]";
100
+ return redactGraph(value, {
101
+ sensitive: (key) => isSensitiveKey(key, patterns),
102
+ mask: REDACTED,
103
+ circular: "[Circular]",
104
+ tooDeep: "[Max depth]",
105
+ // Bounded rather than unbounded: recording is on the request path, and a
106
+ // pathological object graph should slow nothing down. Thirteen because that
107
+ // is the depth this recorder has always stopped at.
108
+ maxDepth: 13,
109
+ flatten: _summarise,
110
+ });
111
+ }
102
112
 
113
+ /** Values that must not be inlined, rendered as a one-line summary instead. */
114
+ function _summarise(value: unknown): string | undefined {
103
115
  if (value instanceof Date) return value.toISOString();
104
116
  if (value instanceof File) {
105
117
  return `[File: ${value.name}, ${value.size} bytes, ${value.type || "unknown"}]`;
106
118
  }
107
119
  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
- }
120
+ return undefined;
129
121
  }
@@ -21,10 +21,14 @@ import { InertiaDevtoolsMiddleware } from "../devtools/middleware.ts";
21
21
  import { devtoolsEnabled, devtoolsSettings } from "../devtools/enabled.ts";
22
22
  import { registerDevtoolsApi } from "../devtools/api.ts";
23
23
  import { setMaxEntries } from "../devtools/store.ts";
24
+ import { installInertiaObservability } from "../devtools/observability.ts";
24
25
 
25
26
  export class InertiaProvider extends ServiceProvider {
26
27
  static override environments: AppEnvironment[] = ["web", "console", "test"];
27
28
 
29
+ /** Detaches the devtools-panel fan-out. Null when there was nothing to attach. */
30
+ private _stopObservability: (() => void) | null = null;
31
+
28
32
  override onRegister(): void {
29
33
  // Make Router.inertia() available before routes load.
30
34
  Router.macro("inertia", inertiaRoute);
@@ -113,6 +117,14 @@ export class InertiaProvider extends ServiceProvider {
113
117
  }
114
118
 
115
119
  override async onBooted(): Promise<void> {
120
+ // Fan the recorder's entries out to the in-page devtools panel, when one is
121
+ // installed. In `onBooted` rather than `onBooting` because that is the first
122
+ // hook where every provider's bindings exist — `devtools.trace` is registered
123
+ // in DevtoolsProvider's `onBooting`, and provider order is the app's to choose.
124
+ if (devtoolsEnabled()) {
125
+ this._stopObservability = installInertiaObservability(this.app);
126
+ }
127
+
116
128
  // Register the dev build hook so DevOrchestrator (in @zerotal/core) can
117
129
  // trigger a full pages-manifest sync + asset rebuild without @zerotal/core
118
130
  // importing @zerotal/inertia (which would create a circular dependency).
@@ -160,6 +172,14 @@ export class InertiaProvider extends ServiceProvider {
160
172
  ),
161
173
  );
162
174
  }
175
+
176
+ override async onStopping(): Promise<void> {
177
+ // The bridge holds the previous app's sink in a module-local; a suite that
178
+ // boots several apps would otherwise leave one app's recorder writing into
179
+ // the trace store of an app that has already stopped.
180
+ this._stopObservability?.();
181
+ this._stopObservability = null;
182
+ }
163
183
  }
164
184
 
165
185
  // Used as fallback when resources/app.html does not exist yet