@playfast/reform-remote-web 1.0.1 → 1.0.2
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.ts +2 -52
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.2",
|
|
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
|
|
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,7 +65,6 @@ 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
|
|
@@ -154,10 +107,7 @@ export const createWebSocketClientTransport = (
|
|
|
154
107
|
state.timer = Option.some(setTimeout(connect, delay))
|
|
155
108
|
}
|
|
156
109
|
|
|
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.
|
|
110
|
+
// Socket may fire both error and close; clear on first drop so only one reconnect is scheduled.
|
|
161
111
|
const handleDrop = (socket: WebSocket): void => {
|
|
162
112
|
if (Option.isNone(state.socket) || state.socket.value !== socket) {
|
|
163
113
|
return
|