@playfast/reform-remote-node 1.0.1 → 1.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/package.json +23 -23
- package/src/index.ts +62 -31
- package/src/server.test.ts +14 -13
package/package.json
CHANGED
|
@@ -1,36 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-remote-node",
|
|
3
|
-
"
|
|
4
|
-
"version": "1.0.1",
|
|
5
|
-
"type": "module",
|
|
3
|
+
"version": "1.1.0",
|
|
6
4
|
"description": "Node WebSocket server adapter for reform-remote — serves a reform scene's UI over the `ws` package, one isolated runtime per connection.",
|
|
7
5
|
"keywords": [
|
|
8
|
-
"reform",
|
|
9
6
|
"effect",
|
|
10
|
-
"websocket",
|
|
11
|
-
"remote",
|
|
12
7
|
"node",
|
|
13
|
-
"
|
|
8
|
+
"reform",
|
|
9
|
+
"remote",
|
|
10
|
+
"server",
|
|
11
|
+
"websocket"
|
|
14
12
|
],
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/playfast/reform/issues"
|
|
15
|
+
},
|
|
15
16
|
"license": "MIT",
|
|
16
17
|
"repository": {
|
|
17
18
|
"type": "git",
|
|
18
19
|
"url": "https://github.com/playfast/reform.git",
|
|
19
20
|
"directory": "packages/reform-remote-node"
|
|
20
21
|
},
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
24
27
|
"sideEffects": false,
|
|
25
28
|
"exports": {
|
|
26
29
|
"./package.json": "./package.json",
|
|
27
30
|
".": "./src/index.ts",
|
|
28
31
|
"./*": "./src/*.ts"
|
|
29
32
|
},
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
34
36
|
"scripts": {
|
|
35
37
|
"clean": "rm -rf dist .tsbuildinfo",
|
|
36
38
|
"check": "tsc --noEmit",
|
|
@@ -44,16 +46,14 @@
|
|
|
44
46
|
"dependencies": {
|
|
45
47
|
"ws": "^8.18.0"
|
|
46
48
|
},
|
|
47
|
-
"peerDependencies": {
|
|
48
|
-
"effect": "*",
|
|
49
|
-
"react": "^19.0.0",
|
|
50
|
-
"@playfast/reform": "*",
|
|
51
|
-
"@playfast/reform-remote": "*"
|
|
52
|
-
},
|
|
53
49
|
"devDependencies": {
|
|
54
50
|
"@types/ws": "^8.5.13"
|
|
55
51
|
},
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@playfast/reform": "*",
|
|
54
|
+
"@playfast/reform-remote": "*",
|
|
55
|
+
"effect": "*",
|
|
56
|
+
"react": "^19.0.0"
|
|
57
|
+
},
|
|
58
|
+
"playbook": "./playbook"
|
|
59
59
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Server } from 'node:http'
|
|
2
|
+
import { Schema } from 'effect'
|
|
2
3
|
import { type RawData, WebSocketServer } from 'ws'
|
|
3
|
-
import type {
|
|
4
|
+
import type { AnyScene } from '@playfast/reform/internal'
|
|
4
5
|
import {
|
|
5
6
|
type InvokeMessage,
|
|
6
7
|
type RemoteTransport,
|
|
@@ -8,53 +9,84 @@ import {
|
|
|
8
9
|
serveShared,
|
|
9
10
|
} from '@playfast/reform-remote'
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
-
* @playfast/reform-remote-node — serve a reform scene's UI over WebSocket from a
|
|
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
|
-
*/
|
|
20
|
-
|
|
21
12
|
export interface NodeWebSocketOptionsExternalApi {
|
|
22
|
-
|
|
23
|
-
readonly scene: Scene
|
|
24
|
-
/** Listen on this port (use `0` for an ephemeral port; read it back from `wss.address()`). */
|
|
13
|
+
readonly scene: AnyScene
|
|
25
14
|
readonly port?: number
|
|
26
|
-
/** Attach to an existing HTTP server instead of opening a port. */
|
|
27
15
|
readonly server?: Server
|
|
28
|
-
/** Restrict upgrades to this path. */
|
|
29
16
|
readonly path?: string
|
|
30
17
|
}
|
|
31
18
|
|
|
32
19
|
export interface NodeWebSocketServer {
|
|
33
|
-
/** The underlying `ws` server — for reading the bound address, or further config. */
|
|
34
20
|
readonly wss: WebSocketServer
|
|
35
|
-
/** Dispose the shared runtime and close the server. */
|
|
36
21
|
readonly stop: () => Promise<void>
|
|
37
22
|
}
|
|
38
23
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
24
|
+
const WirePropSchema = Schema.Union(
|
|
25
|
+
Schema.Struct({
|
|
26
|
+
_tag: Schema.Literal('Data'),
|
|
27
|
+
name: Schema.String,
|
|
28
|
+
value: Schema.Unknown,
|
|
29
|
+
}),
|
|
30
|
+
Schema.Struct({
|
|
31
|
+
_tag: Schema.Literal('Event'),
|
|
32
|
+
name: Schema.String,
|
|
33
|
+
handle: Schema.String,
|
|
34
|
+
}),
|
|
35
|
+
)
|
|
36
|
+
const WireNodeSchema = Schema.Struct({
|
|
37
|
+
id: Schema.String,
|
|
38
|
+
name: Schema.String,
|
|
39
|
+
parentId: Schema.NullOr(Schema.String),
|
|
40
|
+
childIndex: Schema.Number,
|
|
41
|
+
slot: Schema.NullOr(Schema.String),
|
|
42
|
+
key: Schema.NullOr(Schema.String),
|
|
43
|
+
props: Schema.Array(WirePropSchema),
|
|
44
|
+
})
|
|
45
|
+
const WirePatchSchema = Schema.Union(
|
|
46
|
+
Schema.Struct({ _tag: Schema.Literal('Upsert'), node: WireNodeSchema }),
|
|
47
|
+
Schema.Struct({ _tag: Schema.Literal('Delete'), id: Schema.String }),
|
|
48
|
+
)
|
|
49
|
+
const InvokeMessageJson: Schema.Schema<InvokeMessage, string, never> = Schema.parseJson(
|
|
50
|
+
Schema.Struct({
|
|
51
|
+
_tag: Schema.Literal('Invoke'),
|
|
52
|
+
handle: Schema.String,
|
|
53
|
+
payload: Schema.Unknown,
|
|
54
|
+
}),
|
|
55
|
+
)
|
|
56
|
+
const ServerMessageJson: Schema.Schema<ServerMessage, string, never> = Schema.parseJson(
|
|
57
|
+
Schema.Union(
|
|
58
|
+
Schema.Struct({
|
|
59
|
+
_tag: Schema.Literal('Snapshot'),
|
|
60
|
+
tree: Schema.Array(WireNodeSchema),
|
|
61
|
+
}),
|
|
62
|
+
Schema.Struct({
|
|
63
|
+
_tag: Schema.Literal('Patches'),
|
|
64
|
+
patches: Schema.Array(WirePatchSchema),
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
const decodeInvokeMessage: (input: unknown) => InvokeMessage =
|
|
69
|
+
Schema.decodeUnknownSync(InvokeMessageJson)
|
|
70
|
+
const encodeServerMessage: (message: ServerMessage) => string = Schema.encodeSync(ServerMessageJson)
|
|
71
|
+
|
|
72
|
+
const parseInvoke = (frame: RawData): InvokeMessage => decodeInvokeMessage(frame.toString())
|
|
42
73
|
|
|
43
|
-
export const serveNodeWebSocket = (
|
|
44
|
-
|
|
74
|
+
export const serveNodeWebSocket = (
|
|
75
|
+
options: NodeWebSocketOptionsExternalApi,
|
|
76
|
+
): NodeWebSocketServer => {
|
|
77
|
+
const serverConfig =
|
|
45
78
|
options.server !== undefined
|
|
46
|
-
?
|
|
47
|
-
:
|
|
79
|
+
? { server: options.server, path: options.path }
|
|
80
|
+
: { port: options.port === undefined ? 0 : options.port, path: options.path }
|
|
81
|
+
const wss = new WebSocketServer(serverConfig)
|
|
48
82
|
|
|
49
|
-
// One shared runtime for the whole server; every socket attaches to it.
|
|
50
83
|
const shared = serveShared({ scene: options.scene })
|
|
51
84
|
|
|
52
85
|
wss.on('connection', (socket) => {
|
|
53
86
|
const handlers = new Set<(message: InvokeMessage) => void>()
|
|
54
87
|
const transport: RemoteTransport<ServerMessage, InvokeMessage> = {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
onMessage: (handler) => {
|
|
88
|
+
send: (message: ServerMessage): void => void socket.send(encodeServerMessage(message)),
|
|
89
|
+
onMessage: (handler: (message: InvokeMessage) => void): (() => void) => {
|
|
58
90
|
handlers.add(handler)
|
|
59
91
|
return () => void handlers.delete(handler)
|
|
60
92
|
},
|
|
@@ -72,8 +104,7 @@ export const serveNodeWebSocket = (options: NodeWebSocketOptionsExternalApi): No
|
|
|
72
104
|
wss,
|
|
73
105
|
stop: async (): Promise<void> => {
|
|
74
106
|
await shared.dispose()
|
|
75
|
-
//
|
|
76
|
-
// drain on their own — `close` only fires its callback once all are gone.
|
|
107
|
+
// `wss.close` waits for live sockets to drain.
|
|
77
108
|
wss.clients.forEach((client) => client.terminate())
|
|
78
109
|
await new Promise<void>((resolve, reject) =>
|
|
79
110
|
wss.close((error) => (error === undefined ? resolve() : reject(error))),
|
package/src/server.test.ts
CHANGED
|
@@ -2,8 +2,7 @@ import type { AddressInfo } from 'node:net'
|
|
|
2
2
|
import { afterEach, expect, test, vi } from 'vitest'
|
|
3
3
|
import { Layer, Schema as S } from 'effect'
|
|
4
4
|
|
|
5
|
-
// Real loopback
|
|
6
|
-
// the 5s default (passes fast in isolation; 30s still fails a genuine hang).
|
|
5
|
+
// Real loopback I/O: headroom so parallel suite can't starve past 5s default.
|
|
7
6
|
vi.setConfig({ testTimeout: 30_000, hookTimeout: 30_000 })
|
|
8
7
|
import {
|
|
9
8
|
Composition,
|
|
@@ -13,18 +12,16 @@ import {
|
|
|
13
12
|
State,
|
|
14
13
|
StateGroup,
|
|
15
14
|
Ui,
|
|
16
|
-
Wire,
|
|
17
15
|
mount,
|
|
18
16
|
provide,
|
|
19
17
|
scene,
|
|
20
18
|
ui,
|
|
21
19
|
} from '@playfast/reform'
|
|
22
|
-
import
|
|
20
|
+
import { Wire } from '@playfast/reform/internal'
|
|
21
|
+
import type { WireNode, WireProp } from '@playfast/reform/internal'
|
|
23
22
|
import type { ServerMessage } from '@playfast/reform-remote'
|
|
24
23
|
import { type NodeWebSocketServer, serveNodeWebSocket } from './index'
|
|
25
24
|
|
|
26
|
-
// A self-contained counter scene (the adapter packages stay independent of the
|
|
27
|
-
// core package's test fixtures, which aren't part of its public surface).
|
|
28
25
|
class Count extends State.make('count', S.Number) {}
|
|
29
26
|
class Counters extends StateGroup.make(Count) {}
|
|
30
27
|
class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
|
|
@@ -33,11 +30,19 @@ class CounterUi extends ui('Counter', {
|
|
|
33
30
|
props: S.Struct({ count: S.Number }),
|
|
34
31
|
events: { bump: S.Struct({ by: S.Number }) },
|
|
35
32
|
}) {}
|
|
36
|
-
|
|
33
|
+
|
|
34
|
+
class Counter extends Composition.make('Counter', {
|
|
35
|
+
title: 'Counter',
|
|
36
|
+
ui: CounterUi,
|
|
37
|
+
states: [Count],
|
|
38
|
+
})<Counter>() {}
|
|
37
39
|
|
|
38
40
|
const counterScene = () => {
|
|
39
41
|
const presentation = Layer.mergeAll(
|
|
40
|
-
provide(
|
|
42
|
+
provide(
|
|
43
|
+
CounterUi,
|
|
44
|
+
Ui.make(CounterUi, ({ count }) => `count:${count}`),
|
|
45
|
+
),
|
|
41
46
|
StateGroup.live(Counters, { count: 0 }),
|
|
42
47
|
)
|
|
43
48
|
const app = Layer.mergeAll(
|
|
@@ -111,9 +116,7 @@ test('every connection shares ONE runtime — a bump on one socket reaches the o
|
|
|
111
116
|
open.add(host)
|
|
112
117
|
const port = await listening(host)
|
|
113
118
|
|
|
114
|
-
// Attach
|
|
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).
|
|
119
|
+
// Attach listeners before await: shared server snapshots immediately; lazy attach can miss the frame.
|
|
117
120
|
const a = new WebSocket(`ws://localhost:${port}`)
|
|
118
121
|
const snapAP = nextMessage(a, (m) => m._tag === 'Snapshot')
|
|
119
122
|
const b = new WebSocket(`ws://localhost:${port}`)
|
|
@@ -122,7 +125,6 @@ test('every connection shares ONE runtime — a bump on one socket reaches the o
|
|
|
122
125
|
const snapA = await snapAP
|
|
123
126
|
const snapB = await snapBP
|
|
124
127
|
|
|
125
|
-
// a bumps; BOTH a and b receive the broadcast diff off the shared runtime.
|
|
126
128
|
const frameAP = nextMessage(a, (m) => m._tag === 'Patches')
|
|
127
129
|
const frameBP = nextMessage(b, (m) => m._tag === 'Patches')
|
|
128
130
|
a.send(JSON.stringify({ _tag: 'Invoke', handle: '0:bump', payload: { by: 4 } }))
|
|
@@ -139,7 +141,6 @@ test('every connection shares ONE runtime — a bump on one socket reaches the o
|
|
|
139
141
|
)
|
|
140
142
|
expect(countOf(treeA[0]!)).toBe(4)
|
|
141
143
|
expect(countOf(treeB[0]!)).toBe(4)
|
|
142
|
-
// A late joiner snapshots the CURRENT shared count (4), not a fresh 0.
|
|
143
144
|
const c = new WebSocket(`ws://localhost:${port}`)
|
|
144
145
|
const snapC = await nextMessage(c, (m) => m._tag === 'Snapshot')
|
|
145
146
|
expect(countOf((snapC._tag === 'Snapshot' ? snapC.tree : [])[0]!)).toBe(4)
|