@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
package/src/server.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Server entrypoint — exposes a WebSocket bridge so the DevTools panel
|
|
2
|
+
// can connect to actors running in Node.
|
|
3
|
+
import type {
|
|
4
|
+
ExtensionToPageMessage,
|
|
5
|
+
PageToExtensionMessage,
|
|
6
|
+
} from '../../extension/src/shared/types.js'
|
|
7
|
+
import { createInspector, type Transport } from './core.js'
|
|
8
|
+
import {
|
|
9
|
+
debugLog as baseDebugLog,
|
|
10
|
+
infoLog as baseInfoLog,
|
|
11
|
+
warnLog as baseWarnLog,
|
|
12
|
+
} from './logging.js'
|
|
13
|
+
import { sanitize } from './sanitize.js'
|
|
14
|
+
|
|
15
|
+
export interface ServerAdapterOptions {
|
|
16
|
+
/** Port to listen on. Defaults to env XSTATE_DEVTOOLS_PORT or 9301. */
|
|
17
|
+
port?: number
|
|
18
|
+
/** Host to bind. Defaults to '127.0.0.1'. */
|
|
19
|
+
host?: string
|
|
20
|
+
/** Max messages to buffer while no panel is connected. Default 200. */
|
|
21
|
+
bufferSize?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ClientLike {
|
|
25
|
+
send(data: string): void
|
|
26
|
+
on(event: string, listener: (...args: unknown[]) => void): void
|
|
27
|
+
readyState: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const OPEN_STATE = 1
|
|
31
|
+
|
|
32
|
+
function summarizeMessage(message: ExtensionToPageMessage | PageToExtensionMessage) {
|
|
33
|
+
const summary: Record<string, unknown> = { type: message.type }
|
|
34
|
+
if ('sessionId' in message) summary.sessionId = message.sessionId
|
|
35
|
+
if ('parentSessionId' in message && message.parentSessionId) {
|
|
36
|
+
summary.parentSessionId = message.parentSessionId
|
|
37
|
+
}
|
|
38
|
+
if ('globalSeq' in message) summary.globalSeq = message.globalSeq
|
|
39
|
+
if ('timestamp' in message) summary.timestamp = message.timestamp
|
|
40
|
+
if (
|
|
41
|
+
'event' in message &&
|
|
42
|
+
message.event &&
|
|
43
|
+
typeof message.event === 'object' &&
|
|
44
|
+
'type' in message.event
|
|
45
|
+
) {
|
|
46
|
+
summary.eventType = message.event.type
|
|
47
|
+
}
|
|
48
|
+
return summary
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function debugLog(message: string, details?: unknown) {
|
|
52
|
+
baseDebugLog('server', message, details)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function infoLog(message: string, details?: unknown) {
|
|
56
|
+
baseInfoLog('server', message, details)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function warnLog(message: string, details?: unknown) {
|
|
60
|
+
baseWarnLog('server', message, details)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stringifyOutgoingMessage(message: PageToExtensionMessage): string | null {
|
|
64
|
+
const payload = { ...message, __xstateDevtools: true as const }
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
return JSON.stringify(payload)
|
|
68
|
+
} catch (error) {
|
|
69
|
+
warnLog('failed to stringify adapter message; retrying with sanitized payload', {
|
|
70
|
+
error,
|
|
71
|
+
message: summarizeMessage(message),
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
return JSON.stringify(sanitize(payload))
|
|
77
|
+
} catch (error) {
|
|
78
|
+
warnLog('dropping adapter message that could not be stringified', {
|
|
79
|
+
error,
|
|
80
|
+
message: summarizeMessage(message),
|
|
81
|
+
})
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface CachedServer {
|
|
87
|
+
clients: Set<ClientLike>
|
|
88
|
+
dispatchHandlers: Set<(msg: ExtensionToPageMessage) => void>
|
|
89
|
+
buffer: string[]
|
|
90
|
+
bufferSize: number
|
|
91
|
+
activated: boolean
|
|
92
|
+
close: () => void
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Start a local WebSocket server that the DevTools panel can connect to.
|
|
97
|
+
* Returns the inspector callback. Multiple panels can connect simultaneously.
|
|
98
|
+
*
|
|
99
|
+
* The WS server, connected clients, dispatch handlers, and pre-connection
|
|
100
|
+
* buffer are all stashed on globalThis keyed by port. This makes the function
|
|
101
|
+
* idempotent across HMR re-evaluation: subsequent calls reuse the existing
|
|
102
|
+
* server and only register new inspector hooks.
|
|
103
|
+
*
|
|
104
|
+
* Inspection events emitted before the first panel connects are buffered (up
|
|
105
|
+
* to `bufferSize`, default 200) and flushed to the first connecting client so
|
|
106
|
+
* actors registered at boot are visible.
|
|
107
|
+
*/
|
|
108
|
+
export function createServerAdapter(options: ServerAdapterOptions = {}) {
|
|
109
|
+
const port = options.port ?? (Number(process.env.XSTATE_DEVTOOLS_PORT) || 9301)
|
|
110
|
+
const host = options.host ?? '127.0.0.1'
|
|
111
|
+
const bufferSize = options.bufferSize ?? 200
|
|
112
|
+
infoLog('createServerAdapter called', { host, port, bufferSize })
|
|
113
|
+
|
|
114
|
+
const key = `__xstate_devtools_server_${port}__`
|
|
115
|
+
const cache = (globalThis as Record<string, unknown>)[key] as CachedServer | undefined
|
|
116
|
+
|
|
117
|
+
let server: CachedServer
|
|
118
|
+
if (cache) {
|
|
119
|
+
server = cache
|
|
120
|
+
infoLog('reusing cached WebSocket server', {
|
|
121
|
+
host,
|
|
122
|
+
port,
|
|
123
|
+
clientCount: server.clients.size,
|
|
124
|
+
bufferedMessages: server.buffer.length,
|
|
125
|
+
})
|
|
126
|
+
// honour the most recent caller's buffer size if larger
|
|
127
|
+
if (bufferSize > server.bufferSize) server.bufferSize = bufferSize
|
|
128
|
+
} else {
|
|
129
|
+
const clients = new Set<ClientLike>()
|
|
130
|
+
const dispatchHandlers = new Set<(msg: ExtensionToPageMessage) => void>()
|
|
131
|
+
const buffer: string[] = []
|
|
132
|
+
let wss: any = null
|
|
133
|
+
let closed = false
|
|
134
|
+
|
|
135
|
+
server = {
|
|
136
|
+
clients,
|
|
137
|
+
dispatchHandlers,
|
|
138
|
+
buffer,
|
|
139
|
+
bufferSize,
|
|
140
|
+
activated: false,
|
|
141
|
+
close: () => {
|
|
142
|
+
closed = true
|
|
143
|
+
infoLog('closing WebSocket server', { host, port, clientCount: clients.size })
|
|
144
|
+
try {
|
|
145
|
+
wss?.close()
|
|
146
|
+
} catch {
|
|
147
|
+
/* noop */
|
|
148
|
+
}
|
|
149
|
+
clients.clear()
|
|
150
|
+
dispatchHandlers.clear()
|
|
151
|
+
buffer.length = 0
|
|
152
|
+
delete (globalThis as Record<string, unknown>)[key]
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Lazily import ws so this module is import-safe in environments that
|
|
157
|
+
// never use the server entrypoint (or where ws isn't installed).
|
|
158
|
+
void (async () => {
|
|
159
|
+
try {
|
|
160
|
+
const mod = await import('ws')
|
|
161
|
+
const WSServer = (mod as any).WebSocketServer ?? (mod as any).Server
|
|
162
|
+
if (closed) return
|
|
163
|
+
wss = new WSServer({ port, host })
|
|
164
|
+
infoLog('WebSocket server listening', { host, port })
|
|
165
|
+
wss.on('connection', (ws: ClientLike) => {
|
|
166
|
+
infoLog('panel connected to WebSocket server', {
|
|
167
|
+
host,
|
|
168
|
+
port,
|
|
169
|
+
activated: server.activated,
|
|
170
|
+
bufferedMessages: server.buffer.length,
|
|
171
|
+
})
|
|
172
|
+
// Drain bootstrap buffer to the first client only.
|
|
173
|
+
if (!server.activated) {
|
|
174
|
+
server.activated = true
|
|
175
|
+
infoLog('flushing bootstrap buffer to first panel', {
|
|
176
|
+
host,
|
|
177
|
+
port,
|
|
178
|
+
bufferedMessages: server.buffer.length,
|
|
179
|
+
})
|
|
180
|
+
for (const payload of server.buffer) {
|
|
181
|
+
try {
|
|
182
|
+
ws.send(payload)
|
|
183
|
+
} catch {
|
|
184
|
+
/* ignore */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
server.buffer.length = 0
|
|
188
|
+
}
|
|
189
|
+
server.clients.add(ws)
|
|
190
|
+
ws.on('message', (raw: unknown) => {
|
|
191
|
+
try {
|
|
192
|
+
const text = typeof raw === 'string' ? raw : (raw as Buffer).toString('utf8')
|
|
193
|
+
const msg = JSON.parse(text) as ExtensionToPageMessage
|
|
194
|
+
debugLog('received dispatch from panel', summarizeMessage(msg))
|
|
195
|
+
for (const cb of server.dispatchHandlers) cb(msg)
|
|
196
|
+
} catch (error) {
|
|
197
|
+
warnLog('failed to parse panel message', { error })
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
ws.on('close', () => {
|
|
201
|
+
server.clients.delete(ws)
|
|
202
|
+
infoLog('panel disconnected from WebSocket server', {
|
|
203
|
+
host,
|
|
204
|
+
port,
|
|
205
|
+
clientCount: server.clients.size,
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
ws.on('error', (error: unknown) => {
|
|
209
|
+
server.clients.delete(ws)
|
|
210
|
+
warnLog('WebSocket client error', { error })
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
wss.on('error', (err: Error) => {
|
|
214
|
+
warnLog('WS server error', { host, port, message: err.message })
|
|
215
|
+
})
|
|
216
|
+
} catch (e) {
|
|
217
|
+
warnLog('could not start server adapter — install `ws` to enable', {
|
|
218
|
+
host,
|
|
219
|
+
port,
|
|
220
|
+
message: (e as Error).message,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
})()
|
|
224
|
+
|
|
225
|
+
;(globalThis as Record<string, unknown>)[key] = server
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const transport: Transport = {
|
|
229
|
+
send(message: PageToExtensionMessage) {
|
|
230
|
+
const payload = stringifyOutgoingMessage(message)
|
|
231
|
+
if (payload === null) return
|
|
232
|
+
|
|
233
|
+
if (!server.activated) {
|
|
234
|
+
// No panel has connected yet — buffer for the first one.
|
|
235
|
+
if (server.buffer.length >= server.bufferSize) server.buffer.shift()
|
|
236
|
+
server.buffer.push(payload)
|
|
237
|
+
debugLog('buffered outgoing adapter message; no panel connected yet', {
|
|
238
|
+
bufferedMessages: server.buffer.length,
|
|
239
|
+
message: summarizeMessage(message),
|
|
240
|
+
})
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
let sentCount = 0
|
|
244
|
+
for (const ws of server.clients) {
|
|
245
|
+
if (ws.readyState === OPEN_STATE) {
|
|
246
|
+
try {
|
|
247
|
+
ws.send(payload)
|
|
248
|
+
sentCount += 1
|
|
249
|
+
} catch {
|
|
250
|
+
/* ignore */
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
debugLog('sent adapter message to connected panels', {
|
|
255
|
+
sentCount,
|
|
256
|
+
clientCount: server.clients.size,
|
|
257
|
+
message: summarizeMessage(message),
|
|
258
|
+
})
|
|
259
|
+
},
|
|
260
|
+
subscribe(handler) {
|
|
261
|
+
server.dispatchHandlers.add(handler)
|
|
262
|
+
debugLog('registered dispatch handler', { handlerCount: server.dispatchHandlers.size })
|
|
263
|
+
return () => {
|
|
264
|
+
server.dispatchHandlers.delete(handler)
|
|
265
|
+
debugLog('removed dispatch handler', { handlerCount: server.dispatchHandlers.size })
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const inspector = createInspector(transport, 'srv')
|
|
271
|
+
return { ...inspector, close: server.close }
|
|
272
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"outDir": "./dist",
|
|
9
|
+
"rootDir": "..",
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"skipLibCheck": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|