@playfast/reform-remote-node 0.0.2 → 0.0.4
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 +2 -2
- package/src/index.ts +87 -0
- package/src/server.test.ts +137 -0
- package/dist/index.d.ts +0 -29
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -46
- package/dist/index.js.map +0 -1
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.0.4",
|
|
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": [
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"./*": "./src/*.ts"
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
|
-
"
|
|
31
|
+
"src",
|
|
32
32
|
"README.md"
|
|
33
33
|
],
|
|
34
34
|
"scripts": {
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Server } from 'node:http'
|
|
2
|
+
import { type RawData, WebSocketServer } from 'ws'
|
|
3
|
+
import type { Scene } from '@playfast/reform'
|
|
4
|
+
import {
|
|
5
|
+
type InvokeMessage,
|
|
6
|
+
type RemoteTransport,
|
|
7
|
+
type ServerBinding,
|
|
8
|
+
type ServerMessage,
|
|
9
|
+
serve,
|
|
10
|
+
} from '@playfast/reform-remote'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @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`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface NodeWebSocketOptions {
|
|
22
|
+
/** The scene to run — a fresh runtime per connection. */
|
|
23
|
+
readonly scene: Scene
|
|
24
|
+
/** Listen on this port (use `0` for an ephemeral port; read it back from `wss.address()`). */
|
|
25
|
+
readonly port?: number
|
|
26
|
+
/** Attach to an existing HTTP server instead of opening a port. */
|
|
27
|
+
readonly server?: Server
|
|
28
|
+
/** Restrict upgrades to this path. */
|
|
29
|
+
readonly path?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface NodeWebSocketServer {
|
|
33
|
+
/** The underlying `ws` server — for reading the bound address, or further config. */
|
|
34
|
+
readonly wss: WebSocketServer
|
|
35
|
+
/** Dispose every live connection's runtime and close the server. */
|
|
36
|
+
readonly stop: () => Promise<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const parseInvoke = (data: RawData): InvokeMessage => JSON.parse(data.toString()) as InvokeMessage
|
|
40
|
+
|
|
41
|
+
export const serveNodeWebSocket = (options: NodeWebSocketOptions): NodeWebSocketServer => {
|
|
42
|
+
const wss =
|
|
43
|
+
options.server !== undefined
|
|
44
|
+
? new WebSocketServer({ server: options.server, path: options.path })
|
|
45
|
+
: new WebSocketServer({ port: options.port ?? 0, path: options.path })
|
|
46
|
+
|
|
47
|
+
// Every open connection's binding, so `stop` can tear them all down.
|
|
48
|
+
const bindings = new Set<ServerBinding>()
|
|
49
|
+
|
|
50
|
+
wss.on('connection', (socket) => {
|
|
51
|
+
const handlers = new Set<(message: InvokeMessage) => void>()
|
|
52
|
+
const transport: RemoteTransport<ServerMessage, InvokeMessage> = {
|
|
53
|
+
send: (message) => socket.send(JSON.stringify(message)),
|
|
54
|
+
onMessage: (handler) => {
|
|
55
|
+
handlers.add(handler)
|
|
56
|
+
return () => void handlers.delete(handler)
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
socket.on('message', (data) => {
|
|
60
|
+
const message = parseInvoke(data)
|
|
61
|
+
for (const handler of handlers) handler(message)
|
|
62
|
+
})
|
|
63
|
+
|
|
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()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
wss,
|
|
76
|
+
stop: async (): Promise<void> => {
|
|
77
|
+
await Promise.all([...bindings].map((binding) => binding.dispose()))
|
|
78
|
+
bindings.clear()
|
|
79
|
+
// Terminate live sockets so `wss.close` doesn't block waiting for them to
|
|
80
|
+
// drain on their own — `close` only fires its callback once all are gone.
|
|
81
|
+
for (const client of wss.clients) client.terminate()
|
|
82
|
+
await new Promise<void>((resolve, reject) =>
|
|
83
|
+
wss.close((error) => (error === undefined ? resolve() : reject(error))),
|
|
84
|
+
)
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { AddressInfo } from 'node:net'
|
|
2
|
+
import { afterEach, expect, test, vi } from 'vitest'
|
|
3
|
+
import { Layer, Schema as S } from 'effect'
|
|
4
|
+
|
|
5
|
+
// Real loopback socket I/O: headroom so the full parallel suite can't starve it past
|
|
6
|
+
// the 5s default (passes fast in isolation; 30s still fails a genuine hang).
|
|
7
|
+
vi.setConfig({ testTimeout: 30_000, hookTimeout: 30_000 })
|
|
8
|
+
import {
|
|
9
|
+
Composition,
|
|
10
|
+
Engine,
|
|
11
|
+
Event,
|
|
12
|
+
Reducer,
|
|
13
|
+
State,
|
|
14
|
+
StateGroup,
|
|
15
|
+
Ui,
|
|
16
|
+
Wire,
|
|
17
|
+
mount,
|
|
18
|
+
provide,
|
|
19
|
+
scene,
|
|
20
|
+
ui,
|
|
21
|
+
} from '@playfast/reform'
|
|
22
|
+
import type { WireNode, WireProp } from '@playfast/reform'
|
|
23
|
+
import type { ServerMessage } from '@playfast/reform-remote'
|
|
24
|
+
import { type NodeWebSocketServer, serveNodeWebSocket } from './index'
|
|
25
|
+
|
|
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
|
+
class Count extends State.make('count', S.Number) {}
|
|
29
|
+
class Counters extends StateGroup.make(Count) {}
|
|
30
|
+
class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
|
|
31
|
+
class Bump extends Reducer.make('Bump', { states: [Count], events: [Bumped] }) {}
|
|
32
|
+
class CounterUi extends ui('Counter', {
|
|
33
|
+
props: S.Struct({ count: S.Number }),
|
|
34
|
+
events: { bump: S.Struct({ by: S.Number }) },
|
|
35
|
+
}) {}
|
|
36
|
+
class Counter extends Composition.make('Counter', { title: 'Counter', ui: CounterUi, states: [Count] }) {}
|
|
37
|
+
|
|
38
|
+
const counterScene = () => {
|
|
39
|
+
const presentation = Layer.mergeAll(
|
|
40
|
+
provide(CounterUi, Ui.make(CounterUi, ({ count }) => `count:${count}`)),
|
|
41
|
+
StateGroup.live(Counters, { count: 0 }),
|
|
42
|
+
)
|
|
43
|
+
const app = Layer.mergeAll(
|
|
44
|
+
Composition.live(Counter, function* () {
|
|
45
|
+
const count = yield* StateGroup.select(Counters, 'count')
|
|
46
|
+
const bump = yield* Event.trigger(Bumped)
|
|
47
|
+
return mount({ props: { count }, slots: {}, events: { bump } })
|
|
48
|
+
}),
|
|
49
|
+
Reducer.live(Bump, (n, event) => n + event.by),
|
|
50
|
+
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
51
|
+
return scene(Counter, { provide: [app] })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const countOf = (node: WireNode): unknown => {
|
|
55
|
+
const prop = node.props.find(
|
|
56
|
+
(candidate: WireProp): candidate is Extract<WireProp, { _tag: 'Data' }> =>
|
|
57
|
+
candidate._tag === 'Data' && candidate.name === 'count',
|
|
58
|
+
)
|
|
59
|
+
return prop?.value
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const listening = (host: NodeWebSocketServer): Promise<number> =>
|
|
63
|
+
new Promise((resolve) =>
|
|
64
|
+
host.wss.once('listening', () => resolve((host.wss.address() as AddressInfo).port)),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const nextMessage = (
|
|
68
|
+
socket: WebSocket,
|
|
69
|
+
predicate: (message: ServerMessage) => boolean,
|
|
70
|
+
): Promise<ServerMessage> =>
|
|
71
|
+
new Promise((resolve) => {
|
|
72
|
+
const onMessage = (event: MessageEvent): void => {
|
|
73
|
+
const message = JSON.parse(event.data as string) as ServerMessage
|
|
74
|
+
if (!predicate(message)) return
|
|
75
|
+
socket.removeEventListener('message', onMessage)
|
|
76
|
+
resolve(message)
|
|
77
|
+
}
|
|
78
|
+
socket.addEventListener('message', onMessage)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const open = new Set<{ stop: () => Promise<void> }>()
|
|
82
|
+
afterEach(async () => {
|
|
83
|
+
await Promise.all([...open].map((host) => host.stop()))
|
|
84
|
+
open.clear()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('a native WebSocket client receives a snapshot and drives the server back over ws', async () => {
|
|
88
|
+
const host = serveNodeWebSocket({ scene: counterScene() })
|
|
89
|
+
open.add(host)
|
|
90
|
+
const port = await listening(host)
|
|
91
|
+
|
|
92
|
+
const socket = new WebSocket(`ws://localhost:${port}`)
|
|
93
|
+
try {
|
|
94
|
+
const snapshot = await nextMessage(socket, (message) => message._tag === 'Snapshot')
|
|
95
|
+
const tree = snapshot._tag === 'Snapshot' ? snapshot.tree : []
|
|
96
|
+
expect(countOf(tree[0]!)).toBe(0)
|
|
97
|
+
|
|
98
|
+
socket.send(JSON.stringify({ _tag: 'Invoke', handle: '0:bump', payload: { by: 7 } }))
|
|
99
|
+
|
|
100
|
+
const frame = await nextMessage(socket, (message) => message._tag === 'Patches')
|
|
101
|
+
const patches = frame._tag === 'Patches' ? frame.patches : []
|
|
102
|
+
const next = Wire.apply(tree, patches)
|
|
103
|
+
expect(countOf(next[0]!)).toBe(7)
|
|
104
|
+
} finally {
|
|
105
|
+
socket.close()
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('each connection runs an isolated runtime', async () => {
|
|
110
|
+
const host = serveNodeWebSocket({ scene: counterScene() })
|
|
111
|
+
open.add(host)
|
|
112
|
+
const port = await listening(host)
|
|
113
|
+
|
|
114
|
+
const a = new WebSocket(`ws://localhost:${port}`)
|
|
115
|
+
const b = new WebSocket(`ws://localhost:${port}`)
|
|
116
|
+
try {
|
|
117
|
+
const snapA = await nextMessage(a, (m) => m._tag === 'Snapshot')
|
|
118
|
+
await nextMessage(b, (m) => m._tag === 'Snapshot')
|
|
119
|
+
|
|
120
|
+
a.send(JSON.stringify({ _tag: 'Invoke', handle: '0:bump', payload: { by: 4 } }))
|
|
121
|
+
const frameA = await nextMessage(a, (m) => m._tag === 'Patches')
|
|
122
|
+
|
|
123
|
+
const treeA = Wire.apply(
|
|
124
|
+
snapA._tag === 'Snapshot' ? snapA.tree : [],
|
|
125
|
+
frameA._tag === 'Patches' ? frameA.patches : [],
|
|
126
|
+
)
|
|
127
|
+
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.
|
|
129
|
+
const c = new WebSocket(`ws://localhost:${port}`)
|
|
130
|
+
const snapC = await nextMessage(c, (m) => m._tag === 'Snapshot')
|
|
131
|
+
expect(countOf((snapC._tag === 'Snapshot' ? snapC.tree : [])[0]!)).toBe(0)
|
|
132
|
+
c.close()
|
|
133
|
+
} finally {
|
|
134
|
+
a.close()
|
|
135
|
+
b.close()
|
|
136
|
+
}
|
|
137
|
+
})
|
package/dist/index.d.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import type { Server } from 'node:http';
|
|
2
|
-
import { WebSocketServer } from 'ws';
|
|
3
|
-
import type { Scene } from '@playfast/reform';
|
|
4
|
-
/**
|
|
5
|
-
* @playfast/reform-remote-node — serve a reform scene's UI over WebSocket from a
|
|
6
|
-
* Node process, using the `ws` package. Each connection gets its own
|
|
7
|
-
* `serve({ scene, transport })` — its own isolated runtime — wrapped around the
|
|
8
|
-
* socket with JSON framing: the already-`_tag`-discriminated wire messages need
|
|
9
|
-
* no envelope. The Bun-native equivalent is `@playfast/reform-remote-bun`; the
|
|
10
|
-
* browser/web client is `@playfast/reform-remote-web`.
|
|
11
|
-
*/
|
|
12
|
-
export interface NodeWebSocketOptions {
|
|
13
|
-
/** The scene to run — a fresh runtime per connection. */
|
|
14
|
-
readonly scene: Scene;
|
|
15
|
-
/** Listen on this port (use `0` for an ephemeral port; read it back from `wss.address()`). */
|
|
16
|
-
readonly port?: number;
|
|
17
|
-
/** Attach to an existing HTTP server instead of opening a port. */
|
|
18
|
-
readonly server?: Server;
|
|
19
|
-
/** Restrict upgrades to this path. */
|
|
20
|
-
readonly path?: string;
|
|
21
|
-
}
|
|
22
|
-
export interface NodeWebSocketServer {
|
|
23
|
-
/** The underlying `ws` server — for reading the bound address, or further config. */
|
|
24
|
-
readonly wss: WebSocketServer;
|
|
25
|
-
/** Dispose every live connection's runtime and close the server. */
|
|
26
|
-
readonly stop: () => Promise<void>;
|
|
27
|
-
}
|
|
28
|
-
export declare const serveNodeWebSocket: (options: NodeWebSocketOptions) => NodeWebSocketServer;
|
|
29
|
-
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AACvC,OAAO,EAAgB,eAAe,EAAE,MAAM,IAAI,CAAA;AAClD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAA;AAS7C;;;;;;;GAOG;AAEH,MAAM,WAAW,oBAAoB;IACnC,yDAAyD;IACzD,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IACrB,8FAA8F;IAC9F,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB,mEAAmE;IACnE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,WAAW,mBAAmB;IAClC,qFAAqF;IACrF,QAAQ,CAAC,GAAG,EAAE,eAAe,CAAA;IAC7B,oEAAoE;IACpE,QAAQ,CAAC,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CACnC;AAID,eAAO,MAAM,kBAAkB,GAAI,SAAS,oBAAoB,KAAG,mBA8ClE,CAAA"}
|
package/dist/index.js
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { WebSocketServer } from 'ws';
|
|
2
|
-
import { serve, } from '@playfast/reform-remote';
|
|
3
|
-
const parseInvoke = (data) => JSON.parse(data.toString());
|
|
4
|
-
export const serveNodeWebSocket = (options) => {
|
|
5
|
-
const wss = options.server !== undefined
|
|
6
|
-
? new WebSocketServer({ server: options.server, path: options.path })
|
|
7
|
-
: new WebSocketServer({ port: options.port ?? 0, path: options.path });
|
|
8
|
-
// Every open connection's binding, so `stop` can tear them all down.
|
|
9
|
-
const bindings = new Set();
|
|
10
|
-
wss.on('connection', (socket) => {
|
|
11
|
-
const handlers = new Set();
|
|
12
|
-
const transport = {
|
|
13
|
-
send: (message) => socket.send(JSON.stringify(message)),
|
|
14
|
-
onMessage: (handler) => {
|
|
15
|
-
handlers.add(handler);
|
|
16
|
-
return () => void handlers.delete(handler);
|
|
17
|
-
},
|
|
18
|
-
};
|
|
19
|
-
socket.on('message', (data) => {
|
|
20
|
-
const message = parseInvoke(data);
|
|
21
|
-
for (const handler of handlers)
|
|
22
|
-
handler(message);
|
|
23
|
-
});
|
|
24
|
-
const binding = serve({ scene: options.scene, transport });
|
|
25
|
-
bindings.add(binding);
|
|
26
|
-
socket.on('close', () => {
|
|
27
|
-
bindings.delete(binding);
|
|
28
|
-
void binding.dispose();
|
|
29
|
-
});
|
|
30
|
-
// Push the first snapshot once the socket is live.
|
|
31
|
-
void binding.start();
|
|
32
|
-
});
|
|
33
|
-
return {
|
|
34
|
-
wss,
|
|
35
|
-
stop: async () => {
|
|
36
|
-
await Promise.all([...bindings].map((binding) => binding.dispose()));
|
|
37
|
-
bindings.clear();
|
|
38
|
-
// Terminate live sockets so `wss.close` doesn't block waiting for them to
|
|
39
|
-
// drain on their own — `close` only fires its callback once all are gone.
|
|
40
|
-
for (const client of wss.clients)
|
|
41
|
-
client.terminate();
|
|
42
|
-
await new Promise((resolve, reject) => wss.close((error) => (error === undefined ? resolve() : reject(error))));
|
|
43
|
-
},
|
|
44
|
-
};
|
|
45
|
-
};
|
|
46
|
-
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|