@playfast/reform-remote-node 0.0.5 → 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 +4 -4
- package/package.json +1 -1
- package/src/index.ts +25 -29
- package/src/server.test.ts +20 -6
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).
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
"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
|
-
|
|
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.
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
17
|
-
* no envelope. The Bun-native
|
|
18
|
-
*
|
|
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
|
|
22
|
-
/** The scene to run —
|
|
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
|
|
35
|
+
/** Dispose the shared runtime and close the server. */
|
|
36
36
|
readonly stop: () => Promise<void>
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const parseInvoke = (
|
|
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:
|
|
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
|
|
47
|
+
: new WebSocketServer({ port: options.port === undefined ? 0 : options.port, path: options.path })
|
|
46
48
|
|
|
47
|
-
//
|
|
48
|
-
const
|
|
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', (
|
|
60
|
-
const message = parseInvoke(
|
|
61
|
-
|
|
62
|
+
socket.on('message', (frame) => {
|
|
63
|
+
const message = parseInvoke(frame)
|
|
64
|
+
handlers.forEach((handler) => handler(message))
|
|
62
65
|
})
|
|
63
66
|
|
|
64
|
-
const
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
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
|
)
|
package/src/server.test.ts
CHANGED
|
@@ -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('
|
|
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
|
|
118
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
145
|
+
expect(countOf((snapC._tag === 'Snapshot' ? snapC.tree : [])[0]!)).toBe(4)
|
|
132
146
|
c.close()
|
|
133
147
|
} finally {
|
|
134
148
|
a.close()
|