@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 +29 -1
- package/api-surface.md +359 -0
- package/package.json +3 -2
- package/src/devtools/observability.ts +145 -0
- package/src/devtools/recorder.ts +7 -0
- package/src/devtools/redact.ts +22 -30
- package/src/provider/InertiaProvider.ts +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,35 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
6
6
|
|
|
7
7
|
**Maturity: `stable`**
|
|
8
8
|
|
|
9
|
-
## [
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/devtools/recorder.ts
CHANGED
|
@@ -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
|
}
|
package/src/devtools/redact.ts
CHANGED
|
@@ -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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|