@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/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
+ }