@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 CHANGED
@@ -1,36 +1,38 @@
1
1
  {
2
2
  "name": "@playfast/reform-remote-node",
3
- "playbook": "./playbook",
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
- "server"
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
- "bugs": {
22
- "url": "https://github.com/playfast/reform/issues"
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
- "files": [
31
- "src",
32
- "README.md"
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
- "publishConfig": {
57
- "access": "public"
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 { Scene } from '@playfast/reform'
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
- /** The scene to run — ONE shared runtime that every connection attaches to. */
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 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
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 = (options: NodeWebSocketOptionsExternalApi): NodeWebSocketServer => {
44
- const wss =
74
+ export const serveNodeWebSocket = (
75
+ options: NodeWebSocketOptionsExternalApi,
76
+ ): NodeWebSocketServer => {
77
+ const serverConfig =
45
78
  options.server !== undefined
46
- ? new WebSocketServer({ server: options.server, path: options.path })
47
- : new WebSocketServer({ port: options.port === undefined ? 0 : options.port, path: options.path })
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
- // oxlint-disable-next-line reform-rules/no-json-parse-stringify -- wire frame encode; ServerMessage is a structural type with no Schema here
56
- send: (message) => socket.send(JSON.stringify(message)),
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
- // Terminate live sockets so `wss.close` doesn't block waiting for them to
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))),
@@ -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 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).
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 type { WireNode, WireProp } from '@playfast/reform'
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
- class Counter extends Composition.make('Counter', { title: 'Counter', ui: CounterUi, states: [Count] }) {}
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(CounterUi, Ui.make(CounterUi, ({ count }) => `count:${count}`)),
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 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).
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)