@xstate-devtools/adapter 0.1.1 → 0.1.3
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 +1 -1
- package/src/core.ts +28 -27
- package/src/server.ts +41 -3
package/package.json
CHANGED
package/src/core.ts
CHANGED
|
@@ -569,44 +569,45 @@ export function createInspector(
|
|
|
569
569
|
if (inspectionEvent.type === '@xstate.actor') {
|
|
570
570
|
const actorRef: AnyActorRef = inspectionEvent.actorRef
|
|
571
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
|
|
572
586
|
actorRefs.set(sessionId, actorRef)
|
|
587
|
+
actorMachines.set(sessionId, machine)
|
|
573
588
|
|
|
574
589
|
// Eagerly remove the actor from both maps when it stops so we don't
|
|
575
590
|
// accumulate strong references to every short-lived actor indefinitely.
|
|
576
591
|
// Without this, actors that stop silently (no further snapshot/event) are
|
|
577
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
|
+
}
|
|
578
605
|
try {
|
|
579
|
-
actorRef.subscribe({
|
|
580
|
-
complete: () => {
|
|
581
|
-
if (actorRefs.has(sessionId)) {
|
|
582
|
-
actorRefs.delete(sessionId)
|
|
583
|
-
actorMachines.delete(sessionId)
|
|
584
|
-
const stopMsg: PageToExtensionMessage = {
|
|
585
|
-
type: 'XSTATE_ACTOR_STOPPED',
|
|
586
|
-
sessionId: tag(sessionId),
|
|
587
|
-
}
|
|
588
|
-
debugLog(source, 'actor completed; notifying transport', summarizeMessage(stopMsg))
|
|
589
|
-
transport.send(stopMsg)
|
|
590
|
-
}
|
|
591
|
-
},
|
|
592
|
-
error: () => {
|
|
593
|
-
actorRefs.delete(sessionId)
|
|
594
|
-
actorMachines.delete(sessionId)
|
|
595
|
-
},
|
|
596
|
-
})
|
|
606
|
+
actorRef.subscribe({ complete: notifyStop, error: notifyStop })
|
|
597
607
|
} catch {
|
|
598
608
|
// subscribe is best-effort; older actor implementations may not support it
|
|
599
609
|
}
|
|
600
610
|
|
|
601
|
-
const actorLogic = (actorRef as { logic?: unknown }).logic as any
|
|
602
|
-
const machine = actorLogic?.root
|
|
603
|
-
? serializeMachine(
|
|
604
|
-
actorLogic,
|
|
605
|
-
actorLogic.config?.__xstateDevtoolsSource ?? getSourceLocation(source, options),
|
|
606
|
-
)
|
|
607
|
-
: null
|
|
608
|
-
actorMachines.set(sessionId, machine)
|
|
609
|
-
|
|
610
611
|
const message: PageToExtensionMessage = {
|
|
611
612
|
type: 'XSTATE_ACTOR_REGISTERED',
|
|
612
613
|
sessionId: tag(sessionId),
|
package/src/server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Server entrypoint — exposes a WebSocket bridge so the DevTools panel
|
|
2
2
|
// can connect to actors running in Node.
|
|
3
|
+
import { createServer as createTcpServer } from 'node:net'
|
|
3
4
|
import type {
|
|
4
5
|
ExtensionToPageMessage,
|
|
5
6
|
PageToExtensionMessage,
|
|
@@ -12,6 +13,28 @@ import {
|
|
|
12
13
|
} from './logging.js'
|
|
13
14
|
import { sanitize } from './sanitize.js'
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Find the lowest TCP port >= `start` that is not currently in use.
|
|
18
|
+
* Uses a temporary TCP server to probe; the port is released before resolving.
|
|
19
|
+
*/
|
|
20
|
+
function getAvailablePort(start: number): Promise<number> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const probe = createTcpServer()
|
|
23
|
+
probe.unref()
|
|
24
|
+
probe.on('error', (err: NodeJS.ErrnoException) => {
|
|
25
|
+
if (err.code === 'EADDRINUSE') {
|
|
26
|
+
resolve(getAvailablePort(start + 1))
|
|
27
|
+
} else {
|
|
28
|
+
reject(err)
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
probe.listen(start, '127.0.0.1', () => {
|
|
32
|
+
const port = (probe.address() as { port: number }).port
|
|
33
|
+
probe.close(() => resolve(port))
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
15
38
|
export interface ServerAdapterOptions {
|
|
16
39
|
/** Port to listen on. Defaults to env XSTATE_DEVTOOLS_PORT or 9301. */
|
|
17
40
|
port?: number
|
|
@@ -89,6 +112,8 @@ interface CachedServer {
|
|
|
89
112
|
buffer: string[]
|
|
90
113
|
bufferSize: number
|
|
91
114
|
activated: boolean
|
|
115
|
+
/** Resolves with the actual TCP port once the WS server is listening. */
|
|
116
|
+
port: Promise<number>
|
|
92
117
|
close: () => void
|
|
93
118
|
}
|
|
94
119
|
|
|
@@ -132,12 +157,20 @@ export function createServerAdapter(options: ServerAdapterOptions = {}) {
|
|
|
132
157
|
let wss: any = null
|
|
133
158
|
let closed = false
|
|
134
159
|
|
|
160
|
+
let portResolve!: (port: number) => void
|
|
161
|
+
let portReject!: (err: unknown) => void
|
|
162
|
+
const portPromise = new Promise<number>((res, rej) => {
|
|
163
|
+
portResolve = res
|
|
164
|
+
portReject = rej
|
|
165
|
+
})
|
|
166
|
+
|
|
135
167
|
server = {
|
|
136
168
|
clients,
|
|
137
169
|
dispatchHandlers,
|
|
138
170
|
buffer,
|
|
139
171
|
bufferSize,
|
|
140
172
|
activated: false,
|
|
173
|
+
port: portPromise,
|
|
141
174
|
close: () => {
|
|
142
175
|
closed = true
|
|
143
176
|
infoLog('closing WebSocket server', { host, port, clientCount: clients.size })
|
|
@@ -160,8 +193,12 @@ export function createServerAdapter(options: ServerAdapterOptions = {}) {
|
|
|
160
193
|
const mod = await import('ws')
|
|
161
194
|
const WSServer = (mod as any).WebSocketServer ?? (mod as any).Server
|
|
162
195
|
if (closed) return
|
|
163
|
-
|
|
164
|
-
|
|
196
|
+
const actualPort = await getAvailablePort(port)
|
|
197
|
+
if (closed) return
|
|
198
|
+
wss = new WSServer({ port: actualPort, host })
|
|
199
|
+
process.env.XSTATE_DEVTOOLS_PORT = String(actualPort)
|
|
200
|
+
portResolve(actualPort)
|
|
201
|
+
infoLog('WebSocket server listening', { host, port: actualPort })
|
|
165
202
|
wss.on('connection', (ws: ClientLike) => {
|
|
166
203
|
infoLog('panel connected to WebSocket server', {
|
|
167
204
|
host,
|
|
@@ -214,6 +251,7 @@ export function createServerAdapter(options: ServerAdapterOptions = {}) {
|
|
|
214
251
|
warnLog('WS server error', { host, port, message: err.message })
|
|
215
252
|
})
|
|
216
253
|
} catch (e) {
|
|
254
|
+
portReject(e)
|
|
217
255
|
warnLog('could not start server adapter — install `ws` to enable', {
|
|
218
256
|
host,
|
|
219
257
|
port,
|
|
@@ -268,5 +306,5 @@ export function createServerAdapter(options: ServerAdapterOptions = {}) {
|
|
|
268
306
|
}
|
|
269
307
|
|
|
270
308
|
const inspector = createInspector(transport, 'srv')
|
|
271
|
-
return { ...inspector, close: server.close }
|
|
309
|
+
return { ...inspector, close: server.close, port: server.port }
|
|
272
310
|
}
|