@xstate-devtools/adapter 0.1.3 → 0.1.4
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/README.md +49 -0
- package/dist/chunk-3GM2SFT4.js +680 -0
- package/dist/chunk-3GM2SFT4.js.map +1 -0
- package/dist/chunk-MHHRMHW5.js +62 -0
- package/dist/chunk-MHHRMHW5.js.map +1 -0
- package/dist/index.cjs +753 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +22 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +790 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +12 -0
- package/dist/react.d.ts +12 -0
- package/dist/react.js +43 -0
- package/dist/react.js.map +1 -0
- package/dist/server.cjs +936 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +29 -0
- package/dist/server.d.ts +29 -0
- package/dist/server.js +235 -0
- package/dist/server.js.map +1 -0
- package/package.json +46 -6
- package/src/core.test.ts +0 -287
- package/src/core.ts +0 -667
- package/src/index.ts +0 -73
- package/src/logging.test.ts +0 -39
- package/src/logging.ts +0 -40
- package/src/react.tsx +0 -50
- package/src/sanitize.test.ts +0 -68
- package/src/sanitize.ts +0 -70
- package/src/serialize.test.ts +0 -170
- package/src/serialize.ts +0 -148
- package/src/server.ts +0 -310
- package/tsconfig.json +0 -14
package/src/core.ts
DELETED
|
@@ -1,667 +0,0 @@
|
|
|
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
|
-
|
|
573
|
-
// Serialize machine and populate both Maps BEFORE subscribing so that
|
|
574
|
-
// the Maps are fully consistent if complete()/error() ever fire
|
|
575
|
-
// synchronously during subscribe() (e.g. an actor that starts in a
|
|
576
|
-
// final state). Populating after subscribe introduced a race where
|
|
577
|
-
// actorMachines.delete() in the callback was a no-op and the subsequent
|
|
578
|
-
// actorMachines.set() would leak the entry permanently.
|
|
579
|
-
const actorLogic = (actorRef as { logic?: unknown }).logic as any
|
|
580
|
-
const machine = actorLogic?.root
|
|
581
|
-
? serializeMachine(
|
|
582
|
-
actorLogic,
|
|
583
|
-
actorLogic.config?.__xstateDevtoolsSource ?? getSourceLocation(source, options),
|
|
584
|
-
)
|
|
585
|
-
: null
|
|
586
|
-
actorRefs.set(sessionId, actorRef)
|
|
587
|
-
actorMachines.set(sessionId, machine)
|
|
588
|
-
|
|
589
|
-
// Eagerly remove the actor from both maps when it stops so we don't
|
|
590
|
-
// accumulate strong references to every short-lived actor indefinitely.
|
|
591
|
-
// Without this, actors that stop silently (no further snapshot/event) are
|
|
592
|
-
// only removed by checkAndNotifyStop — which never fires for them.
|
|
593
|
-
const notifyStop = () => {
|
|
594
|
-
if (actorRefs.has(sessionId)) {
|
|
595
|
-
actorRefs.delete(sessionId)
|
|
596
|
-
actorMachines.delete(sessionId)
|
|
597
|
-
const stopMsg: PageToExtensionMessage = {
|
|
598
|
-
type: 'XSTATE_ACTOR_STOPPED',
|
|
599
|
-
sessionId: tag(sessionId),
|
|
600
|
-
}
|
|
601
|
-
debugLog(source, 'actor stopped; notifying transport', summarizeMessage(stopMsg))
|
|
602
|
-
transport.send(stopMsg)
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
try {
|
|
606
|
-
actorRef.subscribe({ complete: notifyStop, error: notifyStop })
|
|
607
|
-
} catch {
|
|
608
|
-
// subscribe is best-effort; older actor implementations may not support it
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
const message: PageToExtensionMessage = {
|
|
612
|
-
type: 'XSTATE_ACTOR_REGISTERED',
|
|
613
|
-
sessionId: tag(sessionId),
|
|
614
|
-
parentSessionId: tagOptional((actorRef as any)._parent?.sessionId),
|
|
615
|
-
displayName: getActorDisplayName(actorRef),
|
|
616
|
-
machine,
|
|
617
|
-
snapshot: safeSerializeSnapshot(actorRef),
|
|
618
|
-
globalSeq: nextSeq(),
|
|
619
|
-
timestamp: Date.now(),
|
|
620
|
-
}
|
|
621
|
-
infoLog(source, 'registering actor with transport', {
|
|
622
|
-
message: summarizeMessage(message),
|
|
623
|
-
actorCount: actorRefs.size,
|
|
624
|
-
hasMachine: machine !== null,
|
|
625
|
-
})
|
|
626
|
-
transport.send(message)
|
|
627
|
-
} else if (inspectionEvent.type === '@xstate.snapshot') {
|
|
628
|
-
const message: PageToExtensionMessage = {
|
|
629
|
-
type: 'XSTATE_SNAPSHOT',
|
|
630
|
-
sessionId: tag(inspectionEvent.actorRef.sessionId),
|
|
631
|
-
snapshot: serializeSnapshot(inspectionEvent.snapshot),
|
|
632
|
-
timestamp: Date.now(),
|
|
633
|
-
globalSeq: nextSeq(),
|
|
634
|
-
}
|
|
635
|
-
debugLog(source, 'sending snapshot to transport', summarizeMessage(message))
|
|
636
|
-
transport.send(message)
|
|
637
|
-
checkAndNotifyStop(inspectionEvent.actorRef)
|
|
638
|
-
} else if (inspectionEvent.type === '@xstate.event') {
|
|
639
|
-
const message: PageToExtensionMessage = {
|
|
640
|
-
type: 'XSTATE_EVENT',
|
|
641
|
-
sessionId: tag(inspectionEvent.actorRef.sessionId),
|
|
642
|
-
event: sanitize(inspectionEvent.event),
|
|
643
|
-
snapshotAfter: safeSerializeSnapshot(inspectionEvent.actorRef),
|
|
644
|
-
timestamp: Date.now(),
|
|
645
|
-
globalSeq: nextSeq(),
|
|
646
|
-
}
|
|
647
|
-
debugLog(source, 'sending event to transport', summarizeMessage(message))
|
|
648
|
-
transport.send(message)
|
|
649
|
-
checkAndNotifyStop(inspectionEvent.actorRef)
|
|
650
|
-
} else {
|
|
651
|
-
debugLog(
|
|
652
|
-
source,
|
|
653
|
-
'ignoring unsupported inspection event',
|
|
654
|
-
summarizeInspectionEvent(inspectionEvent),
|
|
655
|
-
)
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
function dispose() {
|
|
660
|
-
infoLog(source, 'disposing inspector', { actorCount: actorRefs.size })
|
|
661
|
-
unsubscribe()
|
|
662
|
-
actorRefs.clear()
|
|
663
|
-
actorMachines.clear()
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
return { inspect, dispose }
|
|
667
|
-
}
|