@playfast/reform-remote-node 0.0.4 → 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/README.md CHANGED
@@ -9,10 +9,10 @@
9
9
  ---
10
10
 
11
11
  A concrete WebSocket server adapter for
12
- [`@playfast/reform-remote`](https://www.npmjs.com/package/@playfast/reform-remote). Each
13
- connection gets its own `serve({ scene, transport })` its own isolated runtime wrapped around the
14
- socket with JSON framing. The wire messages are already `_tag`-discriminated and serializable, so
15
- there is no envelope to write.
12
+ [`@playfast/reform-remote`](https://www.npmjs.com/package/@playfast/reform-remote). The scene runs
13
+ **once** as a single shared runtime (`serveShared`); every connection attaches to it via `addClient`,
14
+ so all clients see and drive the same state. Sockets are wrapped with JSON framing — the wire
15
+ messages are already `_tag`-discriminated and serializable, so there is no envelope to write.
16
16
 
17
17
  ```ts
18
18
  import { serveNodeWebSocket } from '@playfast/reform-remote-node'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-remote-node",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.4",
4
+ "version": "0.1.0",
5
5
  "type": "module",
6
6
  "description": "Node WebSocket server adapter for reform-remote — serves a reform scene's UI over the `ws` package, one isolated runtime per connection.",
7
7
  "keywords": [
package/src/index.ts CHANGED
@@ -4,22 +4,22 @@ import type { Scene } from '@playfast/reform'
4
4
  import {
5
5
  type InvokeMessage,
6
6
  type RemoteTransport,
7
- type ServerBinding,
8
7
  type ServerMessage,
9
- serve,
8
+ serveShared,
10
9
  } from '@playfast/reform-remote'
11
10
 
12
11
  /**
13
12
  * @playfast/reform-remote-node — serve a reform scene's UI over WebSocket from a
14
- * Node process, using the `ws` package. Each connection gets its own
15
- * `serve({ scene, transport })` its own isolated runtime wrapped around the
16
- * socket with JSON framing: the already-`_tag`-discriminated wire messages need
17
- * no envelope. The Bun-native equivalent is `@playfast/reform-remote-bun`; the
18
- * browser/web client is `@playfast/reform-remote-web`.
13
+ * Node process, using the `ws` package. The scene runs ONCE as a single shared
14
+ * runtime (`serveShared`); every connection attaches to it via `addClient`, so all
15
+ * clients see and drive the same state. Sockets are wrapped with JSON framing: the
16
+ * already-`_tag`-discriminated wire messages need no envelope. The Bun-native
17
+ * equivalent is `@playfast/reform-remote-bun`; the browser/web client is
18
+ * `@playfast/reform-remote-web`.
19
19
  */
20
20
 
21
- export interface NodeWebSocketOptions {
22
- /** The scene to run — a fresh runtime per connection. */
21
+ export interface NodeWebSocketOptionsExternalApi {
22
+ /** The scene to run — ONE shared runtime that every connection attaches to. */
23
23
  readonly scene: Scene
24
24
  /** Listen on this port (use `0` for an ephemeral port; read it back from `wss.address()`). */
25
25
  readonly port?: number
@@ -32,53 +32,49 @@ export interface NodeWebSocketOptions {
32
32
  export interface NodeWebSocketServer {
33
33
  /** The underlying `ws` server — for reading the bound address, or further config. */
34
34
  readonly wss: WebSocketServer
35
- /** Dispose every live connection's runtime and close the server. */
35
+ /** Dispose the shared runtime and close the server. */
36
36
  readonly stop: () => Promise<void>
37
37
  }
38
38
 
39
- const parseInvoke = (data: RawData): InvokeMessage => JSON.parse(data.toString()) as InvokeMessage
39
+ const parseInvoke = (frame: RawData): InvokeMessage =>
40
+ // oxlint-disable-next-line reform-rules/no-json-parse-stringify, reform-rules/no-type-assertion -- wire frame decode; InvokeMessage is a structural type with no Schema here
41
+ JSON.parse(frame.toString()) as InvokeMessage
40
42
 
41
- export const serveNodeWebSocket = (options: NodeWebSocketOptions): NodeWebSocketServer => {
43
+ export const serveNodeWebSocket = (options: NodeWebSocketOptionsExternalApi): NodeWebSocketServer => {
42
44
  const wss =
43
45
  options.server !== undefined
44
46
  ? new WebSocketServer({ server: options.server, path: options.path })
45
- : new WebSocketServer({ port: options.port ?? 0, path: options.path })
47
+ : new WebSocketServer({ port: options.port === undefined ? 0 : options.port, path: options.path })
46
48
 
47
- // Every open connection's binding, so `stop` can tear them all down.
48
- const bindings = new Set<ServerBinding>()
49
+ // One shared runtime for the whole server; every socket attaches to it.
50
+ const shared = serveShared({ scene: options.scene })
49
51
 
50
52
  wss.on('connection', (socket) => {
51
53
  const handlers = new Set<(message: InvokeMessage) => void>()
52
54
  const transport: RemoteTransport<ServerMessage, InvokeMessage> = {
55
+ // oxlint-disable-next-line reform-rules/no-json-parse-stringify -- wire frame encode; ServerMessage is a structural type with no Schema here
53
56
  send: (message) => socket.send(JSON.stringify(message)),
54
57
  onMessage: (handler) => {
55
58
  handlers.add(handler)
56
59
  return () => void handlers.delete(handler)
57
60
  },
58
61
  }
59
- socket.on('message', (data) => {
60
- const message = parseInvoke(data)
61
- for (const handler of handlers) handler(message)
62
+ socket.on('message', (frame) => {
63
+ const message = parseInvoke(frame)
64
+ handlers.forEach((handler) => handler(message))
62
65
  })
63
66
 
64
- const binding = serve({ scene: options.scene, transport })
65
- bindings.add(binding)
66
- socket.on('close', () => {
67
- bindings.delete(binding)
68
- void binding.dispose()
69
- })
70
- // Push the first snapshot once the socket is live.
71
- void binding.start()
67
+ const handle = shared.addClient(transport)
68
+ socket.on('close', () => handle.remove())
72
69
  })
73
70
 
74
71
  return {
75
72
  wss,
76
73
  stop: async (): Promise<void> => {
77
- await Promise.all([...bindings].map((binding) => binding.dispose()))
78
- bindings.clear()
74
+ await shared.dispose()
79
75
  // Terminate live sockets so `wss.close` doesn't block waiting for them to
80
76
  // drain on their own — `close` only fires its callback once all are gone.
81
- for (const client of wss.clients) client.terminate()
77
+ wss.clients.forEach((client) => client.terminate())
82
78
  await new Promise<void>((resolve, reject) =>
83
79
  wss.close((error) => (error === undefined ? resolve() : reject(error))),
84
80
  )
@@ -106,29 +106,43 @@ test('a native WebSocket client receives a snapshot and drives the server back o
106
106
  }
107
107
  })
108
108
 
109
- test('each connection runs an isolated runtime', async () => {
109
+ test('every connection shares ONE runtime — a bump on one socket reaches the others', async () => {
110
110
  const host = serveNodeWebSocket({ scene: counterScene() })
111
111
  open.add(host)
112
112
  const port = await listening(host)
113
113
 
114
+ // Attach every listener BEFORE awaiting: the shared server snapshots a new client
115
+ // off its pre-rendered baseline immediately, so a lazily-attached listener could miss
116
+ // the frame (a real `connect()` client registers its handler synchronously).
114
117
  const a = new WebSocket(`ws://localhost:${port}`)
118
+ const snapAP = nextMessage(a, (m) => m._tag === 'Snapshot')
115
119
  const b = new WebSocket(`ws://localhost:${port}`)
120
+ const snapBP = nextMessage(b, (m) => m._tag === 'Snapshot')
116
121
  try {
117
- const snapA = await nextMessage(a, (m) => m._tag === 'Snapshot')
118
- await nextMessage(b, (m) => m._tag === 'Snapshot')
122
+ const snapA = await snapAP
123
+ const snapB = await snapBP
119
124
 
125
+ // a bumps; BOTH a and b receive the broadcast diff off the shared runtime.
126
+ const frameAP = nextMessage(a, (m) => m._tag === 'Patches')
127
+ const frameBP = nextMessage(b, (m) => m._tag === 'Patches')
120
128
  a.send(JSON.stringify({ _tag: 'Invoke', handle: '0:bump', payload: { by: 4 } }))
121
- const frameA = await nextMessage(a, (m) => m._tag === 'Patches')
129
+ const frameA = await frameAP
130
+ const frameB = await frameBP
122
131
 
123
132
  const treeA = Wire.apply(
124
133
  snapA._tag === 'Snapshot' ? snapA.tree : [],
125
134
  frameA._tag === 'Patches' ? frameA.patches : [],
126
135
  )
136
+ const treeB = Wire.apply(
137
+ snapB._tag === 'Snapshot' ? snapB.tree : [],
138
+ frameB._tag === 'Patches' ? frameB.patches : [],
139
+ )
127
140
  expect(countOf(treeA[0]!)).toBe(4)
128
- // b's runtime never saw a's bump — a re-snapshot on a fresh socket would read 0.
141
+ expect(countOf(treeB[0]!)).toBe(4)
142
+ // A late joiner snapshots the CURRENT shared count (4), not a fresh 0.
129
143
  const c = new WebSocket(`ws://localhost:${port}`)
130
144
  const snapC = await nextMessage(c, (m) => m._tag === 'Snapshot')
131
- expect(countOf((snapC._tag === 'Snapshot' ? snapC.tree : [])[0]!)).toBe(0)
145
+ expect(countOf((snapC._tag === 'Snapshot' ? snapC.tree : [])[0]!)).toBe(4)
132
146
  c.close()
133
147
  } finally {
134
148
  a.close()