@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xstate-devtools/adapter",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "exports": {
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
- wss = new WSServer({ port, host })
164
- infoLog('WebSocket server listening', { host, port })
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
  }