@xstate-devtools/adapter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core.ts ADDED
@@ -0,0 +1,639 @@
1
+ // Transport-agnostic XState inspection core.
2
+ // Browser and server entrypoints supply their own transports.
3
+ import type { AnyActorRef } from 'xstate'
4
+ import type {
5
+ ExtensionToPageMessage,
6
+ PageToExtensionMessage,
7
+ SerializedMachine,
8
+ SerializedSnapshot,
9
+ } from '../../extension/src/shared/types.js'
10
+ import {
11
+ debugLog as baseDebugLog,
12
+ infoLog as baseInfoLog,
13
+ warnLog as baseWarnLog,
14
+ } from './logging.js'
15
+ import { sanitize } from './sanitize.js'
16
+ import { serializeMachine } from './serialize.js'
17
+
18
+ export type Source = 'web' | 'srv'
19
+
20
+ export interface InspectorOptions {
21
+ /**
22
+ * Absolute filesystem root for the web app. When provided, web stack frames
23
+ * like http://localhost:5173/app/... are remapped to <webSourceRoot>/app/...
24
+ * so VS Code source links can open local files.
25
+ */
26
+ webSourceRoot?: string
27
+ }
28
+
29
+ export interface Transport {
30
+ /** Send a protocol message outbound (toward the panel). */
31
+ send: (message: PageToExtensionMessage) => void
32
+ /** Subscribe to inbound dispatch messages from the panel. Returns a teardown. */
33
+ subscribe: (handler: (message: ExtensionToPageMessage) => void) => () => void
34
+ }
35
+
36
+ type StateValue = string | { [key: string]: StateValue }
37
+
38
+ interface StateNodeLike {
39
+ id: string
40
+ key: string
41
+ type: 'atomic' | 'compound' | 'parallel' | 'final' | 'history' | string
42
+ parent?: StateNodeLike
43
+ states?: Record<string, StateNodeLike>
44
+ initial?: string | { target?: StateNodeLike[] }
45
+ }
46
+
47
+ interface MachineLike {
48
+ root: StateNodeLike
49
+ getStateNodeById: (id: string) => StateNodeLike
50
+ resolveState: (snapshot: {
51
+ value: unknown
52
+ context?: unknown
53
+ status?: string
54
+ output?: unknown
55
+ error?: unknown
56
+ historyValue?: unknown
57
+ }) => unknown
58
+ }
59
+
60
+ interface MutableActorRef extends AnyActorRef {
61
+ logic?: MachineLike
62
+ update?: (snapshot: unknown, event: { type: string; stateNodeId: string }) => void
63
+ }
64
+
65
+ function summarizeMessage(message: ExtensionToPageMessage | PageToExtensionMessage) {
66
+ const summary: Record<string, unknown> = { type: message.type }
67
+ if ('sessionId' in message) summary.sessionId = message.sessionId
68
+ if ('stateNodeId' in message) summary.stateNodeId = message.stateNodeId
69
+ if ('parentSessionId' in message && message.parentSessionId) {
70
+ summary.parentSessionId = message.parentSessionId
71
+ }
72
+ if ('globalSeq' in message) summary.globalSeq = message.globalSeq
73
+ if ('timestamp' in message) summary.timestamp = message.timestamp
74
+ if (
75
+ 'event' in message &&
76
+ message.event &&
77
+ typeof message.event === 'object' &&
78
+ 'type' in message.event
79
+ ) {
80
+ summary.eventType = message.event.type
81
+ }
82
+ return summary
83
+ }
84
+
85
+ function summarizeInspectionEvent(event: any) {
86
+ return {
87
+ type: event?.type,
88
+ sessionId: event?.actorRef?.sessionId,
89
+ eventType:
90
+ event?.type === '@xstate.event' && event?.event && typeof event.event === 'object'
91
+ ? event.event.type
92
+ : undefined,
93
+ }
94
+ }
95
+
96
+ function debugLog(source: Source, message: string, details?: unknown) {
97
+ baseDebugLog(`${source}:adapter`, message, details)
98
+ }
99
+
100
+ function infoLog(source: Source, message: string, details?: unknown) {
101
+ baseInfoLog(`${source}:adapter`, message, details)
102
+ }
103
+
104
+ function warnLog(source: Source, message: string, details?: unknown) {
105
+ baseWarnLog(`${source}:adapter`, message, details)
106
+ }
107
+
108
+ function isLibraryStackFrame(line: string): boolean {
109
+ const normalized = line.replace(/\\/g, '/').toLowerCase()
110
+ return (
111
+ normalized.includes('/node_modules/xstate/') ||
112
+ normalized.includes('/node_modules/@xstate/') ||
113
+ normalized.includes('/@xstate-devtools/adapter/') ||
114
+ normalized.includes('/packages/adapter/')
115
+ )
116
+ }
117
+
118
+ function normalizeStackFrame(line: string): string {
119
+ return line.trim().replace(/^at\s+/, '')
120
+ }
121
+
122
+ function extractStackFrameLocation(frame: string): string {
123
+ return frame.match(/\((.*)\)$/)?.[1] ?? frame
124
+ }
125
+
126
+ function isAnonymousOrEvalLocation(location: string): boolean {
127
+ const normalized = location
128
+ .trim()
129
+ .toLowerCase()
130
+ .replace(/^[./\\]+/, '')
131
+
132
+ return (
133
+ normalized === '<anonymous>' ||
134
+ normalized === 'anonymous' ||
135
+ normalized === '(anonymous)' ||
136
+ normalized === 'eval' ||
137
+ normalized === '<eval>' ||
138
+ normalized === '[native code]'
139
+ )
140
+ }
141
+
142
+ function hasFilesystemBackedPath(location: string): boolean {
143
+ const trimmed = location.trim()
144
+ if (!trimmed) return false
145
+
146
+ const match = trimmed.match(/^(.*?)(?::\d+)?(?::\d+)?$/)
147
+ const rawPath = (match?.[1] ?? trimmed).trim()
148
+ if (!rawPath || isAnonymousOrEvalLocation(rawPath)) return false
149
+
150
+ if (/^[a-zA-Z]:[\\/]/.test(rawPath)) return true
151
+ if (rawPath.startsWith('/') || rawPath.startsWith('./') || rawPath.startsWith('../')) return true
152
+
153
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(rawPath)) {
154
+ try {
155
+ const url = new URL(rawPath)
156
+ if (url.protocol === 'file:') return true
157
+ return url.pathname.startsWith('/@fs/')
158
+ } catch {
159
+ return false
160
+ }
161
+ }
162
+
163
+ return false
164
+ }
165
+
166
+ function remapWebUrlLocationToFsPath(
167
+ location: string,
168
+ source: Source,
169
+ options?: InspectorOptions,
170
+ ): string {
171
+ if (source !== 'web' || !options?.webSourceRoot) return location
172
+
173
+ const match = location.trim().match(/^(.*?)(?::(\d+))?(?::(\d+))?$/)
174
+ if (!match) return location
175
+
176
+ const [, rawPath, line, column] = match
177
+ if (!rawPath || !/^https?:\/\//.test(rawPath)) return location
178
+
179
+ try {
180
+ const url = new URL(rawPath)
181
+ const pathname = decodeURIComponent(url.pathname)
182
+ if (!pathname.startsWith('/app/')) return location
183
+
184
+ const root = options.webSourceRoot.replace(/\/+$/, '')
185
+ const filePath = `${root}${pathname}`
186
+ const suffix = line ? `:${line}${column ? `:${column}` : ''}` : ''
187
+ return `${filePath}${suffix}`
188
+ } catch {
189
+ return location
190
+ }
191
+ }
192
+
193
+ export function getSourceLocationFromStack(
194
+ stack?: string,
195
+ source: Source = 'web',
196
+ options?: InspectorOptions,
197
+ ): string | undefined {
198
+ const lines = stack?.split('\n') ?? []
199
+
200
+ for (let i = 0; i < lines.length; i += 1) {
201
+ if (i <= 2) continue
202
+
203
+ const line = lines[i]
204
+ if (isLibraryStackFrame(line)) continue
205
+
206
+ const normalizedFrame = normalizeStackFrame(line)
207
+ const location = remapWebUrlLocationToFsPath(
208
+ extractStackFrameLocation(normalizedFrame),
209
+ source,
210
+ options,
211
+ )
212
+
213
+ if (!hasFilesystemBackedPath(location)) continue
214
+
215
+ const wrapped = normalizedFrame.match(/\((.*)\)$/)
216
+ if (wrapped) {
217
+ return normalizedFrame.replace(wrapped[1], location)
218
+ }
219
+
220
+ return location
221
+ }
222
+
223
+ return undefined
224
+ }
225
+
226
+ function getSourceLocation(source: Source, options?: InspectorOptions): string | undefined {
227
+ try {
228
+ const oldLimit = Error.stackTraceLimit
229
+ Error.stackTraceLimit = 50
230
+ const stack = new Error().stack
231
+ Error.stackTraceLimit = oldLimit
232
+ return getSourceLocationFromStack(stack, source, options)
233
+ } catch {
234
+ return undefined
235
+ }
236
+ }
237
+
238
+ function serializeSnapshot(snapshot: any): SerializedSnapshot {
239
+ return {
240
+ value: snapshot?.value ?? null,
241
+ context: sanitize(snapshot?.context),
242
+ status: snapshot?.status ?? 'active',
243
+ error: snapshot?.error ? sanitize(snapshot.error) : undefined,
244
+ }
245
+ }
246
+
247
+ function safeSerializeSnapshot(actorRef: AnyActorRef): SerializedSnapshot {
248
+ try {
249
+ return serializeSnapshot(actorRef.getSnapshot())
250
+ } catch {
251
+ return { value: null, context: undefined, status: 'active' }
252
+ }
253
+ }
254
+
255
+ function getActorDisplayName(actorRef: AnyActorRef): string | undefined {
256
+ const actor = actorRef as {
257
+ logic?: { id?: string; src?: unknown; name?: string } | undefined
258
+ src?: unknown
259
+ }
260
+
261
+ const src = actor.src ?? actor.logic?.src
262
+ if (typeof src === 'string' && src.length > 0) return src
263
+ if (typeof actor.logic?.id === 'string' && actor.logic.id.length > 0) return actor.logic.id
264
+ if (typeof actor.logic?.name === 'string' && actor.logic.name.length > 0) return actor.logic.name
265
+ if (src && typeof src === 'object') {
266
+ const namedSrc = src as { id?: string; name?: string }
267
+ if (typeof namedSrc.id === 'string' && namedSrc.id.length > 0) return namedSrc.id
268
+ if (typeof namedSrc.name === 'string' && namedSrc.name.length > 0) return namedSrc.name
269
+ }
270
+ return undefined
271
+ }
272
+
273
+ function getNodeInitialChild(node: StateNodeLike): StateNodeLike | null {
274
+ if (!node.states) return null
275
+ if (typeof node.initial === 'string') {
276
+ return node.states[node.initial] ?? null
277
+ }
278
+
279
+ const target = Array.isArray(node.initial?.target) ? node.initial.target[0] : null
280
+ return target ?? null
281
+ }
282
+
283
+ function encodeChildValue(child: StateNodeLike, childValue: StateValue): StateValue {
284
+ if (child.type === 'atomic' || child.type === 'final' || child.type === 'history') {
285
+ return child.key
286
+ }
287
+
288
+ return { [child.key]: childValue }
289
+ }
290
+
291
+ function getDefaultStateValue(node: StateNodeLike): StateValue {
292
+ if (node.type === 'parallel') {
293
+ const value: Record<string, StateValue> = {}
294
+ for (const child of Object.values(node.states ?? {})) {
295
+ value[child.key] = getDefaultSelectionValue(child)
296
+ }
297
+ return value
298
+ }
299
+
300
+ const initialChild = getNodeInitialChild(node)
301
+ if (!initialChild) return {}
302
+ return encodeChildValue(initialChild, getDefaultStateValue(initialChild))
303
+ }
304
+
305
+ function getDefaultSelectionValue(node: StateNodeLike): StateValue {
306
+ if (node.type === 'atomic' || node.type === 'final' || node.type === 'history') {
307
+ return node.key
308
+ }
309
+
310
+ return getDefaultStateValue(node)
311
+ }
312
+
313
+ function getExistingChildValue(value: unknown, childKey: string): StateValue | undefined {
314
+ if (!value || typeof value !== 'object') return undefined
315
+ return (value as Record<string, StateValue>)[childKey]
316
+ }
317
+
318
+ function getPathToRoot(target: StateNodeLike, root: StateNodeLike): StateNodeLike[] {
319
+ const path: StateNodeLike[] = []
320
+ let current: StateNodeLike | undefined = target
321
+
322
+ while (current) {
323
+ path.unshift(current)
324
+ if (current.id === root.id) return path
325
+ current = current.parent
326
+ }
327
+
328
+ throw new Error(`State node '${target.id}' is not part of machine '${root.id}'`)
329
+ }
330
+
331
+ function buildTargetStateValue(
332
+ node: StateNodeLike,
333
+ path: StateNodeLike[],
334
+ currentValue: unknown,
335
+ ): StateValue {
336
+ const [, ...restPath] = path
337
+
338
+ if (restPath.length === 0) {
339
+ if (node.type === 'parallel') {
340
+ const next: Record<string, StateValue> = {}
341
+ for (const child of Object.values(node.states ?? {})) {
342
+ next[child.key] =
343
+ getExistingChildValue(currentValue, child.key) ?? getDefaultSelectionValue(child)
344
+ }
345
+ return next
346
+ }
347
+
348
+ if (node.type === 'compound') {
349
+ return getDefaultStateValue(node)
350
+ }
351
+
352
+ return node.key
353
+ }
354
+
355
+ const child = restPath[0]
356
+ if (node.type === 'parallel') {
357
+ const next: Record<string, StateValue> = {}
358
+ for (const sibling of Object.values(node.states ?? {})) {
359
+ if (sibling.key === child.key) {
360
+ next[sibling.key] = buildTargetStateValue(
361
+ sibling,
362
+ restPath,
363
+ getExistingChildValue(currentValue, sibling.key),
364
+ )
365
+ } else {
366
+ next[sibling.key] =
367
+ getExistingChildValue(currentValue, sibling.key) ?? getDefaultSelectionValue(sibling)
368
+ }
369
+ }
370
+ return next
371
+ }
372
+
373
+ const childValue = buildTargetStateValue(
374
+ child,
375
+ restPath,
376
+ getExistingChildValue(currentValue, child.key),
377
+ )
378
+ return encodeChildValue(child, childValue)
379
+ }
380
+
381
+ function setActiveState(actorRef: AnyActorRef, stateNodeId: string): void {
382
+ const mutableActorRef = actorRef as MutableActorRef
383
+ const machine = mutableActorRef.logic
384
+ if (
385
+ !machine?.getStateNodeById ||
386
+ !machine.resolveState ||
387
+ typeof mutableActorRef.update !== 'function'
388
+ ) {
389
+ throw new Error('Actor does not expose machine state mutation internals')
390
+ }
391
+
392
+ const currentSnapshot = actorRef.getSnapshot() as {
393
+ value: unknown
394
+ context?: unknown
395
+ status?: string
396
+ output?: unknown
397
+ error?: unknown
398
+ historyValue?: unknown
399
+ }
400
+ const targetNode = machine.getStateNodeById(stateNodeId)
401
+ const path = getPathToRoot(targetNode, machine.root)
402
+ const targetValue = buildTargetStateValue(machine.root, path, currentSnapshot?.value)
403
+
404
+ const nextSnapshot = machine.resolveState({
405
+ value: targetValue,
406
+ context: currentSnapshot?.context,
407
+ status: currentSnapshot?.status,
408
+ output: currentSnapshot?.output,
409
+ error: currentSnapshot?.error,
410
+ historyValue: currentSnapshot?.historyValue,
411
+ })
412
+
413
+ mutableActorRef.update(nextSnapshot, {
414
+ type: 'xstate.devtools.set-active-state',
415
+ stateNodeId,
416
+ })
417
+ }
418
+
419
+ // Cached on globalThis so HMR re-evaluating this module doesn't reset the
420
+ // monotonic seq counter mid-session. The panel re-numbers messages on ingest
421
+ // to merge multiple sources, but keeping a stable per-process seq still helps
422
+ // when the panel reconnects to an already-running adapter.
423
+ const SEQ_KEY = '__xstate_devtools_global_seq__'
424
+ function nextSeq(): number {
425
+ const g = globalThis as Record<string, unknown>
426
+ const cur = (g[SEQ_KEY] as number | undefined) ?? 0
427
+ const next = cur + 1
428
+ g[SEQ_KEY] = next
429
+ return next
430
+ }
431
+
432
+ export function createInspector(
433
+ transport: Transport,
434
+ source: Source,
435
+ options: InspectorOptions = {},
436
+ ) {
437
+ const actorRefs = new Map<string, AnyActorRef>()
438
+ const actorMachines = new Map<string, SerializedMachine | null>()
439
+ const prefix = `${source}:`
440
+ const tag = (sessionId: string) => prefix + sessionId
441
+ const tagOptional = (id: string | undefined) => (id ? prefix + id : undefined)
442
+ const stripIfMine = (id: string): string | null =>
443
+ id.startsWith(prefix) ? id.slice(prefix.length) : null
444
+
445
+ function checkAndNotifyStop(actorRef: AnyActorRef) {
446
+ let snap: any
447
+ try {
448
+ snap = actorRef.getSnapshot()
449
+ } catch {
450
+ return
451
+ }
452
+ if (snap?.status !== 'active') {
453
+ const message: PageToExtensionMessage = {
454
+ type: 'XSTATE_ACTOR_STOPPED',
455
+ sessionId: tag(actorRef.sessionId),
456
+ }
457
+ debugLog(source, 'actor stopped; notifying transport', summarizeMessage(message))
458
+ transport.send(message)
459
+ actorRefs.delete(actorRef.sessionId)
460
+ actorMachines.delete(actorRef.sessionId)
461
+ }
462
+ }
463
+
464
+ const unsubscribe = transport.subscribe((message) => {
465
+ debugLog(source, 'received message from transport', summarizeMessage(message))
466
+ if (message.type === 'XSTATE_PANEL_CONNECTED') {
467
+ // The devtools panel just connected (or reconnected). Re-broadcast every
468
+ // currently-active actor so the panel is never blank because the MV3
469
+ // service worker was killed between page load and panel open.
470
+ infoLog(source, 'panel connected; resyncing actors', { actorCount: actorRefs.size })
471
+ actorRefs.forEach((actorRef, sessionId) => {
472
+ const machine = actorMachines.get(sessionId) ?? null
473
+ const resyncMessage: PageToExtensionMessage = {
474
+ type: 'XSTATE_ACTOR_REGISTERED',
475
+ sessionId: tag(sessionId),
476
+ parentSessionId: tagOptional((actorRef as any)._parent?.sessionId),
477
+ displayName: getActorDisplayName(actorRef),
478
+ machine,
479
+ snapshot: safeSerializeSnapshot(actorRef),
480
+ globalSeq: nextSeq(),
481
+ timestamp: Date.now(),
482
+ }
483
+ debugLog(source, 'resyncing actor', summarizeMessage(resyncMessage))
484
+ transport.send(resyncMessage)
485
+ })
486
+ return
487
+ }
488
+ if (message.type === 'XSTATE_DISPATCH') {
489
+ const local = stripIfMine(message.sessionId)
490
+ if (local === null) {
491
+ debugLog(source, 'ignoring dispatch for different source', summarizeMessage(message))
492
+ return // not for this transport source
493
+ }
494
+ const ref = actorRefs.get(local)
495
+ if (ref) {
496
+ try {
497
+ debugLog(source, 'dispatching event to actor', {
498
+ sessionId: local,
499
+ eventType:
500
+ message.event && typeof message.event === 'object' && 'type' in message.event
501
+ ? message.event.type
502
+ : undefined,
503
+ })
504
+ ref.send(message.event)
505
+ } catch (e) {
506
+ warnLog(source, 'dispatch error', { error: e, sessionId: local })
507
+ }
508
+ } else {
509
+ warnLog(source, 'received dispatch for unknown actor', {
510
+ sessionId: local,
511
+ knownActors: actorRefs.size,
512
+ })
513
+ }
514
+ return
515
+ }
516
+ if (message.type === 'XSTATE_SET_ACTIVE_STATE') {
517
+ const local = stripIfMine(message.sessionId)
518
+ if (local === null) {
519
+ debugLog(
520
+ source,
521
+ 'ignoring state activation for different source',
522
+ summarizeMessage(message),
523
+ )
524
+ return
525
+ }
526
+
527
+ const ref = actorRefs.get(local)
528
+ if (!ref) {
529
+ warnLog(source, 'received state activation for unknown actor', {
530
+ sessionId: local,
531
+ knownActors: actorRefs.size,
532
+ })
533
+ return
534
+ }
535
+
536
+ try {
537
+ debugLog(source, 'setting active state on actor', summarizeMessage(message))
538
+ setActiveState(ref, message.stateNodeId)
539
+ const snapshotMessage: PageToExtensionMessage = {
540
+ type: 'XSTATE_SNAPSHOT',
541
+ sessionId: tag(local),
542
+ snapshot: safeSerializeSnapshot(ref),
543
+ timestamp: Date.now(),
544
+ globalSeq: nextSeq(),
545
+ }
546
+ debugLog(
547
+ source,
548
+ 'sending snapshot after state activation',
549
+ summarizeMessage(snapshotMessage),
550
+ )
551
+ transport.send(snapshotMessage)
552
+ } catch (error) {
553
+ warnLog(source, 'failed to set active state', {
554
+ error,
555
+ sessionId: local,
556
+ stateNodeId: message.stateNodeId,
557
+ })
558
+ }
559
+ }
560
+ })
561
+
562
+ // Notify the extension that the adapter is ready
563
+ transport.send({ type: 'XSTATE_ADAPTER_READY' })
564
+
565
+ infoLog(source, 'inspector created')
566
+
567
+ const inspect = (inspectionEvent: any) => {
568
+ debugLog(source, 'inspect callback invoked', summarizeInspectionEvent(inspectionEvent))
569
+ if (inspectionEvent.type === '@xstate.actor') {
570
+ const actorRef: AnyActorRef = inspectionEvent.actorRef
571
+ const sessionId: string = actorRef.sessionId
572
+ actorRefs.set(sessionId, actorRef)
573
+
574
+ const actorLogic = (actorRef as { logic?: unknown }).logic as any
575
+ const machine = actorLogic?.root
576
+ ? serializeMachine(
577
+ actorLogic,
578
+ actorLogic.config?.__xstateDevtoolsSource ?? getSourceLocation(source, options),
579
+ )
580
+ : null
581
+ actorMachines.set(sessionId, machine)
582
+
583
+ const message: PageToExtensionMessage = {
584
+ type: 'XSTATE_ACTOR_REGISTERED',
585
+ sessionId: tag(sessionId),
586
+ parentSessionId: tagOptional((actorRef as any)._parent?.sessionId),
587
+ displayName: getActorDisplayName(actorRef),
588
+ machine,
589
+ snapshot: safeSerializeSnapshot(actorRef),
590
+ globalSeq: nextSeq(),
591
+ timestamp: Date.now(),
592
+ }
593
+ infoLog(source, 'registering actor with transport', {
594
+ message: summarizeMessage(message),
595
+ actorCount: actorRefs.size,
596
+ hasMachine: machine !== null,
597
+ })
598
+ transport.send(message)
599
+ } else if (inspectionEvent.type === '@xstate.snapshot') {
600
+ const message: PageToExtensionMessage = {
601
+ type: 'XSTATE_SNAPSHOT',
602
+ sessionId: tag(inspectionEvent.actorRef.sessionId),
603
+ snapshot: serializeSnapshot(inspectionEvent.snapshot),
604
+ timestamp: Date.now(),
605
+ globalSeq: nextSeq(),
606
+ }
607
+ debugLog(source, 'sending snapshot to transport', summarizeMessage(message))
608
+ transport.send(message)
609
+ checkAndNotifyStop(inspectionEvent.actorRef)
610
+ } else if (inspectionEvent.type === '@xstate.event') {
611
+ const message: PageToExtensionMessage = {
612
+ type: 'XSTATE_EVENT',
613
+ sessionId: tag(inspectionEvent.actorRef.sessionId),
614
+ event: sanitize(inspectionEvent.event),
615
+ snapshotAfter: safeSerializeSnapshot(inspectionEvent.actorRef),
616
+ timestamp: Date.now(),
617
+ globalSeq: nextSeq(),
618
+ }
619
+ debugLog(source, 'sending event to transport', summarizeMessage(message))
620
+ transport.send(message)
621
+ checkAndNotifyStop(inspectionEvent.actorRef)
622
+ } else {
623
+ debugLog(
624
+ source,
625
+ 'ignoring unsupported inspection event',
626
+ summarizeInspectionEvent(inspectionEvent),
627
+ )
628
+ }
629
+ }
630
+
631
+ function dispose() {
632
+ infoLog(source, 'disposing inspector', { actorCount: actorRefs.size })
633
+ unsubscribe()
634
+ actorRefs.clear()
635
+ actorMachines.clear()
636
+ }
637
+
638
+ return { inspect, dispose }
639
+ }
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ // Browser entrypoint — uses window.postMessage via the extension's injected bridge.
2
+ import type {
3
+ ExtensionToPageMessage,
4
+ PageToExtensionMessage,
5
+ } from '../../extension/src/shared/types.js'
6
+ import { createInspector, type InspectorOptions, type Transport } from './core.js'
7
+ import { debugLog, infoLog, warnLog } from './logging.js'
8
+
9
+ declare global {
10
+ interface Window {
11
+ __XSTATE_DEVTOOLS__?: {
12
+ send: (message: unknown) => void
13
+ }
14
+ }
15
+ }
16
+
17
+ export function createAdapter(options: InspectorOptions = {}) {
18
+ if (typeof window === 'undefined') {
19
+ // Non-browser env (SSR/SSG/server) — return a no-op so importing this module is safe.
20
+ infoLog('web:adapter', 'createAdapter called without window; returning no-op adapter')
21
+ return { inspect: () => {}, dispose: () => {} }
22
+ }
23
+
24
+ infoLog('web:adapter', 'creating browser adapter', {
25
+ hookInstalled: Boolean(window.__XSTATE_DEVTOOLS__),
26
+ })
27
+
28
+ let warnedMissingHook = false
29
+
30
+ const transport: Transport = {
31
+ send(message: PageToExtensionMessage) {
32
+ const payload = { ...message, __xstateDevtools: true as const }
33
+ debugLog('web:adapter', 'sending message via page hook', {
34
+ type: message.type,
35
+ sessionId: 'sessionId' in message ? message.sessionId : undefined,
36
+ })
37
+ if (window.__XSTATE_DEVTOOLS__) {
38
+ window.__XSTATE_DEVTOOLS__.send(payload)
39
+ return
40
+ }
41
+
42
+ if (!warnedMissingHook) {
43
+ warnedMissingHook = true
44
+ warnLog('web:adapter', 'page hook missing; using direct window.postMessage fallback')
45
+ }
46
+ // Fallback keeps inspection working if MAIN-world injection is unavailable.
47
+ window.postMessage(payload, '*')
48
+ },
49
+ subscribe(handler) {
50
+ infoLog('web:adapter', 'subscribing to window messages')
51
+ const onMessage = (evt: MessageEvent) => {
52
+ if (evt.source !== window) return
53
+ const data = evt.data
54
+ if (!data?.__xstateDevtools) return
55
+ debugLog('web:adapter', 'received message from window bridge', {
56
+ type: (data as ExtensionToPageMessage).type,
57
+ sessionId:
58
+ 'sessionId' in (data as ExtensionToPageMessage)
59
+ ? (data as ExtensionToPageMessage & { sessionId?: string }).sessionId
60
+ : undefined,
61
+ })
62
+ handler(data as ExtensionToPageMessage)
63
+ }
64
+ window.addEventListener('message', onMessage)
65
+ return () => {
66
+ infoLog('web:adapter', 'unsubscribing from window messages')
67
+ window.removeEventListener('message', onMessage)
68
+ }
69
+ },
70
+ }
71
+
72
+ return createInspector(transport, 'web', options)
73
+ }