@kontsedal/olas-devtools 0.0.1-rc.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.
@@ -0,0 +1,151 @@
1
+ // Compact JSON renderer for the devtools panel payload column.
2
+ //
3
+ // Renders values inline by default. Objects/arrays show their summary
4
+ // ("{4}", "[12]") inline; clicking the summary expands them. Each nested
5
+ // level inherits the same expand/collapse semantics. Scoped to the
6
+ // `olas-devtools-json-*` class prefix; styles are in `styles.ts`.
7
+
8
+ import { type ReactElement, useState } from 'react'
9
+
10
+ export function JsonView({ value, depth = 0 }: { value: unknown; depth?: number }): ReactElement {
11
+ return <Render value={value} depth={depth} initiallyOpen={depth === 0} />
12
+ }
13
+
14
+ function Render({
15
+ value,
16
+ depth,
17
+ initiallyOpen,
18
+ }: {
19
+ value: unknown
20
+ depth: number
21
+ initiallyOpen: boolean
22
+ }): ReactElement {
23
+ if (value === null) return <span className="olas-devtools-json-null">null</span>
24
+ if (value === undefined) return <span className="olas-devtools-json-null">undefined</span>
25
+
26
+ const t = typeof value
27
+ if (t === 'string') return <span className="olas-devtools-json-string">"{value as string}"</span>
28
+ if (t === 'number') return <span className="olas-devtools-json-number">{String(value)}</span>
29
+ if (t === 'boolean') return <span className="olas-devtools-json-boolean">{String(value)}</span>
30
+ if (t === 'bigint') return <span className="olas-devtools-json-number">{String(value)}n</span>
31
+
32
+ // Errors render as `Error("message")` so they're distinguishable from
33
+ // plain string payloads.
34
+ if (value instanceof Error) {
35
+ return (
36
+ <span className="olas-devtools-json-error">
37
+ {value.name}({JSON.stringify(value.message)})
38
+ </span>
39
+ )
40
+ }
41
+
42
+ if (Array.isArray(value)) {
43
+ return <CollapsibleArray value={value} depth={depth} initiallyOpen={initiallyOpen} />
44
+ }
45
+
46
+ if (t === 'object') {
47
+ return (
48
+ <CollapsibleObject
49
+ value={value as Record<string, unknown>}
50
+ depth={depth}
51
+ initiallyOpen={initiallyOpen}
52
+ />
53
+ )
54
+ }
55
+
56
+ return <span>{String(value)}</span>
57
+ }
58
+
59
+ function CollapsibleArray({
60
+ value,
61
+ depth,
62
+ initiallyOpen,
63
+ }: {
64
+ value: unknown[]
65
+ depth: number
66
+ initiallyOpen: boolean
67
+ }): ReactElement {
68
+ const [open, setOpen] = useState(initiallyOpen && value.length <= 12)
69
+ if (value.length === 0) {
70
+ return <span className="olas-devtools-json-bracket">[]</span>
71
+ }
72
+ if (!open) {
73
+ return (
74
+ <button type="button" className="olas-devtools-json-toggle" onClick={() => setOpen(true)}>
75
+ <span className="olas-devtools-json-bracket">[</span>
76
+ <span className="olas-devtools-json-summary">
77
+ {value.length} item{value.length === 1 ? '' : 's'}
78
+ </span>
79
+ <span className="olas-devtools-json-bracket">]</span>
80
+ </button>
81
+ )
82
+ }
83
+ return (
84
+ <span className="olas-devtools-json-block">
85
+ <button
86
+ type="button"
87
+ className="olas-devtools-json-toggle olas-devtools-json-toggle-open"
88
+ onClick={() => setOpen(false)}
89
+ >
90
+ <span className="olas-devtools-json-bracket">[</span>
91
+ </button>
92
+ <span className="olas-devtools-json-children">
93
+ {value.map((item, idx) => (
94
+ <span key={idx} className="olas-devtools-json-row">
95
+ <span className="olas-devtools-json-index">{idx}:</span>
96
+ <Render value={item} depth={depth + 1} initiallyOpen={false} />
97
+ </span>
98
+ ))}
99
+ </span>
100
+ <span className="olas-devtools-json-bracket">]</span>
101
+ </span>
102
+ )
103
+ }
104
+
105
+ function CollapsibleObject({
106
+ value,
107
+ depth,
108
+ initiallyOpen,
109
+ }: {
110
+ value: Record<string, unknown>
111
+ depth: number
112
+ initiallyOpen: boolean
113
+ }): ReactElement {
114
+ const keys = Object.keys(value)
115
+ const [open, setOpen] = useState(initiallyOpen && keys.length <= 8)
116
+ if (keys.length === 0) {
117
+ return <span className="olas-devtools-json-bracket">{'{}'}</span>
118
+ }
119
+ if (!open) {
120
+ return (
121
+ <button type="button" className="olas-devtools-json-toggle" onClick={() => setOpen(true)}>
122
+ <span className="olas-devtools-json-bracket">{'{'}</span>
123
+ <span className="olas-devtools-json-summary">
124
+ {keys.slice(0, 3).join(', ')}
125
+ {keys.length > 3 ? ` +${keys.length - 3}` : ''}
126
+ </span>
127
+ <span className="olas-devtools-json-bracket">{'}'}</span>
128
+ </button>
129
+ )
130
+ }
131
+ return (
132
+ <span className="olas-devtools-json-block">
133
+ <button
134
+ type="button"
135
+ className="olas-devtools-json-toggle olas-devtools-json-toggle-open"
136
+ onClick={() => setOpen(false)}
137
+ >
138
+ <span className="olas-devtools-json-bracket">{'{'}</span>
139
+ </button>
140
+ <span className="olas-devtools-json-children">
141
+ {keys.map((k) => (
142
+ <span key={k} className="olas-devtools-json-row">
143
+ <span className="olas-devtools-json-key">{k}:</span>
144
+ <Render value={value[k]} depth={depth + 1} initiallyOpen={false} />
145
+ </span>
146
+ ))}
147
+ </span>
148
+ <span className="olas-devtools-json-bracket">{'}'}</span>
149
+ </span>
150
+ )
151
+ }
package/src/format.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Render a payload (props, vars, result, etc.) as a single-line string for
3
+ * the panel. Cuts at `maxLen` so a giant blob doesn't blow up the layout.
4
+ */
5
+ export function formatPayload(value: unknown, maxLen = 200): string {
6
+ if (value === undefined) return 'undefined'
7
+ if (value === null) return 'null'
8
+ if (typeof value === 'function') return '[fn]'
9
+ let s: string
10
+ try {
11
+ s = JSON.stringify(value, replaceUnserializable)
12
+ } catch {
13
+ s = String(value)
14
+ }
15
+ if (s === undefined) s = String(value)
16
+ return s.length > maxLen ? `${s.slice(0, maxLen)}…` : s
17
+ }
18
+
19
+ function replaceUnserializable(_key: string, value: unknown): unknown {
20
+ if (typeof value === 'function') return '[fn]'
21
+ if (typeof value === 'bigint') return value.toString()
22
+ if (value instanceof Error) return { name: value.name, message: value.message }
23
+ return value
24
+ }
25
+
26
+ /** Render an HH:MM:SS.mmm timestamp from epoch ms. */
27
+ export function formatTime(t: number): string {
28
+ const d = new Date(t)
29
+ const pad = (n: number, w = 2) => n.toString().padStart(w, '0')
30
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`
31
+ }
32
+
33
+ /** Render a controller path / query key as a compact string. */
34
+ export function formatPath(path: readonly unknown[]): string {
35
+ if (path.length === 0) return '∅'
36
+ return path.map((p) => String(p)).join(' › ')
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { DevtoolsLauncher, type DevtoolsLauncherProps } from './DevtoolsLauncher'
2
+ export { DevtoolsPanel, type DevtoolsPanelProps, type DevtoolsTab } from './DevtoolsPanel'
3
+ export { formatPath, formatPayload, formatTime } from './format'
4
+ export {
5
+ type CacheEntry,
6
+ type ControllerNode,
7
+ DevtoolsStore,
8
+ type DevtoolsStoreOptions,
9
+ type FieldEntry,
10
+ insertNode,
11
+ type MutationEntry,
12
+ setNodeState,
13
+ } from './store'
package/src/store.ts ADDED
@@ -0,0 +1,352 @@
1
+ import type { DebugEvent, Root } from '@kontsedal/olas-core'
2
+ import { type Signal, signal } from '@kontsedal/olas-core'
3
+
4
+ /**
5
+ * Per-path node in the live controller tree. `state` reflects the most
6
+ * recently observed lifecycle event; `path` is the array reported by the
7
+ * devtools bus.
8
+ */
9
+ export type ControllerNode = {
10
+ readonly path: readonly string[]
11
+ state: 'active' | 'suspended' | 'disposed'
12
+ props: unknown
13
+ children: ControllerNode[]
14
+ }
15
+
16
+ /** One entry in the cache timeline. */
17
+ export type CacheEntry =
18
+ | {
19
+ id: number
20
+ t: number
21
+ kind: 'subscribed'
22
+ queryKey: readonly unknown[]
23
+ subscriberPath: readonly string[]
24
+ }
25
+ | { id: number; t: number; kind: 'fetch-start'; queryKey: readonly unknown[] }
26
+ | {
27
+ id: number
28
+ t: number
29
+ kind: 'fetch-success'
30
+ queryKey: readonly unknown[]
31
+ durationMs: number
32
+ }
33
+ | {
34
+ id: number
35
+ t: number
36
+ kind: 'fetch-error'
37
+ queryKey: readonly unknown[]
38
+ durationMs: number
39
+ error: unknown
40
+ }
41
+ | { id: number; t: number; kind: 'invalidated'; queryKey: readonly unknown[] }
42
+ | { id: number; t: number; kind: 'gc'; queryKey: readonly unknown[] }
43
+
44
+ /** One entry in the mutation log. `durationMs` is set on success/error when
45
+ * the entry can be paired with a preceding `run` for the same path+name. */
46
+ export type MutationEntry =
47
+ | { id: number; t: number; kind: 'run'; path: readonly string[]; name?: string; vars: unknown }
48
+ | {
49
+ id: number
50
+ t: number
51
+ kind: 'success'
52
+ path: readonly string[]
53
+ name?: string
54
+ result: unknown
55
+ durationMs?: number
56
+ }
57
+ | {
58
+ id: number
59
+ t: number
60
+ kind: 'error'
61
+ path: readonly string[]
62
+ name?: string
63
+ error: unknown
64
+ durationMs?: number
65
+ }
66
+ | { id: number; t: number; kind: 'rollback'; path: readonly string[]; name?: string }
67
+
68
+ /** One entry in the field validation log. */
69
+ export type FieldEntry = {
70
+ id: number
71
+ t: number
72
+ path: readonly string[]
73
+ field: string
74
+ valid: boolean
75
+ errors: string[]
76
+ }
77
+
78
+ /** Defaults — exported so callers can override via `new DevtoolsStore({ maxEntries: 500 })`. */
79
+ export const DEFAULT_MAX_ENTRIES = 100
80
+
81
+ export type DevtoolsStoreOptions = {
82
+ /** Cap on each event log (cache, mutation, field). Oldest entries drop first. */
83
+ maxEntries?: number
84
+ /** Optional clock — useful for tests. Default: `() => Date.now()`. */
85
+ now?: () => number
86
+ }
87
+
88
+ /**
89
+ * Subscribes to a root's `__debug` bus and maintains live state for the
90
+ * devtools panel. Exposes signals so the React layer can consume via
91
+ * `@kontsedal/olas-react`'s `use()`.
92
+ *
93
+ * Pure logic — no DOM, no React. Construct one per root.
94
+ */
95
+ export class DevtoolsStore {
96
+ readonly tree$: Signal<ControllerNode> = signal(makeRoot())
97
+ readonly cache$: Signal<CacheEntry[]> = signal([])
98
+ readonly mutations$: Signal<MutationEntry[]> = signal([])
99
+ readonly fields$: Signal<FieldEntry[]> = signal([])
100
+
101
+ private readonly maxEntries: number
102
+ private readonly now: () => number
103
+ private nextId = 1
104
+
105
+ /** Keyed by `path|name` so a mutation:run can be paired with its
106
+ * success/error to compute duration. Cleared after pairing. */
107
+ private mutationStarts = new Map<string, number>()
108
+
109
+ constructor(options?: DevtoolsStoreOptions) {
110
+ this.maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES
111
+ this.now = options?.now ?? (() => Date.now())
112
+ }
113
+
114
+ /**
115
+ * Subscribe to the given root's debug bus. Returns the unsubscribe. The
116
+ * caller (typically the React component) is responsible for invoking it
117
+ * on unmount.
118
+ */
119
+ attach(root: Pick<Root<unknown>, '__debug'>): () => void {
120
+ return root.__debug.subscribe((event) => this.handle(event))
121
+ }
122
+
123
+ /** Apply one event. Exposed for tests. */
124
+ handle(event: DebugEvent): void {
125
+ switch (event.type) {
126
+ case 'controller:constructed':
127
+ this.tree$.set(insertNode(this.tree$.peek(), event.path, event.props))
128
+ return
129
+ case 'controller:suspended':
130
+ this.tree$.set(setNodeState(this.tree$.peek(), event.path, 'suspended'))
131
+ return
132
+ case 'controller:resumed':
133
+ this.tree$.set(setNodeState(this.tree$.peek(), event.path, 'active'))
134
+ return
135
+ case 'controller:disposed':
136
+ this.tree$.set(setNodeState(this.tree$.peek(), event.path, 'disposed'))
137
+ return
138
+ case 'cache:subscribed':
139
+ this.pushCache({
140
+ kind: 'subscribed',
141
+ queryKey: event.queryKey,
142
+ subscriberPath: event.subscriberPath,
143
+ })
144
+ return
145
+ case 'cache:fetch-start':
146
+ this.pushCache({ kind: 'fetch-start', queryKey: event.queryKey })
147
+ return
148
+ case 'cache:fetch-success':
149
+ this.pushCache({
150
+ kind: 'fetch-success',
151
+ queryKey: event.queryKey,
152
+ durationMs: event.durationMs,
153
+ })
154
+ return
155
+ case 'cache:fetch-error':
156
+ this.pushCache({
157
+ kind: 'fetch-error',
158
+ queryKey: event.queryKey,
159
+ durationMs: event.durationMs,
160
+ error: event.error,
161
+ })
162
+ return
163
+ case 'cache:invalidated':
164
+ this.pushCache({ kind: 'invalidated', queryKey: event.queryKey })
165
+ return
166
+ case 'cache:gc':
167
+ this.pushCache({ kind: 'gc', queryKey: event.queryKey })
168
+ return
169
+ case 'mutation:run': {
170
+ this.mutationStarts.set(mutationKey(event.path, event.name), this.now())
171
+ this.pushMutation({ kind: 'run', path: event.path, name: event.name, vars: event.vars })
172
+ return
173
+ }
174
+ case 'mutation:success': {
175
+ const durationMs = this.consumeStart(event.path, event.name)
176
+ this.pushMutation({
177
+ kind: 'success',
178
+ path: event.path,
179
+ name: event.name,
180
+ result: event.result,
181
+ ...(durationMs !== undefined ? { durationMs } : {}),
182
+ })
183
+ return
184
+ }
185
+ case 'mutation:error': {
186
+ const durationMs = this.consumeStart(event.path, event.name)
187
+ this.pushMutation({
188
+ kind: 'error',
189
+ path: event.path,
190
+ name: event.name,
191
+ error: event.error,
192
+ ...(durationMs !== undefined ? { durationMs } : {}),
193
+ })
194
+ return
195
+ }
196
+ case 'mutation:rollback':
197
+ this.pushMutation({ kind: 'rollback', path: event.path, name: event.name })
198
+ return
199
+ case 'field:validated':
200
+ this.pushField({
201
+ path: event.path,
202
+ field: event.field,
203
+ valid: event.valid,
204
+ errors: event.errors,
205
+ })
206
+ return
207
+ }
208
+ }
209
+
210
+ /** Clear every log. Tree state is preserved — the live tree is not a log. */
211
+ clearLogs(): void {
212
+ this.cache$.set([])
213
+ this.mutations$.set([])
214
+ this.fields$.set([])
215
+ }
216
+
217
+ // -----------------------------------------------------------------------
218
+ // Internals
219
+ // -----------------------------------------------------------------------
220
+
221
+ private pushCache(entry: DistributiveOmit<CacheEntry, 'id' | 't'>): void {
222
+ const full = { id: this.nextId++, t: this.now(), ...entry } as CacheEntry
223
+ this.cache$.set(appendBounded(this.cache$.peek(), full, this.maxEntries))
224
+ }
225
+
226
+ private pushMutation(entry: DistributiveOmit<MutationEntry, 'id' | 't'>): void {
227
+ const full = { id: this.nextId++, t: this.now(), ...entry } as MutationEntry
228
+ this.mutations$.set(appendBounded(this.mutations$.peek(), full, this.maxEntries))
229
+ }
230
+
231
+ private pushField(entry: Omit<FieldEntry, 'id' | 't'>): void {
232
+ const full = { id: this.nextId++, t: this.now(), ...entry } as FieldEntry
233
+ this.fields$.set(appendBounded(this.fields$.peek(), full, this.maxEntries))
234
+ }
235
+
236
+ private consumeStart(path: readonly string[], name: string | undefined): number | undefined {
237
+ const key = mutationKey(path, name)
238
+ const startedAt = this.mutationStarts.get(key)
239
+ if (startedAt === undefined) return undefined
240
+ this.mutationStarts.delete(key)
241
+ return this.now() - startedAt
242
+ }
243
+ }
244
+
245
+ function mutationKey(path: readonly string[], name: string | undefined): string {
246
+ return `${path.join('>')}#${name ?? ''}`
247
+ }
248
+
249
+ /**
250
+ * Distributes `Omit` over a discriminated union so each variant keeps its own
251
+ * keys. The default `Omit<A | B, K>` collapses to the intersection of keys —
252
+ * not what we want when constructing one variant at a time.
253
+ */
254
+ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Pure helpers — tested independently of the class.
258
+ // ---------------------------------------------------------------------------
259
+
260
+ function makeRoot(): ControllerNode {
261
+ return { path: [], state: 'active', props: undefined, children: [] }
262
+ }
263
+
264
+ function appendBounded<T>(arr: readonly T[], item: T, max: number): T[] {
265
+ const next = arr.length >= max ? arr.slice(arr.length - max + 1) : arr.slice()
266
+ next.push(item)
267
+ return next
268
+ }
269
+
270
+ /**
271
+ * Insert (or update) a node at `path` inside the tree. Auto-creates any
272
+ * missing intermediate ancestors as 'active' placeholders — needed if the
273
+ * subscriber attached after the root was constructed.
274
+ *
275
+ * Returns a NEW tree object (immutable update).
276
+ */
277
+ export function insertNode(
278
+ root: ControllerNode,
279
+ path: readonly string[],
280
+ props: unknown,
281
+ ): ControllerNode {
282
+ if (path.length === 0) {
283
+ // The root controller's "constructed" event has path === ['root']
284
+ // (one segment), not []. We never receive empty paths in practice, but
285
+ // handle defensively.
286
+ return { ...root, state: 'active', props }
287
+ }
288
+ return cloneWithUpsert(root, path, 0, props)
289
+ }
290
+
291
+ function cloneWithUpsert(
292
+ node: ControllerNode,
293
+ path: readonly string[],
294
+ depth: number,
295
+ props: unknown,
296
+ ): ControllerNode {
297
+ if (depth === path.length) {
298
+ return { ...node, state: 'active', props }
299
+ }
300
+ const segment = path[depth] as string
301
+ const idx = node.children.findIndex((c) => c.path[c.path.length - 1] === segment)
302
+ const childPath = path.slice(0, depth + 1)
303
+ if (idx === -1) {
304
+ const newChild = cloneWithUpsert(
305
+ { path: childPath, state: 'active', props: undefined, children: [] },
306
+ path,
307
+ depth + 1,
308
+ props,
309
+ )
310
+ return { ...node, children: [...node.children, newChild] }
311
+ }
312
+ const existing = node.children[idx]!
313
+ const updatedChild = cloneWithUpsert(existing, path, depth + 1, props)
314
+ const nextChildren = node.children.slice()
315
+ nextChildren[idx] = updatedChild
316
+ return { ...node, children: nextChildren }
317
+ }
318
+
319
+ /**
320
+ * Set `state` on the node at `path`. If the node doesn't exist (out-of-order
321
+ * event delivery), the tree is returned unchanged.
322
+ */
323
+ export function setNodeState(
324
+ root: ControllerNode,
325
+ path: readonly string[],
326
+ state: ControllerNode['state'],
327
+ ): ControllerNode {
328
+ if (path.length === 0) {
329
+ return { ...root, state }
330
+ }
331
+ return setStateAt(root, path, 0, state) ?? root
332
+ }
333
+
334
+ function setStateAt(
335
+ node: ControllerNode,
336
+ path: readonly string[],
337
+ depth: number,
338
+ state: ControllerNode['state'],
339
+ ): ControllerNode | null {
340
+ if (depth === path.length) {
341
+ return { ...node, state }
342
+ }
343
+ const segment = path[depth] as string
344
+ const idx = node.children.findIndex((c) => c.path[c.path.length - 1] === segment)
345
+ if (idx === -1) return null
346
+ const existing = node.children[idx]!
347
+ const updatedChild = setStateAt(existing, path, depth + 1, state)
348
+ if (updatedChild === null) return null
349
+ const nextChildren = node.children.slice()
350
+ nextChildren[idx] = updatedChild
351
+ return { ...node, children: nextChildren }
352
+ }