@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 +1 -1
- package/package.json +20 -20
- package/src/client.test.ts +12 -3
- package/src/transport.test.ts +118 -0
- package/src/transport.ts +91 -24
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
21
|
-
"
|
|
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
|
-
"
|
|
30
|
-
"
|
|
31
|
-
|
|
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
|
-
"
|
|
54
|
-
"
|
|
55
|
-
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@playfast/reform-remote": "*",
|
|
53
|
+
"effect": "*"
|
|
54
|
+
},
|
|
55
|
+
"playbook": "./playbook"
|
|
56
56
|
}
|
package/src/client.test.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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 =>
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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<
|
|
121
|
+
timer: Option.Option<Fiber.RuntimeFiber<void, never>>
|
|
73
122
|
queue: ReadonlyArray<InvokeMessage>
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
163
|
+
state.timer = Option.some(
|
|
164
|
+
Effect.runFork(Effect.sleep(delay).pipe(Effect.andThen(Effect.sync(connect)))),
|
|
165
|
+
)
|
|
108
166
|
}
|
|
109
167
|
|
|
110
|
-
//
|
|
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 =
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
222
|
+
Effect.runSync(Fiber.interrupt(state.timer.value))
|
|
156
223
|
}
|
|
157
224
|
if (Option.isSome(state.socket)) {
|
|
158
225
|
state.socket.value.close()
|