@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/package.json +33 -0
- package/src/core.test.ts +287 -0
- package/src/core.ts +639 -0
- package/src/index.ts +73 -0
- package/src/logging.test.ts +39 -0
- package/src/logging.ts +40 -0
- package/src/react.tsx +50 -0
- package/src/sanitize.test.ts +68 -0
- package/src/sanitize.ts +70 -0
- package/src/serialize.test.ts +170 -0
- package/src/serialize.ts +148 -0
- package/src/server.ts +272 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { debugLog, infoLog, isLoggingEnabled, warnLog } from './logging.js'
|
|
3
|
+
|
|
4
|
+
describe('adapter logging', () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
delete globalThis.__XSTATE_DEVTOOLS_LOGGING__
|
|
7
|
+
vi.unstubAllEnvs()
|
|
8
|
+
vi.restoreAllMocks()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('is disabled by default', () => {
|
|
12
|
+
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
|
13
|
+
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
14
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
15
|
+
|
|
16
|
+
expect(isLoggingEnabled()).toBe(false)
|
|
17
|
+
|
|
18
|
+
debugLog('web:adapter', 'debug message')
|
|
19
|
+
infoLog('web:adapter', 'info message')
|
|
20
|
+
warnLog('web:adapter', 'warn message')
|
|
21
|
+
|
|
22
|
+
expect(debugSpy).not.toHaveBeenCalled()
|
|
23
|
+
expect(infoSpy).not.toHaveBeenCalled()
|
|
24
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('can be enabled explicitly', () => {
|
|
28
|
+
vi.stubEnv('XSTATE_DEVTOOLS_LOGGING', 'true')
|
|
29
|
+
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
|
30
|
+
|
|
31
|
+
expect(isLoggingEnabled()).toBe(true)
|
|
32
|
+
|
|
33
|
+
debugLog('web:adapter', 'debug message', { enabled: true })
|
|
34
|
+
|
|
35
|
+
expect(debugSpy).toHaveBeenCalledWith('[xstate-devtools:web:adapter] debug message', {
|
|
36
|
+
enabled: true,
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
})
|
package/src/logging.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
type LogLevel = 'debug' | 'info' | 'warn'
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
var __XSTATE_DEVTOOLS_LOGGING__: boolean | undefined
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function hasProcessEnv() {
|
|
8
|
+
return typeof process !== 'undefined' && typeof process.env !== 'undefined'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isLoggingEnabled() {
|
|
12
|
+
if (globalThis.__XSTATE_DEVTOOLS_LOGGING__ === true) return true
|
|
13
|
+
if (!hasProcessEnv()) return false
|
|
14
|
+
|
|
15
|
+
const value = process.env.XSTATE_DEVTOOLS_LOGGING
|
|
16
|
+
return value === '1' || value === 'true'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function log(level: LogLevel, scope: string, message: string, details?: unknown) {
|
|
20
|
+
if (!isLoggingEnabled()) return
|
|
21
|
+
|
|
22
|
+
if (details === undefined) {
|
|
23
|
+
console[level](`[xstate-devtools:${scope}] ${message}`)
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console[level](`[xstate-devtools:${scope}] ${message}`, details)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function debugLog(scope: string, message: string, details?: unknown) {
|
|
31
|
+
log('debug', scope, message, details)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function infoLog(scope: string, message: string, details?: unknown) {
|
|
35
|
+
log('info', scope, message, details)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function warnLog(scope: string, message: string, details?: unknown) {
|
|
39
|
+
log('warn', scope, message, details)
|
|
40
|
+
}
|
package/src/react.tsx
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// packages/adapter/src/react.tsx
|
|
2
|
+
|
|
3
|
+
import { useActorRef as useXStateActorRef, useMachine as useXStateMachine } from '@xstate/react'
|
|
4
|
+
import { createContext, type ReactNode, useContext, useEffect, useRef } from 'react'
|
|
5
|
+
import type { ActorOptions, AnyStateMachine } from 'xstate'
|
|
6
|
+
import { createAdapter } from './index.js'
|
|
7
|
+
|
|
8
|
+
type AdapterContext = ReturnType<typeof createAdapter> | null
|
|
9
|
+
|
|
10
|
+
const InspectorContext = createContext<AdapterContext>(null)
|
|
11
|
+
|
|
12
|
+
export function InspectorProvider({ children }: { children: ReactNode }) {
|
|
13
|
+
const adapterRef = useRef<ReturnType<typeof createAdapter> | null>(null)
|
|
14
|
+
if (!adapterRef.current && typeof window !== 'undefined') {
|
|
15
|
+
adapterRef.current = createAdapter()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
return () => {
|
|
20
|
+
adapterRef.current?.dispose()
|
|
21
|
+
adapterRef.current = null
|
|
22
|
+
}
|
|
23
|
+
}, [])
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<InspectorContext.Provider value={adapterRef.current}>{children}</InspectorContext.Provider>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useInspectedMachine<T extends AnyStateMachine>(
|
|
31
|
+
machine: T,
|
|
32
|
+
options?: ActorOptions<T>,
|
|
33
|
+
) {
|
|
34
|
+
const adapter = useContext(InspectorContext)
|
|
35
|
+
return useXStateMachine(machine, {
|
|
36
|
+
...options,
|
|
37
|
+
inspect: adapter?.inspect,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useInspectedActorRef<T extends AnyStateMachine>(
|
|
42
|
+
machine: T,
|
|
43
|
+
options?: ActorOptions<T>,
|
|
44
|
+
) {
|
|
45
|
+
const adapter = useContext(InspectorContext)
|
|
46
|
+
return useXStateActorRef(machine, {
|
|
47
|
+
...options,
|
|
48
|
+
inspect: adapter?.inspect,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// packages/adapter/src/sanitize.test.ts
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { sanitize } from './sanitize.js'
|
|
4
|
+
|
|
5
|
+
describe('sanitize', () => {
|
|
6
|
+
it('passes primitives through unchanged', () => {
|
|
7
|
+
expect(sanitize(42)).toBe(42)
|
|
8
|
+
expect(sanitize(true)).toBe(true)
|
|
9
|
+
expect(sanitize(null)).toBe(null)
|
|
10
|
+
expect(sanitize('hello')).toBe('hello')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('replaces functions with a descriptor string', () => {
|
|
14
|
+
expect(sanitize(function myFn() {})).toBe('[Function: myFn]')
|
|
15
|
+
expect(sanitize(() => {})).toBe('[Function: (anonymous)]')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('truncates long strings', () => {
|
|
19
|
+
const long = 'x'.repeat(600)
|
|
20
|
+
const result = sanitize(long) as string
|
|
21
|
+
expect(result.length).toBeLessThan(520)
|
|
22
|
+
expect(result.endsWith('…')).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('handles nested objects', () => {
|
|
26
|
+
const result = sanitize({ a: 1, b: { c: 'hello' } })
|
|
27
|
+
expect(result).toEqual({ a: 1, b: { c: 'hello' } })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('handles Maps', () => {
|
|
31
|
+
const m = new Map([['key', 'value']])
|
|
32
|
+
const result = sanitize(m) as any
|
|
33
|
+
expect(result.__type).toBe('Map')
|
|
34
|
+
expect(result.entries).toEqual([['key', 'value']])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('handles actual circular references', () => {
|
|
38
|
+
const a: any = {}
|
|
39
|
+
a.self = a
|
|
40
|
+
expect(() => sanitize(a)).not.toThrow()
|
|
41
|
+
expect(JSON.stringify(sanitize(a))).toContain('[Circular]')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('handles deep linear nesting', () => {
|
|
45
|
+
const deep: any = {}
|
|
46
|
+
let curr = deep
|
|
47
|
+
for (let i = 0; i < 15; i++) {
|
|
48
|
+
curr.child = {}
|
|
49
|
+
curr = curr.child
|
|
50
|
+
}
|
|
51
|
+
curr.value = 'bottom'
|
|
52
|
+
const result = JSON.stringify(sanitize(deep))
|
|
53
|
+
expect(result).toContain('[MaxDepth]')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('does not duplicate shared objects across branches', () => {
|
|
57
|
+
const shared = { nested: { value: 'shared' } }
|
|
58
|
+
const value = {
|
|
59
|
+
first: shared,
|
|
60
|
+
second: shared,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const result = sanitize(value) as Record<string, unknown>
|
|
64
|
+
|
|
65
|
+
expect(result.first).toEqual({ nested: { value: 'shared' } })
|
|
66
|
+
expect(result.second).toBe('[Circular]')
|
|
67
|
+
})
|
|
68
|
+
})
|
package/src/sanitize.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// packages/adapter/src/sanitize.ts
|
|
2
|
+
|
|
3
|
+
const MAX_DEPTH = 10
|
|
4
|
+
const MAX_STRING_LENGTH = 500
|
|
5
|
+
const MAX_ARRAY_LENGTH = 100
|
|
6
|
+
|
|
7
|
+
function sanitizeValue(value: unknown, depth: number, seen: WeakSet<object>): unknown {
|
|
8
|
+
if (depth > MAX_DEPTH) return '[MaxDepth]'
|
|
9
|
+
if (value === null || value === undefined) return value
|
|
10
|
+
if (typeof value === 'boolean' || typeof value === 'number') return value
|
|
11
|
+
if (typeof value === 'string') {
|
|
12
|
+
return value.length > MAX_STRING_LENGTH ? `${value.slice(0, MAX_STRING_LENGTH)}…` : value
|
|
13
|
+
}
|
|
14
|
+
if (typeof value === 'function') return `[Function: ${value.name || '(anonymous)'}]`
|
|
15
|
+
if (typeof value === 'symbol') return `[Symbol: ${value.description ?? ''}]`
|
|
16
|
+
if (typeof value === 'bigint') return `[BigInt: ${value}]`
|
|
17
|
+
if (value instanceof Error) return { __type: 'Error', name: value.name, message: value.message }
|
|
18
|
+
if (value instanceof Date) return { __type: 'Date', iso: value.toISOString() }
|
|
19
|
+
if (value instanceof RegExp) return { __type: 'RegExp', source: value.source, flags: value.flags }
|
|
20
|
+
if (typeof value === 'object') {
|
|
21
|
+
if (seen.has(value)) return '[Circular]'
|
|
22
|
+
seen.add(value)
|
|
23
|
+
}
|
|
24
|
+
if (value instanceof Map) {
|
|
25
|
+
const entries: [unknown, unknown][] = []
|
|
26
|
+
for (const [k, v] of value as Map<unknown, unknown>) {
|
|
27
|
+
if (entries.length >= MAX_ARRAY_LENGTH) break
|
|
28
|
+
entries.push([sanitizeValue(k, depth + 1, seen), sanitizeValue(v, depth + 1, seen)])
|
|
29
|
+
}
|
|
30
|
+
return { __type: 'Map', entries }
|
|
31
|
+
}
|
|
32
|
+
if (value instanceof Set) {
|
|
33
|
+
const values: unknown[] = []
|
|
34
|
+
for (const v of value as Set<unknown>) {
|
|
35
|
+
if (values.length >= MAX_ARRAY_LENGTH) break
|
|
36
|
+
values.push(sanitizeValue(v, depth + 1, seen))
|
|
37
|
+
}
|
|
38
|
+
return { __type: 'Set', values }
|
|
39
|
+
}
|
|
40
|
+
if (value instanceof Promise) return '[Promise]'
|
|
41
|
+
if (value instanceof WeakMap || value instanceof WeakSet) return '[WeakCollection]'
|
|
42
|
+
if (ArrayBuffer.isView(value)) return `[TypedArray: ${(value as any).constructor.name}]`
|
|
43
|
+
// Detect DOM nodes (works in browser and is safe to check)
|
|
44
|
+
if (typeof Node !== 'undefined' && value instanceof Node) {
|
|
45
|
+
return `[DOMNode: ${(value as Element).tagName ?? value.nodeName}]`
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
const sliced = value.slice(0, MAX_ARRAY_LENGTH)
|
|
49
|
+
const result = sliced.map((v) => sanitizeValue(v, depth + 1, seen))
|
|
50
|
+
if (value.length > MAX_ARRAY_LENGTH) result.push(`[…${value.length - MAX_ARRAY_LENGTH} more]`)
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
if (typeof value === 'object') {
|
|
54
|
+
const result: Record<string, unknown> = {}
|
|
55
|
+
let count = 0
|
|
56
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
57
|
+
if (count++ >= MAX_ARRAY_LENGTH) {
|
|
58
|
+
result['…'] = '[truncated]'
|
|
59
|
+
break
|
|
60
|
+
}
|
|
61
|
+
result[k] = sanitizeValue(v, depth + 1, seen)
|
|
62
|
+
}
|
|
63
|
+
return result
|
|
64
|
+
}
|
|
65
|
+
return String(value)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function sanitize(value: unknown, depth = 0): unknown {
|
|
69
|
+
return sanitizeValue(value, depth, new WeakSet<object>())
|
|
70
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// packages/adapter/src/serialize.test.ts
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { createMachine, fromPromise, setup } from 'xstate'
|
|
4
|
+
import { serializeMachine } from './serialize.js'
|
|
5
|
+
|
|
6
|
+
describe('serializeMachine', () => {
|
|
7
|
+
it('serializes a simple compound machine', () => {
|
|
8
|
+
const machine = createMachine({
|
|
9
|
+
id: 'test',
|
|
10
|
+
initial: 'idle',
|
|
11
|
+
states: {
|
|
12
|
+
idle: { on: { START: 'running' } },
|
|
13
|
+
running: { on: { STOP: 'idle' } },
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
const result = serializeMachine(machine)
|
|
17
|
+
expect(result.id).toBe('test')
|
|
18
|
+
expect(result.root.type).toBe('compound')
|
|
19
|
+
expect(result.root.initial).toBe('idle')
|
|
20
|
+
expect(Object.keys(result.root.states)).toEqual(['idle', 'running'])
|
|
21
|
+
expect(result.root.states.idle.on).toHaveLength(1)
|
|
22
|
+
expect(result.root.states.idle.on[0].eventType).toBe('START')
|
|
23
|
+
expect(result.root.states.idle.on[0].targets).toEqual(['test.running'])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('serializes parallel states', () => {
|
|
27
|
+
const machine = createMachine({
|
|
28
|
+
id: 'parallel',
|
|
29
|
+
type: 'parallel',
|
|
30
|
+
states: {
|
|
31
|
+
a: { initial: 'on', states: { on: {}, off: {} } },
|
|
32
|
+
b: { initial: 'on', states: { on: {}, off: {} } },
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
const result = serializeMachine(machine)
|
|
36
|
+
expect(result.root.type).toBe('parallel')
|
|
37
|
+
expect(Object.keys(result.root.states)).toEqual(['a', 'b'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('serializes named guards and actions from setup()', () => {
|
|
41
|
+
const machine = setup({
|
|
42
|
+
guards: { isReady: () => true },
|
|
43
|
+
actions: { doSomething: () => {} },
|
|
44
|
+
}).createMachine({
|
|
45
|
+
id: 'guarded',
|
|
46
|
+
initial: 'idle',
|
|
47
|
+
states: {
|
|
48
|
+
idle: {
|
|
49
|
+
on: {
|
|
50
|
+
GO: {
|
|
51
|
+
target: 'active',
|
|
52
|
+
guard: 'isReady',
|
|
53
|
+
actions: 'doSomething',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
active: {},
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
const result = serializeMachine(machine)
|
|
61
|
+
const transition = result.root.states.idle.on[0]
|
|
62
|
+
expect(transition.guard).toBe('isReady')
|
|
63
|
+
expect(transition.actions).toEqual(['doSomething'])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('serializes always transitions', () => {
|
|
67
|
+
const machine = createMachine({
|
|
68
|
+
id: 'always-test',
|
|
69
|
+
initial: 'checking',
|
|
70
|
+
states: {
|
|
71
|
+
checking: {
|
|
72
|
+
always: [{ target: 'done' }],
|
|
73
|
+
},
|
|
74
|
+
done: {},
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
const result = serializeMachine(machine)
|
|
78
|
+
expect(result.root.states.checking.always).toHaveLength(1)
|
|
79
|
+
expect(result.root.states.checking.always[0].targets).toContain('always-test.done')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('serializes invoked services', () => {
|
|
83
|
+
const fetchActor = fromPromise(async () => ({ data: 'ok' }))
|
|
84
|
+
const machine = setup({
|
|
85
|
+
actors: { fetchData: fetchActor },
|
|
86
|
+
}).createMachine({
|
|
87
|
+
id: 'invoke-test',
|
|
88
|
+
initial: 'loading',
|
|
89
|
+
states: {
|
|
90
|
+
loading: {
|
|
91
|
+
invoke: {
|
|
92
|
+
id: 'fetch',
|
|
93
|
+
src: 'fetchData',
|
|
94
|
+
onDone: 'done',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
done: {},
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
const result = serializeMachine(machine)
|
|
101
|
+
expect(result.root.states.loading.invoke).toHaveLength(1)
|
|
102
|
+
expect(result.root.states.loading.invoke[0].id).toBe('fetch')
|
|
103
|
+
expect(result.root.states.loading.invoke[0].src).toBeTruthy()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('includes sourceLocation when provided', () => {
|
|
107
|
+
const machine = createMachine({ id: 'm', initial: 'a', states: { a: {} } })
|
|
108
|
+
const result = serializeMachine(machine, 'src/auth.ts:42')
|
|
109
|
+
expect(result.sourceLocation).toBe('src/auth.ts:42')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('handles cyclic machine node graphs without recursing forever', () => {
|
|
113
|
+
const root: any = {
|
|
114
|
+
id: 'root',
|
|
115
|
+
key: 'root',
|
|
116
|
+
type: 'compound',
|
|
117
|
+
states: {},
|
|
118
|
+
transitions: new Map(),
|
|
119
|
+
always: [],
|
|
120
|
+
entry: [],
|
|
121
|
+
exit: [],
|
|
122
|
+
invoke: [],
|
|
123
|
+
}
|
|
124
|
+
root.states.self = root
|
|
125
|
+
|
|
126
|
+
const result = serializeMachine({ id: 'cyclic', root } as any)
|
|
127
|
+
|
|
128
|
+
expect(result.root.id).toBe('root')
|
|
129
|
+
expect(result.root.states.self.id).toBe('root')
|
|
130
|
+
expect(result.root.states.self.states).toEqual({})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('caps the number of serialized child states', () => {
|
|
134
|
+
const states = Object.fromEntries(
|
|
135
|
+
Array.from({ length: 150 }, (_, index) => [
|
|
136
|
+
`state${index}`,
|
|
137
|
+
{
|
|
138
|
+
id: `machine.state${index}`,
|
|
139
|
+
key: `state${index}`,
|
|
140
|
+
type: 'atomic',
|
|
141
|
+
states: {},
|
|
142
|
+
transitions: new Map(),
|
|
143
|
+
always: [],
|
|
144
|
+
entry: [],
|
|
145
|
+
exit: [],
|
|
146
|
+
invoke: [],
|
|
147
|
+
},
|
|
148
|
+
]),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const machine = {
|
|
152
|
+
id: 'bounded',
|
|
153
|
+
root: {
|
|
154
|
+
id: 'bounded',
|
|
155
|
+
key: 'bounded',
|
|
156
|
+
type: 'compound',
|
|
157
|
+
states,
|
|
158
|
+
transitions: new Map(),
|
|
159
|
+
always: [],
|
|
160
|
+
entry: [],
|
|
161
|
+
exit: [],
|
|
162
|
+
invoke: [],
|
|
163
|
+
},
|
|
164
|
+
} as any
|
|
165
|
+
|
|
166
|
+
const result = serializeMachine(machine)
|
|
167
|
+
|
|
168
|
+
expect(Object.keys(result.root.states)).toHaveLength(100)
|
|
169
|
+
})
|
|
170
|
+
})
|
package/src/serialize.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// packages/adapter/src/serialize.ts
|
|
2
|
+
import type { AnyStateMachine } from 'xstate'
|
|
3
|
+
import type {
|
|
4
|
+
SerializedInvoke,
|
|
5
|
+
SerializedMachine,
|
|
6
|
+
SerializedStateNode,
|
|
7
|
+
SerializedTransition,
|
|
8
|
+
} from '../../extension/src/shared/types.js'
|
|
9
|
+
|
|
10
|
+
const MAX_SERIALIZED_NODES = 500
|
|
11
|
+
const MAX_TRANSITIONS_PER_NODE = 100
|
|
12
|
+
const MAX_CHILD_STATES = 100
|
|
13
|
+
const MAX_ACTIONS_PER_TRANSITION = 20
|
|
14
|
+
const MAX_ENTRY_EXIT_ACTIONS = 20
|
|
15
|
+
const MAX_INVOKES_PER_NODE = 20
|
|
16
|
+
|
|
17
|
+
function serializeGuard(guard: unknown): string | undefined {
|
|
18
|
+
if (!guard) return undefined
|
|
19
|
+
if (typeof guard === 'string') return guard
|
|
20
|
+
if (typeof guard === 'function') return (guard as Function).name || '(inline)'
|
|
21
|
+
if (typeof guard === 'object' && guard !== null) {
|
|
22
|
+
const g = guard as any
|
|
23
|
+
return g.type ?? g.name ?? '(inline)'
|
|
24
|
+
}
|
|
25
|
+
return '(inline)'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function serializeAction(action: unknown): string {
|
|
29
|
+
if (typeof action === 'string') return action
|
|
30
|
+
if (typeof action === 'function') return (action as Function).name || '(anonymous)'
|
|
31
|
+
if (typeof action === 'object' && action !== null) {
|
|
32
|
+
const a = action as any
|
|
33
|
+
return a.type ?? a.name ?? String(action)
|
|
34
|
+
}
|
|
35
|
+
return String(action)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function serializeTransitionList(transitions: any[]): SerializedTransition[] {
|
|
39
|
+
return transitions.slice(0, MAX_TRANSITIONS_PER_NODE).map((t: any) => ({
|
|
40
|
+
eventType: t.eventType ?? '',
|
|
41
|
+
targets: (t.target ?? []).map((n: any) => n?.id ?? String(n)).filter(Boolean),
|
|
42
|
+
guard: serializeGuard(t.guard),
|
|
43
|
+
actions: (t.actions ?? [])
|
|
44
|
+
.slice(0, MAX_ACTIONS_PER_TRANSITION)
|
|
45
|
+
.map(serializeAction)
|
|
46
|
+
.filter(Boolean),
|
|
47
|
+
}))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function serializeInvokes(node: any): SerializedInvoke[] {
|
|
51
|
+
return (node.invoke as any[]).slice(0, MAX_INVOKES_PER_NODE).map((inv: any) => ({
|
|
52
|
+
id: inv.id ?? '(unknown)',
|
|
53
|
+
src: typeof inv.src === 'string' ? inv.src : (inv.src?.id ?? inv.src?.name ?? '(inline)'),
|
|
54
|
+
}))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface SerializeState {
|
|
58
|
+
seen: WeakSet<object>
|
|
59
|
+
count: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function serializeNode(node: any, state: SerializeState): SerializedStateNode {
|
|
63
|
+
if (!node || typeof node !== 'object') {
|
|
64
|
+
return {
|
|
65
|
+
id: '(unknown)',
|
|
66
|
+
key: '(unknown)',
|
|
67
|
+
type: 'atomic',
|
|
68
|
+
states: {},
|
|
69
|
+
on: [],
|
|
70
|
+
always: [],
|
|
71
|
+
entry: [],
|
|
72
|
+
exit: [],
|
|
73
|
+
invoke: [],
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (state.count >= MAX_SERIALIZED_NODES) {
|
|
78
|
+
return {
|
|
79
|
+
id: node.id ?? '(truncated)',
|
|
80
|
+
key: node.key ?? '(truncated)',
|
|
81
|
+
type: node.type ?? 'atomic',
|
|
82
|
+
states: {},
|
|
83
|
+
on: [],
|
|
84
|
+
always: [],
|
|
85
|
+
entry: [],
|
|
86
|
+
exit: [],
|
|
87
|
+
invoke: [],
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (state.seen.has(node)) {
|
|
92
|
+
return {
|
|
93
|
+
id: node.id ?? '(circular)',
|
|
94
|
+
key: node.key ?? '(circular)',
|
|
95
|
+
type: node.type ?? 'atomic',
|
|
96
|
+
states: {},
|
|
97
|
+
on: [],
|
|
98
|
+
always: [],
|
|
99
|
+
entry: [],
|
|
100
|
+
exit: [],
|
|
101
|
+
invoke: [],
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
state.seen.add(node)
|
|
106
|
+
state.count += 1
|
|
107
|
+
|
|
108
|
+
const allTransitions: SerializedTransition[] = []
|
|
109
|
+
if (node.transitions instanceof Map) {
|
|
110
|
+
for (const [, tList] of node.transitions) {
|
|
111
|
+
if (allTransitions.length >= MAX_TRANSITIONS_PER_NODE) break
|
|
112
|
+
allTransitions.push(...serializeTransitionList(tList))
|
|
113
|
+
if (allTransitions.length >= MAX_TRANSITIONS_PER_NODE) {
|
|
114
|
+
allTransitions.length = MAX_TRANSITIONS_PER_NODE
|
|
115
|
+
break
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const always = Array.isArray(node.always) ? serializeTransitionList(node.always) : []
|
|
121
|
+
const childEntries = Object.entries(node.states ?? {}).slice(0, MAX_CHILD_STATES)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
id: node.id,
|
|
125
|
+
key: node.key,
|
|
126
|
+
type: node.type,
|
|
127
|
+
initial: node.initial?.target?.[0]?.key,
|
|
128
|
+
states: Object.fromEntries(childEntries.map(([k, v]) => [k, serializeNode(v, state)])),
|
|
129
|
+
on: allTransitions,
|
|
130
|
+
always,
|
|
131
|
+
entry: (node.entry ?? []).slice(0, MAX_ENTRY_EXIT_ACTIONS).map(serializeAction).filter(Boolean),
|
|
132
|
+
exit: (node.exit ?? []).slice(0, MAX_ENTRY_EXIT_ACTIONS).map(serializeAction).filter(Boolean),
|
|
133
|
+
invoke: serializeInvokes(node),
|
|
134
|
+
sourceLocation: node.config?.__xstateDevtoolsSource ?? undefined,
|
|
135
|
+
description: node.config?.description ?? undefined,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function serializeMachine(
|
|
140
|
+
machine: AnyStateMachine,
|
|
141
|
+
sourceLocation?: string,
|
|
142
|
+
): SerializedMachine {
|
|
143
|
+
return {
|
|
144
|
+
id: machine.id,
|
|
145
|
+
root: serializeNode(machine.root, { seen: new WeakSet<object>(), count: 0 }),
|
|
146
|
+
sourceLocation,
|
|
147
|
+
}
|
|
148
|
+
}
|