@playfast/reform-remote-web 1.0.2 → 1.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 +1 -1
- package/src/transport.test.ts +118 -0
- package/src/transport.ts +25 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-remote-web",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Browser/web WebSocket client transport for reform-remote — streams a server-run reform scene to a thin renderer.",
|
|
7
7
|
"keywords": [
|
|
@@ -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
|
@@ -71,7 +71,18 @@ export const createWebSocketClientTransport = (
|
|
|
71
71
|
attempts: number
|
|
72
72
|
timer: Option.Option<ReturnType<typeof setTimeout>>
|
|
73
73
|
queue: ReadonlyArray<InvokeMessage>
|
|
74
|
-
|
|
74
|
+
// Inbound messages that arrived before any handler subscribed. The server pushes the initial
|
|
75
|
+
// Snapshot the instant the socket opens, but a consumer (e.g. RemoteUI) only subscribes after
|
|
76
|
+
// it mounts — so without this buffer the first scene is dropped and the UI blanks forever.
|
|
77
|
+
inbox: ReadonlyArray<ServerMessage>
|
|
78
|
+
} = {
|
|
79
|
+
socket: Option.none(),
|
|
80
|
+
status: 'connecting',
|
|
81
|
+
attempts: 0,
|
|
82
|
+
timer: Option.none(),
|
|
83
|
+
queue: [],
|
|
84
|
+
inbox: [],
|
|
85
|
+
}
|
|
75
86
|
|
|
76
87
|
const setStatus = (status: ClientStatus): void => {
|
|
77
88
|
if (status === state.status) {
|
|
@@ -127,6 +138,12 @@ export const createWebSocketClientTransport = (
|
|
|
127
138
|
socket.addEventListener('message', (event: MessageEvent) => {
|
|
128
139
|
// 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
140
|
const message = JSON.parse(event.data as string) as ServerMessage
|
|
141
|
+
if (handlers.size === 0) {
|
|
142
|
+
// No subscriber yet: buffer so the first one to subscribe still receives this message
|
|
143
|
+
// rather than losing it (the server's initial Snapshot can beat the consumer's mount).
|
|
144
|
+
state.inbox = [...state.inbox, message]
|
|
145
|
+
return
|
|
146
|
+
}
|
|
130
147
|
handlers.forEach((handler) => handler(message))
|
|
131
148
|
})
|
|
132
149
|
socket.addEventListener('close', () => handleDrop(socket))
|
|
@@ -142,6 +159,13 @@ export const createWebSocketClientTransport = (
|
|
|
142
159
|
},
|
|
143
160
|
onMessage: (handler) => {
|
|
144
161
|
handlers.add(handler)
|
|
162
|
+
// Replay anything that arrived before this first subscription, then clear the buffer so a
|
|
163
|
+
// later second subscriber does not receive the same messages again.
|
|
164
|
+
if (state.inbox.length > 0) {
|
|
165
|
+
const buffered = state.inbox
|
|
166
|
+
state.inbox = []
|
|
167
|
+
buffered.forEach((message) => handler(message))
|
|
168
|
+
}
|
|
145
169
|
return () => void handlers.delete(handler)
|
|
146
170
|
},
|
|
147
171
|
status: () => state.status,
|