@playfast/reform-remote-web 1.0.2 → 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/README.md CHANGED
@@ -33,7 +33,7 @@ const App = () => {
33
33
  **Reconnection is built in.** A dropped socket is retried with exponential backoff
34
34
  (`baseDelayMs`/`maxDelayMs`), and because the message handlers persist across sockets, the
35
35
  server's first frame on the new connection — always a `Snapshot` — re-syncs the tree
36
- automatically, with no stale nodes left behind. The returned value *is* a `RemoteTransport`
36
+ automatically, with no stale nodes left behind. The returned value _is_ a `RemoteTransport`
37
37
  (pass it straight to `connect` or `<RemoteUI>`) that additionally carries `status()`,
38
38
  `onStatusChange()` — together a `StatusReporter`, so `useConnectionStatus` drives the badge
39
39
  above — and `close()` to stop reconnecting.
package/package.json CHANGED
@@ -1,35 +1,37 @@
1
1
  {
2
2
  "name": "@playfast/reform-remote-web",
3
- "playbook": "./playbook",
4
- "version": "1.0.2",
5
- "type": "module",
3
+ "version": "1.1.0",
6
4
  "description": "Browser/web WebSocket client transport for reform-remote — streams a server-run reform scene to a thin renderer.",
7
5
  "keywords": [
8
- "reform",
9
6
  "effect",
10
- "websocket",
7
+ "reform",
11
8
  "remote",
12
- "transport"
9
+ "transport",
10
+ "websocket"
13
11
  ],
12
+ "bugs": {
13
+ "url": "https://github.com/playfast/reform/issues"
14
+ },
14
15
  "license": "MIT",
15
16
  "repository": {
16
17
  "type": "git",
17
18
  "url": "https://github.com/playfast/reform.git",
18
19
  "directory": "packages/reform-remote-web"
19
20
  },
20
- "bugs": {
21
- "url": "https://github.com/playfast/reform/issues"
22
- },
21
+ "files": [
22
+ "src",
23
+ "README.md"
24
+ ],
25
+ "type": "module",
23
26
  "sideEffects": false,
24
27
  "exports": {
25
28
  "./package.json": "./package.json",
26
29
  ".": "./src/index.ts",
27
30
  "./*": "./src/*.ts"
28
31
  },
29
- "files": [
30
- "src",
31
- "README.md"
32
- ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
33
35
  "scripts": {
34
36
  "clean": "rm -rf dist .tsbuildinfo",
35
37
  "check": "tsc --noEmit",
@@ -40,17 +42,15 @@
40
42
  "lint": "oxlint src",
41
43
  "lint:fix": "oxlint --fix src"
42
44
  },
43
- "peerDependencies": {
44
- "effect": "*",
45
- "@playfast/reform-remote": "*"
46
- },
47
45
  "devDependencies": {
48
46
  "@playfast/reform": "*",
49
47
  "@playfast/reform-remote": "*",
50
48
  "@playfast/reform-remote-node": "*",
51
49
  "react": "^19.0.0"
52
50
  },
53
- "publishConfig": {
54
- "access": "public"
55
- }
51
+ "peerDependencies": {
52
+ "@playfast/reform-remote": "*",
53
+ "effect": "*"
54
+ },
55
+ "playbook": "./playbook"
56
56
  }
@@ -31,11 +31,19 @@ class CounterUi extends ui('Counter', {
31
31
  props: S.Struct({ count: S.Number }),
32
32
  events: { bump: S.Struct({ by: S.Number }) },
33
33
  }) {}
34
- class Counter extends Composition.make('Counter', { title: 'Counter', ui: CounterUi, states: [Count] }) {}
34
+
35
+ class Counter extends Composition.make('Counter', {
36
+ title: 'Counter',
37
+ ui: CounterUi,
38
+ states: [Count],
39
+ })<Counter>() {}
35
40
 
36
41
  const counterScene = () => {
37
42
  const presentation = Layer.mergeAll(
38
- provide(CounterUi, Ui.make(CounterUi, ({ count }) => `count:${count}`)),
43
+ provide(
44
+ CounterUi,
45
+ Ui.make(CounterUi, ({ count }) => `count:${count}`),
46
+ ),
39
47
  StateGroup.live(Counters, { count: 0 }),
40
48
  )
41
49
  const app = Layer.mergeAll(
@@ -60,7 +68,8 @@ const probeView = (probe: Probe) =>
60
68
  return null
61
69
  })
62
70
 
63
- const draw = (node: ReactNode): void => void renderToStaticMarkup(createElement(Fragment, null, node))
71
+ const draw = (node: ReactNode): void =>
72
+ void renderToStaticMarkup(createElement(Fragment, null, node))
64
73
 
65
74
  const listening = (host: NodeWebSocketServer): Promise<number> =>
66
75
  new Promise((resolve) =>
@@ -0,0 +1,118 @@
1
+ import { expect, test } from 'vitest'
2
+ import type { ServerMessage } from '@playfast/reform-remote'
3
+ import { createWebSocketClientTransport } from './transport'
4
+
5
+ // The scene-handshake race that blanks the app on WebKit ~8% of cold loads.
6
+ //
7
+ // `serveShared.addClient` (reform-remote) pushes the initial `Snapshot` the instant the WebSocket
8
+ // opens — the client never asks for it. On our side the socket is opened eagerly (connectRemote)
9
+ // while `RemoteUI` only registers its `onMessage` handler during its FIRST React render, a
10
+ // scheduler flush later. So there is a window where the server's Snapshot arrives before any
11
+ // handler is subscribed. If the transport drops messages that land in that window, `RemoteUI`
12
+ // never receives the scene, renders null forever, and the whole app is silently blank. WebKit's
13
+ // scheduler/WS-I/O timing loses this race intermittently; Chromium wins it.
14
+ //
15
+ // These tests pin the transport's contract deterministically with a fake WebSocket whose open and
16
+ // message events we fire by hand — no real I/O, no timing flake.
17
+
18
+ type Listener = (event: unknown) => void
19
+
20
+ class FakeSocket {
21
+ static readonly CONNECTING = 0
22
+ static readonly OPEN = 1
23
+ static readonly CLOSING = 2
24
+ static readonly CLOSED = 3
25
+ static instances: FakeSocket[] = []
26
+
27
+ readyState: number = FakeSocket.CONNECTING
28
+ readonly sent: string[] = []
29
+ readonly url: string
30
+ readonly protocols: string | ReadonlyArray<string> | undefined
31
+ private readonly listeners = new Map<string, Set<Listener>>()
32
+
33
+ constructor(url: string, protocols?: string | ReadonlyArray<string>) {
34
+ this.url = url
35
+ this.protocols = protocols
36
+ FakeSocket.instances.push(this)
37
+ }
38
+
39
+ addEventListener(type: string, cb: Listener): void {
40
+ const set = this.listeners.get(type) ?? new Set<Listener>()
41
+ set.add(cb)
42
+ this.listeners.set(type, set)
43
+ }
44
+
45
+ removeEventListener(type: string, cb: Listener): void {
46
+ this.listeners.get(type)?.delete(cb)
47
+ }
48
+
49
+ send(data: string): void {
50
+ this.sent.push(data)
51
+ }
52
+
53
+ close(): void {
54
+ this.readyState = FakeSocket.CLOSED
55
+ this.fire('close', {})
56
+ }
57
+
58
+ // --- test controls (not part of the WebSocket API) ---
59
+ emitOpen(): void {
60
+ this.readyState = FakeSocket.OPEN
61
+ this.fire('open', {})
62
+ }
63
+
64
+ emitServerMessage(message: ServerMessage): void {
65
+ this.fire('message', { data: JSON.stringify(message) })
66
+ }
67
+
68
+ private fire(type: string, event: unknown): void {
69
+ this.listeners.get(type)?.forEach((cb) => cb(event))
70
+ }
71
+ }
72
+
73
+ const makeTransport = () => {
74
+ FakeSocket.instances = []
75
+ const transport = createWebSocketClientTransport({
76
+ url: 'ws://handshake-race',
77
+ WebSocket: FakeSocket as unknown as typeof WebSocket,
78
+ })
79
+ const socket = FakeSocket.instances[0]
80
+ if (socket === undefined) {
81
+ throw new Error('transport did not construct a socket')
82
+ }
83
+ return { transport, socket }
84
+ }
85
+
86
+ const SNAPSHOT: ServerMessage = { _tag: 'Snapshot', tree: [] }
87
+
88
+ test('delivers a server message that arrived before the first subscriber (handshake race)', () => {
89
+ const { transport, socket } = makeTransport()
90
+ socket.emitOpen()
91
+ // Server pushes the Snapshot on connect, BEFORE RemoteUI mounts and subscribes.
92
+ socket.emitServerMessage(SNAPSHOT)
93
+ // RemoteUI renders and subscribes only now.
94
+ const received: ServerMessage[] = []
95
+ transport.onMessage((message) => received.push(message))
96
+ expect(received).toEqual([SNAPSHOT])
97
+ })
98
+
99
+ test('still delivers messages that arrive after subscription', () => {
100
+ const { transport, socket } = makeTransport()
101
+ socket.emitOpen()
102
+ const received: ServerMessage[] = []
103
+ transport.onMessage((message) => received.push(message))
104
+ socket.emitServerMessage(SNAPSHOT)
105
+ expect(received).toEqual([SNAPSHOT])
106
+ })
107
+
108
+ test('does not replay the buffered message to a later second subscriber', () => {
109
+ const { transport, socket } = makeTransport()
110
+ socket.emitOpen()
111
+ socket.emitServerMessage(SNAPSHOT)
112
+ const first: ServerMessage[] = []
113
+ transport.onMessage((message) => first.push(message))
114
+ const second: ServerMessage[] = []
115
+ transport.onMessage((message) => second.push(message))
116
+ expect(first).toEqual([SNAPSHOT])
117
+ expect(second).toEqual([])
118
+ })
package/src/transport.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Option, Redacted } from 'effect'
1
+ import { Effect, Fiber, Option, Redacted, Schema } from 'effect'
2
2
  import type { InvokeMessage, RemoteTransport, ServerMessage } from '@playfast/reform-remote'
3
3
  import { MissingWebSocket } from './errors.js'
4
4
 
@@ -7,12 +7,60 @@ export type ClientStatus = 'connecting' | 'open' | 'reconnecting' | 'closed'
7
7
  const DEFAULT_BASE_DELAY_MS = 250
8
8
  const DEFAULT_MAX_DELAY_MS = 5000
9
9
 
10
+ const WirePropSchema = Schema.Union(
11
+ Schema.Struct({
12
+ _tag: Schema.Literal('Data'),
13
+ name: Schema.String,
14
+ value: Schema.Unknown,
15
+ }),
16
+ Schema.Struct({
17
+ _tag: Schema.Literal('Event'),
18
+ name: Schema.String,
19
+ handle: Schema.String,
20
+ }),
21
+ )
22
+ const WireNodeSchema = Schema.Struct({
23
+ id: Schema.String,
24
+ name: Schema.String,
25
+ parentId: Schema.NullOr(Schema.String),
26
+ childIndex: Schema.Number,
27
+ slot: Schema.NullOr(Schema.String),
28
+ key: Schema.NullOr(Schema.String),
29
+ props: Schema.Array(WirePropSchema),
30
+ })
31
+ const WirePatchSchema = Schema.Union(
32
+ Schema.Struct({ _tag: Schema.Literal('Upsert'), node: WireNodeSchema }),
33
+ Schema.Struct({ _tag: Schema.Literal('Delete'), id: Schema.String }),
34
+ )
35
+ const InvokeMessageJson: Schema.Schema<InvokeMessage, string, never> = Schema.parseJson(
36
+ Schema.Struct({
37
+ _tag: Schema.Literal('Invoke'),
38
+ handle: Schema.String,
39
+ payload: Schema.Unknown,
40
+ }),
41
+ )
42
+ const ServerMessageJson: Schema.Schema<ServerMessage, string, never> = Schema.parseJson(
43
+ Schema.Union(
44
+ Schema.Struct({
45
+ _tag: Schema.Literal('Snapshot'),
46
+ tree: Schema.Array(WireNodeSchema),
47
+ }),
48
+ Schema.Struct({
49
+ _tag: Schema.Literal('Patches'),
50
+ patches: Schema.Array(WirePatchSchema),
51
+ }),
52
+ ),
53
+ )
54
+ const encodeInvokeMessage: (message: InvokeMessage) => string = Schema.encodeSync(InvokeMessageJson)
55
+ const decodeServerMessage: (input: unknown) => ServerMessage =
56
+ Schema.decodeUnknownSync(ServerMessageJson)
57
+
10
58
  export interface WebSocketClientTransportOptionsExternalApi {
11
59
  readonly url: string
12
60
  readonly baseDelayMs?: number
13
61
  readonly maxDelayMs?: number
14
62
  readonly protocols?: string | ReadonlyArray<string>
15
- // Browser WebSocket can't set headers; auth rides as `bearer.<token>` subprotocol. Redacted so it never logs.
63
+ // Browser WebSocket cannot set headers; auth uses a bearer subprotocol and stays redacted.
16
64
  readonly authToken?: Redacted.Redacted<string>
17
65
  readonly WebSocket?: typeof WebSocket
18
66
  readonly onStatus?: (status: ClientStatus) => void
@@ -33,13 +81,14 @@ interface GlobalWebSocketHolderExternalApi {
33
81
  const resolveWebSocket = (injected: typeof WebSocket | undefined): typeof WebSocket => {
34
82
  const globalScope: GlobalWebSocketHolderExternalApi = globalThis
35
83
  const impl = injected ?? globalScope.WebSocket
36
- if (impl === undefined) {
37
- // oxlint-disable-next-line reform-rules/no-throw -- sync public transport factory invariant; no Effect context at construction
38
- throw new MissingWebSocket({
39
- message: 'No global WebSocket; pass `WebSocket` in options (e.g. the `ws` package in Node <22).',
40
- })
41
- }
42
- return impl
84
+ return Option.getOrThrowWith(
85
+ Option.fromNullable(impl),
86
+ () =>
87
+ new MissingWebSocket({
88
+ message:
89
+ 'No global WebSocket; pass `WebSocket` in options (e.g. the `ws` package in Node <22).',
90
+ }),
91
+ )
43
92
  }
44
93
 
45
94
  export const createWebSocketClientTransport = (
@@ -69,9 +118,18 @@ export const createWebSocketClientTransport = (
69
118
  socket: Option.Option<WebSocket>
70
119
  status: ClientStatus
71
120
  attempts: number
72
- timer: Option.Option<ReturnType<typeof setTimeout>>
121
+ timer: Option.Option<Fiber.RuntimeFiber<void, never>>
73
122
  queue: ReadonlyArray<InvokeMessage>
74
- } = { socket: Option.none(), status: 'connecting', attempts: 0, timer: Option.none(), queue: [] }
123
+ // The opening Snapshot can arrive before RemoteUI subscribes.
124
+ inbox: ReadonlyArray<ServerMessage>
125
+ } = {
126
+ socket: Option.none(),
127
+ status: 'connecting',
128
+ attempts: 0,
129
+ timer: Option.none(),
130
+ queue: [],
131
+ inbox: [],
132
+ }
75
133
 
76
134
  const setStatus = (status: ClientStatus): void => {
77
135
  if (status === state.status) {
@@ -92,8 +150,7 @@ export const createWebSocketClientTransport = (
92
150
  }
93
151
  const pending = state.queue
94
152
  state.queue = []
95
- // oxlint-disable-next-line reform-rules/no-json-parse-stringify -- wire frame encode; InvokeMessage is a structural type with no Schema here
96
- pending.forEach((message) => socket.send(JSON.stringify(message)))
153
+ pending.forEach((message) => socket.send(encodeInvokeMessage(message)))
97
154
  }
98
155
 
99
156
  const scheduleReconnect = (): void => {
@@ -103,11 +160,12 @@ export const createWebSocketClientTransport = (
103
160
  setStatus('reconnecting')
104
161
  const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** state.attempts)
105
162
  state.attempts += 1
106
- // oxlint-disable-next-line reform-rules/no-set-timeout-interval -- DOM transport reconnect backoff; sync factory has no Effect runtime
107
- state.timer = Option.some(setTimeout(connect, delay))
163
+ state.timer = Option.some(
164
+ Effect.runFork(Effect.sleep(delay).pipe(Effect.andThen(Effect.sync(connect)))),
165
+ )
108
166
  }
109
167
 
110
- // Socket may fire both error and close; clear on first drop so only one reconnect is scheduled.
168
+ // A socket may fire both error and close; clearing first prevents duplicate reconnects.
111
169
  const handleDrop = (socket: WebSocket): void => {
112
170
  if (Option.isNone(state.socket) || state.socket.value !== socket) {
113
171
  return
@@ -117,16 +175,20 @@ export const createWebSocketClientTransport = (
117
175
  }
118
176
 
119
177
  function connect(): void {
120
- const socket = protocols === undefined ? new WebSocketImpl(url) : new WebSocketImpl(url, [...protocols])
178
+ const socket =
179
+ protocols === undefined ? new WebSocketImpl(url) : new WebSocketImpl(url, [...protocols])
121
180
  state.socket = Option.some(socket)
122
181
  socket.addEventListener('open', () => {
123
182
  state.attempts = 0
124
183
  setStatus('open')
125
184
  flush()
126
185
  })
127
- socket.addEventListener('message', (event: MessageEvent) => {
128
- // oxlint-disable-next-line reform-rules/no-json-parse-stringify, reform-rules/no-type-assertion -- wire frame decode; ServerMessage is a structural type with no Schema here
129
- const message = JSON.parse(event.data as string) as ServerMessage
186
+ socket.addEventListener('message', (event: MessageEvent<unknown>) => {
187
+ const message = decodeServerMessage(event.data)
188
+ if (handlers.size === 0) {
189
+ state.inbox = [...state.inbox, message]
190
+ return
191
+ }
130
192
  handlers.forEach((handler) => handler(message))
131
193
  })
132
194
  socket.addEventListener('close', () => handleDrop(socket))
@@ -136,23 +198,28 @@ export const createWebSocketClientTransport = (
136
198
  connect()
137
199
 
138
200
  return {
139
- send: (message) => {
201
+ send: (message: InvokeMessage): void => {
140
202
  state.queue = [...state.queue, message]
141
203
  flush()
142
204
  },
143
- onMessage: (handler) => {
205
+ onMessage: (handler: (message: ServerMessage) => void): (() => void) => {
144
206
  handlers.add(handler)
207
+ if (state.inbox.length > 0) {
208
+ const buffered = state.inbox
209
+ state.inbox = []
210
+ buffered.forEach((message) => handler(message))
211
+ }
145
212
  return () => void handlers.delete(handler)
146
213
  },
147
214
  status: () => state.status,
148
- onStatusChange: (listener) => {
215
+ onStatusChange: (listener: () => void): (() => void) => {
149
216
  statusListeners.add(listener)
150
217
  return () => void statusListeners.delete(listener)
151
218
  },
152
219
  close: () => {
153
220
  setStatus('closed')
154
221
  if (Option.isSome(state.timer)) {
155
- clearTimeout(state.timer.value)
222
+ Effect.runSync(Fiber.interrupt(state.timer.value))
156
223
  }
157
224
  if (Option.isSome(state.socket)) {
158
225
  state.socket.value.close()