@playfast/reform-remote-web 1.0.1 → 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/client.test.ts +2 -10
- package/src/errors.ts +1 -12
- package/src/index.ts +0 -9
- package/src/transport.test.ts +118 -0
- package/src/transport.ts +27 -53
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": [
|
package/src/client.test.ts
CHANGED
|
@@ -20,11 +20,9 @@ import { connect, remoteViews } from '@playfast/reform-remote'
|
|
|
20
20
|
import { type NodeWebSocketServer, serveNodeWebSocket } from '@playfast/reform-remote-node'
|
|
21
21
|
import { createWebSocketClientTransport } from './index'
|
|
22
22
|
|
|
23
|
-
// Real loopback
|
|
24
|
-
// full parallel suite can't starve it past the 5s default (30s still fails a real hang).
|
|
23
|
+
// Real loopback I/O: headroom so parallel suite can't starve past 5s default.
|
|
25
24
|
vi.setConfig({ testTimeout: 30_000, hookTimeout: 30_000 })
|
|
26
25
|
|
|
27
|
-
// Self-contained counter scene for the server end of the round-trip.
|
|
28
26
|
class Count extends State.make('count', S.Number) {}
|
|
29
27
|
class Counters extends StateGroup.make(Count) {}
|
|
30
28
|
class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
|
|
@@ -53,7 +51,6 @@ const counterScene = () => {
|
|
|
53
51
|
|
|
54
52
|
interface Probe {
|
|
55
53
|
props?: Record<string, unknown>
|
|
56
|
-
// The counter contract's only event, `bump`, takes `{ by: number }`.
|
|
57
54
|
events?: Record<string, (payload: { by: number }) => void>
|
|
58
55
|
}
|
|
59
56
|
const probeView = (probe: Probe) =>
|
|
@@ -63,7 +60,6 @@ const probeView = (probe: Probe) =>
|
|
|
63
60
|
return null
|
|
64
61
|
})
|
|
65
62
|
|
|
66
|
-
// Views render as React components now, so a test runs them by rendering node().
|
|
67
63
|
const draw = (node: ReactNode): void => void renderToStaticMarkup(createElement(Fragment, null, node))
|
|
68
64
|
|
|
69
65
|
const listening = (host: NodeWebSocketServer): Promise<number> =>
|
|
@@ -122,7 +118,6 @@ test('a dropped connection reconnects with backoff and re-syncs via the fresh sn
|
|
|
122
118
|
views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probe) }),
|
|
123
119
|
})
|
|
124
120
|
try {
|
|
125
|
-
// Live, then bump so the client holds a non-zero count.
|
|
126
121
|
await vi.waitFor(() => {
|
|
127
122
|
draw(client.node())
|
|
128
123
|
expect(probe.props).toEqual({ count: 0 })
|
|
@@ -133,14 +128,11 @@ test('a dropped connection reconnects with backoff and re-syncs via the fresh sn
|
|
|
133
128
|
expect(probe.props).toEqual({ count: 5 })
|
|
134
129
|
})
|
|
135
130
|
|
|
136
|
-
// Kill the server out from under the client.
|
|
137
131
|
await host.stop()
|
|
138
132
|
open.delete(host)
|
|
139
133
|
await vi.waitFor(() => expect(transport.status()).toBe('reconnecting'))
|
|
140
134
|
|
|
141
|
-
//
|
|
142
|
-
// new session's snapshot resets the tree — count is 0 again, proving re-sync
|
|
143
|
-
// (not the stale 5).
|
|
135
|
+
// Fresh runtime on same port: reconnect snapshot resets tree (not stale 5).
|
|
144
136
|
const restarted = serveNodeWebSocket({ scene: counterScene(), port })
|
|
145
137
|
open.add(restarted)
|
|
146
138
|
await listening(restarted)
|
package/src/errors.ts
CHANGED
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
import { type Cause, Data } from 'effect'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
* Tagged errors for the web client transport. Every failure carries a `_tag` and
|
|
5
|
-
* structured fields — never a bare `Error` — so callers can match with Effect
|
|
6
|
-
* `catchTag` / `Match`. Tags are namespaced by package to stay globally unique.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The constructor shape `Data.TaggedError(tag)<A>` produces, named so the
|
|
11
|
-
* generated `.d.ts` can describe the `extends` base under `isolatedDeclarations`
|
|
12
|
-
* (which forbids an inferred expression in an extends clause).
|
|
13
|
-
*/
|
|
3
|
+
// Named so isolatedDeclarations can describe the extends base (inferred expression forbidden).
|
|
14
4
|
type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (
|
|
15
5
|
args: A,
|
|
16
6
|
) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
|
|
@@ -22,5 +12,4 @@ const MissingWebSocketBase: TaggedErrorClass<
|
|
|
22
12
|
readonly message: string
|
|
23
13
|
}>
|
|
24
14
|
|
|
25
|
-
/** Thrown when no `WebSocket` is available and none was injected via options. */
|
|
26
15
|
export class MissingWebSocket extends MissingWebSocketBase {}
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `@playfast/reform-remote-web` — the browser/web client side of the remote
|
|
3
|
-
* transport. Public API is exposed two ways (Effect-style): namespace barrels for
|
|
4
|
-
* discovery (`import { Transport } from "@playfast/reform-remote-web"`) and
|
|
5
|
-
* per-path subpaths for direct use (`import { createWebSocketClientTransport }
|
|
6
|
-
* from "@playfast/reform-remote-web/transport"`). The select re-exports below keep
|
|
7
|
-
* the common entry points nameable from the root.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
1
|
export * as Transport from './transport.js'
|
|
11
2
|
export * as Errors from './errors.js'
|
|
12
3
|
|
|
@@ -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
|
@@ -2,73 +2,30 @@ 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
|
|
|
5
|
-
/**
|
|
6
|
-
* The browser/web client side of the remote transport. A factory that wraps a
|
|
7
|
-
* WebSocket into a `RemoteTransport` you hand to `connect({ transport, views })`:
|
|
8
|
-
* it sends `Invoke`s up and surfaces `Snapshot`/`Patches` down, with JSON framing.
|
|
9
|
-
*
|
|
10
|
-
* It owns reconnection so the caller doesn't have to: a dropped socket is retried
|
|
11
|
-
* with exponential backoff, and because the message handlers persist across
|
|
12
|
-
* sockets, the server's first frame on the new connection — always a `Snapshot` —
|
|
13
|
-
* re-syncs the tree automatically (no stale nodes leak). Works anywhere a global
|
|
14
|
-
* `WebSocket` exists (browsers, Bun, Node 22+); inject one via `options` otherwise.
|
|
15
|
-
* There is no React hook — this is the transport only.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
5
|
export type ClientStatus = 'connecting' | 'open' | 'reconnecting' | 'closed'
|
|
19
6
|
|
|
20
|
-
/** First reconnect delay; doubles each attempt up to {@link DEFAULT_MAX_DELAY_MS}. */
|
|
21
7
|
const DEFAULT_BASE_DELAY_MS = 250
|
|
22
|
-
/** Ceiling for the exponential backoff delay. */
|
|
23
8
|
const DEFAULT_MAX_DELAY_MS = 5000
|
|
24
9
|
|
|
25
|
-
/**
|
|
26
|
-
* Options for {@link createWebSocketClientTransport}. A single named-options object
|
|
27
|
-
* (no positional args) so every reform-remote adapter constructor has the same
|
|
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.
|
|
32
|
-
*/
|
|
33
10
|
export interface WebSocketClientTransportOptionsExternalApi {
|
|
34
|
-
/** The WebSocket URL to connect to (e.g. `ws://127.0.0.1:8787/reform`). */
|
|
35
11
|
readonly url: string
|
|
36
|
-
/** First reconnect delay; doubles each attempt up to `maxDelayMs`. Default 250ms. */
|
|
37
12
|
readonly baseDelayMs?: number
|
|
38
|
-
/** Ceiling for the backoff delay. Default 5000ms. */
|
|
39
13
|
readonly maxDelayMs?: number
|
|
40
|
-
/** Subprotocols passed to the `WebSocket` constructor. */
|
|
41
14
|
readonly protocols?: string | ReadonlyArray<string>
|
|
42
|
-
|
|
43
|
-
* A bearer token to authenticate the connection. A browser `WebSocket` can't set headers,
|
|
44
|
-
* so the only header-like channel is the subprotocol list: the token rides as an extra
|
|
45
|
-
* `bearer.<token>` subprotocol alongside any {@link protocols}. The server reads it off
|
|
46
|
-
* `Sec-WebSocket-Protocol` at the upgrade. Held `Redacted` so it never logs.
|
|
47
|
-
*/
|
|
15
|
+
// Browser WebSocket can't set headers; auth rides as `bearer.<token>` subprotocol. Redacted so it never logs.
|
|
48
16
|
readonly authToken?: Redacted.Redacted<string>
|
|
49
|
-
/** The `WebSocket` implementation to use. Defaults to the global. */
|
|
50
17
|
readonly WebSocket?: typeof WebSocket
|
|
51
|
-
/** Notified on every connection-status transition. */
|
|
52
18
|
readonly onStatus?: (status: ClientStatus) => void
|
|
53
19
|
}
|
|
54
20
|
|
|
55
|
-
/** Back-compat alias for the public options type. */
|
|
56
21
|
export type WebSocketClientTransportOptions = WebSocketClientTransportOptionsExternalApi
|
|
57
22
|
|
|
58
|
-
/** A `RemoteTransport` that also exposes its connection status and a manual close. */
|
|
59
23
|
export type WebSocketClientTransport = RemoteTransport<InvokeMessage, ServerMessage> & {
|
|
60
24
|
readonly status: () => ClientStatus
|
|
61
|
-
/**
|
|
62
|
-
* Subscribe to status transitions (returns an unsubscribe). With `status()`
|
|
63
|
-
* this satisfies the React binding's `StatusReporter`, so `useConnectionStatus`
|
|
64
|
-
* can drive a reconnecting badge with no manual store.
|
|
65
|
-
*/
|
|
66
25
|
readonly onStatusChange: (listener: () => void) => () => void
|
|
67
|
-
/** Stop reconnecting and close the socket. The transport is inert afterwards. */
|
|
68
26
|
readonly close: () => void
|
|
69
27
|
}
|
|
70
28
|
|
|
71
|
-
/** DOM-boundary view of the global scope: the optional global `WebSocket` constructor. */
|
|
72
29
|
interface GlobalWebSocketHolderExternalApi {
|
|
73
30
|
readonly WebSocket?: typeof WebSocket
|
|
74
31
|
}
|
|
@@ -93,9 +50,6 @@ export const createWebSocketClientTransport = (
|
|
|
93
50
|
const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS
|
|
94
51
|
const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS
|
|
95
52
|
|
|
96
|
-
// Normalize `protocols` to an array and fold in the auth token as a `bearer.<token>`
|
|
97
|
-
// subprotocol. Computed once (the token doesn't change across reconnects). `undefined`
|
|
98
|
-
// when there's nothing to send, so the no-auth path matches the original behavior.
|
|
99
53
|
const baseProtocols: ReadonlyArray<string> =
|
|
100
54
|
options.protocols === undefined
|
|
101
55
|
? []
|
|
@@ -111,14 +65,24 @@ export const createWebSocketClientTransport = (
|
|
|
111
65
|
|
|
112
66
|
const handlers = new Set<(message: ServerMessage) => void>()
|
|
113
67
|
const statusListeners = new Set<() => void>()
|
|
114
|
-
// A const holder whose fields swap — the codebase's no-`let` idiom.
|
|
115
68
|
const state: {
|
|
116
69
|
socket: Option.Option<WebSocket>
|
|
117
70
|
status: ClientStatus
|
|
118
71
|
attempts: number
|
|
119
72
|
timer: Option.Option<ReturnType<typeof setTimeout>>
|
|
120
73
|
queue: ReadonlyArray<InvokeMessage>
|
|
121
|
-
|
|
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
|
+
}
|
|
122
86
|
|
|
123
87
|
const setStatus = (status: ClientStatus): void => {
|
|
124
88
|
if (status === state.status) {
|
|
@@ -154,10 +118,7 @@ export const createWebSocketClientTransport = (
|
|
|
154
118
|
state.timer = Option.some(setTimeout(connect, delay))
|
|
155
119
|
}
|
|
156
120
|
|
|
157
|
-
//
|
|
158
|
-
// fire both `error` and `close`, so guard on identity — clear the current
|
|
159
|
-
// socket on the first drop, so the second event is a no-op and only one
|
|
160
|
-
// reconnect is scheduled. A user `close` flips the status, making this inert.
|
|
121
|
+
// Socket may fire both error and close; clear on first drop so only one reconnect is scheduled.
|
|
161
122
|
const handleDrop = (socket: WebSocket): void => {
|
|
162
123
|
if (Option.isNone(state.socket) || state.socket.value !== socket) {
|
|
163
124
|
return
|
|
@@ -177,6 +138,12 @@ export const createWebSocketClientTransport = (
|
|
|
177
138
|
socket.addEventListener('message', (event: MessageEvent) => {
|
|
178
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
|
|
179
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
|
+
}
|
|
180
147
|
handlers.forEach((handler) => handler(message))
|
|
181
148
|
})
|
|
182
149
|
socket.addEventListener('close', () => handleDrop(socket))
|
|
@@ -192,6 +159,13 @@ export const createWebSocketClientTransport = (
|
|
|
192
159
|
},
|
|
193
160
|
onMessage: (handler) => {
|
|
194
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
|
+
}
|
|
195
169
|
return () => void handlers.delete(handler)
|
|
196
170
|
},
|
|
197
171
|
status: () => state.status,
|