@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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": "0.0.5",
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 WebSocketClientTransportOptions {
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 impl = injected ?? (globalThis as { WebSocket?: typeof WebSocket }).WebSocket
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 WS = resolveWebSocket(options.WebSocket)
75
- const baseDelayMs = options.baseDelayMs ?? 250
76
- const maxDelayMs = options.maxDelayMs ?? 5000
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 | null
116
+ socket: Option.Option<WebSocket>
100
117
  status: ClientStatus
101
118
  attempts: number
102
- timer: ReturnType<typeof setTimeout> | null
103
- } = { socket: null, status: 'connecting', attempts: 0, timer: null }
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) return
124
+ if (status === state.status) {
125
+ return
126
+ }
107
127
  state.status = status
108
128
  options.onStatus?.(status)
109
- for (const listener of statusListeners) listener()
129
+ statusListeners.forEach((listener) => listener())
110
130
  }
111
131
 
112
132
  const flush = (): void => {
113
- const socket = state.socket
114
- if (socket === null || socket.readyState !== WS.OPEN) return
115
- const pending = queue.splice(0, queue.length)
116
- for (const message of pending) socket.send(JSON.stringify(message))
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') return
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
- state.timer = setTimeout(connect, delay)
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 — null out the current
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) return
133
- state.socket = null
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 WS(url) : new WS(url, [...protocols])
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
- for (const handler of handlers) handler(message)
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.push(message)
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 (state.timer !== null) clearTimeout(state.timer)
172
- state.socket?.close()
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
  }