@playfast/reform-remote-node 0.0.2 → 0.0.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,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-remote-node",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.2",
4
+ "version": "0.0.3",
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
- "dist",
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,132 @@
1
+ import type { AddressInfo } from 'node:net'
2
+ import { afterEach, expect, test } from 'vitest'
3
+ import { Layer, Schema as S } from 'effect'
4
+ import {
5
+ Composition,
6
+ Engine,
7
+ Event,
8
+ Reducer,
9
+ State,
10
+ StateGroup,
11
+ Ui,
12
+ Wire,
13
+ provide,
14
+ scene,
15
+ ui,
16
+ } from '@playfast/reform'
17
+ import type { WireNode, WireProp } from '@playfast/reform'
18
+ import type { ServerMessage } from '@playfast/reform-remote'
19
+ import { type NodeWebSocketServer, serveNodeWebSocket } from './index'
20
+
21
+ // A self-contained counter scene (the adapter packages stay independent of the
22
+ // core package's test fixtures, which aren't part of its public surface).
23
+ class Count extends State.make('count', S.Number) {}
24
+ class Counters extends StateGroup.make(Count) {}
25
+ class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
26
+ class Bump extends Reducer.make('Bump', { states: [Count], events: [Bumped] }) {}
27
+ class CounterUi extends ui('Counter', {
28
+ props: S.Struct({ count: S.Number }),
29
+ events: { bump: S.Struct({ by: S.Number }) },
30
+ }) {}
31
+ class Counter extends Composition.make('Counter', { title: 'Counter', ui: CounterUi, states: [Count] }) {}
32
+
33
+ const counterScene = () => {
34
+ const presentation = Layer.mergeAll(
35
+ provide(CounterUi, Ui.make(CounterUi, ({ count }) => `count:${count}`)),
36
+ StateGroup.live(Counters, { count: 0 }),
37
+ )
38
+ const app = Layer.mergeAll(
39
+ Composition.live(Counter, function* () {
40
+ const count = yield* StateGroup.select(Counters, 'count')
41
+ const bump = yield* Event.trigger(Bumped)
42
+ return (yield* CounterUi)({ count }, { bump })
43
+ }),
44
+ Reducer.live(Bump, (n, event) => n + event.by),
45
+ ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
46
+ return scene(Counter, { provide: [app] })
47
+ }
48
+
49
+ const countOf = (node: WireNode): unknown => {
50
+ const prop = node.props.find(
51
+ (candidate: WireProp): candidate is Extract<WireProp, { _tag: 'Data' }> =>
52
+ candidate._tag === 'Data' && candidate.name === 'count',
53
+ )
54
+ return prop?.value
55
+ }
56
+
57
+ const listening = (host: NodeWebSocketServer): Promise<number> =>
58
+ new Promise((resolve) =>
59
+ host.wss.once('listening', () => resolve((host.wss.address() as AddressInfo).port)),
60
+ )
61
+
62
+ const nextMessage = (
63
+ socket: WebSocket,
64
+ predicate: (message: ServerMessage) => boolean,
65
+ ): Promise<ServerMessage> =>
66
+ new Promise((resolve) => {
67
+ const onMessage = (event: MessageEvent): void => {
68
+ const message = JSON.parse(event.data as string) as ServerMessage
69
+ if (!predicate(message)) return
70
+ socket.removeEventListener('message', onMessage)
71
+ resolve(message)
72
+ }
73
+ socket.addEventListener('message', onMessage)
74
+ })
75
+
76
+ const open = new Set<{ stop: () => Promise<void> }>()
77
+ afterEach(async () => {
78
+ await Promise.all([...open].map((host) => host.stop()))
79
+ open.clear()
80
+ })
81
+
82
+ test('a native WebSocket client receives a snapshot and drives the server back over ws', async () => {
83
+ const host = serveNodeWebSocket({ scene: counterScene() })
84
+ open.add(host)
85
+ const port = await listening(host)
86
+
87
+ const socket = new WebSocket(`ws://localhost:${port}`)
88
+ try {
89
+ const snapshot = await nextMessage(socket, (message) => message._tag === 'Snapshot')
90
+ const tree = snapshot._tag === 'Snapshot' ? snapshot.tree : []
91
+ expect(countOf(tree[0]!)).toBe(0)
92
+
93
+ socket.send(JSON.stringify({ _tag: 'Invoke', handle: '0:bump', payload: { by: 7 } }))
94
+
95
+ const frame = await nextMessage(socket, (message) => message._tag === 'Patches')
96
+ const patches = frame._tag === 'Patches' ? frame.patches : []
97
+ const next = Wire.apply(tree, patches)
98
+ expect(countOf(next[0]!)).toBe(7)
99
+ } finally {
100
+ socket.close()
101
+ }
102
+ })
103
+
104
+ test('each connection runs an isolated runtime', async () => {
105
+ const host = serveNodeWebSocket({ scene: counterScene() })
106
+ open.add(host)
107
+ const port = await listening(host)
108
+
109
+ const a = new WebSocket(`ws://localhost:${port}`)
110
+ const b = new WebSocket(`ws://localhost:${port}`)
111
+ try {
112
+ const snapA = await nextMessage(a, (m) => m._tag === 'Snapshot')
113
+ await nextMessage(b, (m) => m._tag === 'Snapshot')
114
+
115
+ a.send(JSON.stringify({ _tag: 'Invoke', handle: '0:bump', payload: { by: 4 } }))
116
+ const frameA = await nextMessage(a, (m) => m._tag === 'Patches')
117
+
118
+ const treeA = Wire.apply(
119
+ snapA._tag === 'Snapshot' ? snapA.tree : [],
120
+ frameA._tag === 'Patches' ? frameA.patches : [],
121
+ )
122
+ expect(countOf(treeA[0]!)).toBe(4)
123
+ // b's runtime never saw a's bump — a re-snapshot on a fresh socket would read 0.
124
+ const c = new WebSocket(`ws://localhost:${port}`)
125
+ const snapC = await nextMessage(c, (m) => m._tag === 'Snapshot')
126
+ expect(countOf((snapC._tag === 'Snapshot' ? snapC.tree : [])[0]!)).toBe(0)
127
+ c.close()
128
+ } finally {
129
+ a.close()
130
+ b.close()
131
+ }
132
+ })
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
@@ -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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"}