@playfast/reform-remote-web 0.0.5 → 1.0.1
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.ts +64 -27
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-remote-web",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "1.0.1",
|
|
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": [
|
package/src/transport.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Redacted } from 'effect'
|
|
1
|
+
import { Option, Redacted } from 'effect'
|
|
2
2
|
import type { InvokeMessage, RemoteTransport, ServerMessage } from '@playfast/reform-remote'
|
|
3
3
|
import { MissingWebSocket } from './errors.js'
|
|
4
4
|
|
|
@@ -17,12 +17,20 @@ import { MissingWebSocket } from './errors.js'
|
|
|
17
17
|
|
|
18
18
|
export type ClientStatus = 'connecting' | 'open' | 'reconnecting' | 'closed'
|
|
19
19
|
|
|
20
|
+
/** First reconnect delay; doubles each attempt up to {@link DEFAULT_MAX_DELAY_MS}. */
|
|
21
|
+
const DEFAULT_BASE_DELAY_MS = 250
|
|
22
|
+
/** Ceiling for the exponential backoff delay. */
|
|
23
|
+
const DEFAULT_MAX_DELAY_MS = 5000
|
|
24
|
+
|
|
20
25
|
/**
|
|
21
26
|
* Options for {@link createWebSocketClientTransport}. A single named-options object
|
|
22
27
|
* (no positional args) so every reform-remote adapter constructor has the same
|
|
23
28
|
* shape and the client transports stay drop-in swappable.
|
|
29
|
+
*
|
|
30
|
+
* The `ExternalApi` postfix marks this as the package's public consumer-facing
|
|
31
|
+
* surface, where optional config fields are idiomatic.
|
|
24
32
|
*/
|
|
25
|
-
export interface
|
|
33
|
+
export interface WebSocketClientTransportOptionsExternalApi {
|
|
26
34
|
/** The WebSocket URL to connect to (e.g. `ws://127.0.0.1:8787/reform`). */
|
|
27
35
|
readonly url: string
|
|
28
36
|
/** First reconnect delay; doubles each attempt up to `maxDelayMs`. Default 250ms. */
|
|
@@ -44,6 +52,9 @@ export interface WebSocketClientTransportOptions {
|
|
|
44
52
|
readonly onStatus?: (status: ClientStatus) => void
|
|
45
53
|
}
|
|
46
54
|
|
|
55
|
+
/** Back-compat alias for the public options type. */
|
|
56
|
+
export type WebSocketClientTransportOptions = WebSocketClientTransportOptionsExternalApi
|
|
57
|
+
|
|
47
58
|
/** A `RemoteTransport` that also exposes its connection status and a manual close. */
|
|
48
59
|
export type WebSocketClientTransport = RemoteTransport<InvokeMessage, ServerMessage> & {
|
|
49
60
|
readonly status: () => ClientStatus
|
|
@@ -57,9 +68,16 @@ export type WebSocketClientTransport = RemoteTransport<InvokeMessage, ServerMess
|
|
|
57
68
|
readonly close: () => void
|
|
58
69
|
}
|
|
59
70
|
|
|
71
|
+
/** DOM-boundary view of the global scope: the optional global `WebSocket` constructor. */
|
|
72
|
+
interface GlobalWebSocketHolderExternalApi {
|
|
73
|
+
readonly WebSocket?: typeof WebSocket
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
const resolveWebSocket = (injected: typeof WebSocket | undefined): typeof WebSocket => {
|
|
61
|
-
const
|
|
77
|
+
const globalScope: GlobalWebSocketHolderExternalApi = globalThis
|
|
78
|
+
const impl = injected ?? globalScope.WebSocket
|
|
62
79
|
if (impl === undefined) {
|
|
80
|
+
// oxlint-disable-next-line reform-rules/no-throw -- sync public transport factory invariant; no Effect context at construction
|
|
63
81
|
throw new MissingWebSocket({
|
|
64
82
|
message: 'No global WebSocket; pass `WebSocket` in options (e.g. the `ws` package in Node <22).',
|
|
65
83
|
})
|
|
@@ -71,9 +89,9 @@ export const createWebSocketClientTransport = (
|
|
|
71
89
|
options: WebSocketClientTransportOptions,
|
|
72
90
|
): WebSocketClientTransport => {
|
|
73
91
|
const url = options.url
|
|
74
|
-
const
|
|
75
|
-
const baseDelayMs = options.baseDelayMs ??
|
|
76
|
-
const maxDelayMs = options.maxDelayMs ??
|
|
92
|
+
const WebSocketImpl = resolveWebSocket(options.WebSocket)
|
|
93
|
+
const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS
|
|
94
|
+
const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS
|
|
77
95
|
|
|
78
96
|
// Normalize `protocols` to an array and fold in the auth token as a `bearer.<token>`
|
|
79
97
|
// subprotocol. Computed once (the token doesn't change across reconnects). `undefined`
|
|
@@ -93,58 +111,73 @@ export const createWebSocketClientTransport = (
|
|
|
93
111
|
|
|
94
112
|
const handlers = new Set<(message: ServerMessage) => void>()
|
|
95
113
|
const statusListeners = new Set<() => void>()
|
|
96
|
-
const queue: Array<InvokeMessage> = []
|
|
97
114
|
// A const holder whose fields swap — the codebase's no-`let` idiom.
|
|
98
115
|
const state: {
|
|
99
|
-
socket: WebSocket
|
|
116
|
+
socket: Option.Option<WebSocket>
|
|
100
117
|
status: ClientStatus
|
|
101
118
|
attempts: number
|
|
102
|
-
timer: ReturnType<typeof setTimeout
|
|
103
|
-
|
|
119
|
+
timer: Option.Option<ReturnType<typeof setTimeout>>
|
|
120
|
+
queue: ReadonlyArray<InvokeMessage>
|
|
121
|
+
} = { socket: Option.none(), status: 'connecting', attempts: 0, timer: Option.none(), queue: [] }
|
|
104
122
|
|
|
105
123
|
const setStatus = (status: ClientStatus): void => {
|
|
106
|
-
if (status === state.status)
|
|
124
|
+
if (status === state.status) {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
107
127
|
state.status = status
|
|
108
128
|
options.onStatus?.(status)
|
|
109
|
-
|
|
129
|
+
statusListeners.forEach((listener) => listener())
|
|
110
130
|
}
|
|
111
131
|
|
|
112
132
|
const flush = (): void => {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
133
|
+
if (!Option.isSome(state.socket)) {
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
const socket = state.socket.value
|
|
137
|
+
if (socket.readyState !== WebSocketImpl.OPEN) {
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
const pending = state.queue
|
|
141
|
+
state.queue = []
|
|
142
|
+
// oxlint-disable-next-line reform-rules/no-json-parse-stringify -- wire frame encode; InvokeMessage is a structural type with no Schema here
|
|
143
|
+
pending.forEach((message) => socket.send(JSON.stringify(message)))
|
|
117
144
|
}
|
|
118
145
|
|
|
119
146
|
const scheduleReconnect = (): void => {
|
|
120
|
-
if (state.status === 'closed')
|
|
147
|
+
if (state.status === 'closed') {
|
|
148
|
+
return
|
|
149
|
+
}
|
|
121
150
|
setStatus('reconnecting')
|
|
122
151
|
const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** state.attempts)
|
|
123
152
|
state.attempts += 1
|
|
124
|
-
|
|
153
|
+
// oxlint-disable-next-line reform-rules/no-set-timeout-interval -- DOM transport reconnect backoff; sync factory has no Effect runtime
|
|
154
|
+
state.timer = Option.some(setTimeout(connect, delay))
|
|
125
155
|
}
|
|
126
156
|
|
|
127
157
|
// A drop while live (or a failed connect) backs off and retries. A socket can
|
|
128
|
-
// fire both `error` and `close`, so guard on identity —
|
|
158
|
+
// fire both `error` and `close`, so guard on identity — clear the current
|
|
129
159
|
// socket on the first drop, so the second event is a no-op and only one
|
|
130
160
|
// reconnect is scheduled. A user `close` flips the status, making this inert.
|
|
131
161
|
const handleDrop = (socket: WebSocket): void => {
|
|
132
|
-
if (state.socket !== socket)
|
|
133
|
-
|
|
162
|
+
if (Option.isNone(state.socket) || state.socket.value !== socket) {
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
state.socket = Option.none()
|
|
134
166
|
scheduleReconnect()
|
|
135
167
|
}
|
|
136
168
|
|
|
137
169
|
function connect(): void {
|
|
138
|
-
const socket = protocols === undefined ? new
|
|
139
|
-
state.socket = socket
|
|
170
|
+
const socket = protocols === undefined ? new WebSocketImpl(url) : new WebSocketImpl(url, [...protocols])
|
|
171
|
+
state.socket = Option.some(socket)
|
|
140
172
|
socket.addEventListener('open', () => {
|
|
141
173
|
state.attempts = 0
|
|
142
174
|
setStatus('open')
|
|
143
175
|
flush()
|
|
144
176
|
})
|
|
145
177
|
socket.addEventListener('message', (event: MessageEvent) => {
|
|
178
|
+
// 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
|
|
146
179
|
const message = JSON.parse(event.data as string) as ServerMessage
|
|
147
|
-
|
|
180
|
+
handlers.forEach((handler) => handler(message))
|
|
148
181
|
})
|
|
149
182
|
socket.addEventListener('close', () => handleDrop(socket))
|
|
150
183
|
socket.addEventListener('error', () => handleDrop(socket))
|
|
@@ -154,7 +187,7 @@ export const createWebSocketClientTransport = (
|
|
|
154
187
|
|
|
155
188
|
return {
|
|
156
189
|
send: (message) => {
|
|
157
|
-
queue.
|
|
190
|
+
state.queue = [...state.queue, message]
|
|
158
191
|
flush()
|
|
159
192
|
},
|
|
160
193
|
onMessage: (handler) => {
|
|
@@ -168,8 +201,12 @@ export const createWebSocketClientTransport = (
|
|
|
168
201
|
},
|
|
169
202
|
close: () => {
|
|
170
203
|
setStatus('closed')
|
|
171
|
-
if (
|
|
172
|
-
|
|
204
|
+
if (Option.isSome(state.timer)) {
|
|
205
|
+
clearTimeout(state.timer.value)
|
|
206
|
+
}
|
|
207
|
+
if (Option.isSome(state.socket)) {
|
|
208
|
+
state.socket.value.close()
|
|
209
|
+
}
|
|
173
210
|
},
|
|
174
211
|
}
|
|
175
212
|
}
|