@playfast/reform-remote-web 0.0.2 → 0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-remote-web",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.2",
4
+ "version": "0.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": [
@@ -27,7 +27,7 @@
27
27
  "./*": "./src/*.ts"
28
28
  },
29
29
  "files": [
30
- "dist",
30
+ "src",
31
31
  "README.md"
32
32
  ],
33
33
  "scripts": {
@@ -0,0 +1,155 @@
1
+ import type { AddressInfo } from 'node:net'
2
+ import { createElement, Fragment, type ReactNode } from 'react'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
4
+ import { afterEach, expect, test, vi } from 'vitest'
5
+ import { Layer, Schema as S } from 'effect'
6
+ import {
7
+ Composition,
8
+ Engine,
9
+ Event,
10
+ Reducer,
11
+ State,
12
+ StateGroup,
13
+ Ui,
14
+ provide,
15
+ scene,
16
+ ui,
17
+ } from '@playfast/reform'
18
+ import { connect, remoteViews } from '@playfast/reform-remote'
19
+ import { type NodeWebSocketServer, serveNodeWebSocket } from '@playfast/reform-remote-node'
20
+ import { createWebSocketClientTransport } from './index'
21
+
22
+ // Self-contained counter scene for the server end of the round-trip.
23
+ class Count extends State.make('count', S.Number) {}
24
+ class Counters extends StateGroup.make(Count) {}
25
+ class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
26
+ class Bump extends Reducer.make('Bump', { states: [Count], events: [Bumped] }) {}
27
+ class CounterUi extends ui('Counter', {
28
+ props: S.Struct({ count: S.Number }),
29
+ events: { bump: S.Struct({ by: S.Number }) },
30
+ }) {}
31
+ class Counter extends Composition.make('Counter', { title: 'Counter', ui: CounterUi, states: [Count] }) {}
32
+
33
+ const counterScene = () => {
34
+ const presentation = Layer.mergeAll(
35
+ provide(CounterUi, Ui.make(CounterUi, ({ count }) => `count:${count}`)),
36
+ StateGroup.live(Counters, { count: 0 }),
37
+ )
38
+ const app = Layer.mergeAll(
39
+ Composition.live(Counter, function* () {
40
+ const count = yield* StateGroup.select(Counters, 'count')
41
+ const bump = yield* Event.trigger(Bumped)
42
+ return (yield* CounterUi)({ count }, { bump })
43
+ }),
44
+ Reducer.live(Bump, (n, event) => n + event.by),
45
+ ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
46
+ return scene(Counter, { provide: [app] })
47
+ }
48
+
49
+ interface Probe {
50
+ props?: Record<string, unknown>
51
+ // The counter contract's only event, `bump`, takes `{ by: number }`.
52
+ events?: Record<string, (payload: { by: number }) => void>
53
+ }
54
+ const probeView = (probe: Probe) =>
55
+ Ui.make(CounterUi, (props, _slots, events) => {
56
+ probe.props = props
57
+ probe.events = events
58
+ return null
59
+ })
60
+
61
+ // Views render as React components now, so a test runs them by rendering node().
62
+ const draw = (node: ReactNode): void => void renderToStaticMarkup(createElement(Fragment, null, node))
63
+
64
+ const listening = (host: NodeWebSocketServer): Promise<number> =>
65
+ new Promise((resolve) =>
66
+ host.wss.once('listening', () => resolve((host.wss.address() as AddressInfo).port)),
67
+ )
68
+
69
+ const open = new Set<NodeWebSocketServer>()
70
+ afterEach(async () => {
71
+ await Promise.all([...open].map((host) => host.stop()))
72
+ open.clear()
73
+ })
74
+
75
+ test('the web client renders server state over a real socket and drives it back', async () => {
76
+ const host = serveNodeWebSocket({ scene: counterScene() })
77
+ open.add(host)
78
+ const port = await listening(host)
79
+
80
+ const transport = createWebSocketClientTransport({ url: `ws://localhost:${port}` })
81
+ const probe: Probe = {}
82
+ const client = connect({
83
+ transport,
84
+ views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probe) }),
85
+ })
86
+ try {
87
+ await vi.waitFor(() => {
88
+ draw(client.node())
89
+ expect(probe.props).toEqual({ count: 0 })
90
+ })
91
+
92
+ probe.events?.['bump']?.({ by: 6 })
93
+ await vi.waitFor(() => {
94
+ draw(client.node())
95
+ expect(probe.props).toEqual({ count: 6 })
96
+ })
97
+ expect(transport.status()).toBe('open')
98
+ } finally {
99
+ transport.close()
100
+ client.dispose()
101
+ }
102
+ })
103
+
104
+ test('a dropped connection reconnects with backoff and re-syncs via the fresh snapshot', async () => {
105
+ const host = serveNodeWebSocket({ scene: counterScene() })
106
+ open.add(host)
107
+ const port = await listening(host)
108
+
109
+ const transport = createWebSocketClientTransport({
110
+ url: `ws://localhost:${port}`,
111
+ baseDelayMs: 20,
112
+ maxDelayMs: 80,
113
+ })
114
+ const probe: Probe = {}
115
+ const client = connect({
116
+ transport,
117
+ views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probe) }),
118
+ })
119
+ try {
120
+ // Live, then bump so the client holds a non-zero count.
121
+ await vi.waitFor(() => {
122
+ draw(client.node())
123
+ expect(probe.props).toEqual({ count: 0 })
124
+ })
125
+ probe.events?.['bump']?.({ by: 5 })
126
+ await vi.waitFor(() => {
127
+ draw(client.node())
128
+ expect(probe.props).toEqual({ count: 5 })
129
+ })
130
+
131
+ // Kill the server out from under the client.
132
+ await host.stop()
133
+ open.delete(host)
134
+ await vi.waitFor(() => expect(transport.status()).toBe('reconnecting'))
135
+
136
+ // Bring a fresh runtime back on the same port. The client reconnects and the
137
+ // new session's snapshot resets the tree — count is 0 again, proving re-sync
138
+ // (not the stale 5).
139
+ const restarted = serveNodeWebSocket({ scene: counterScene(), port })
140
+ open.add(restarted)
141
+ await listening(restarted)
142
+
143
+ await vi.waitFor(
144
+ () => {
145
+ draw(client.node())
146
+ expect(probe.props).toEqual({ count: 0 })
147
+ expect(transport.status()).toBe('open')
148
+ },
149
+ { timeout: 4000 },
150
+ )
151
+ } finally {
152
+ transport.close()
153
+ client.dispose()
154
+ }
155
+ })
@@ -1,22 +1,26 @@
1
- import { type Cause } from 'effect';
1
+ import { type Cause, Data } from 'effect'
2
+
2
3
  /**
3
4
  * Tagged errors for the web client transport. Every failure carries a `_tag` and
4
5
  * structured fields — never a bare `Error` — so callers can match with Effect
5
6
  * `catchTag` / `Match`. Tags are namespaced by package to stay globally unique.
6
7
  */
8
+
7
9
  /**
8
10
  * The constructor shape `Data.TaggedError(tag)<A>` produces, named so the
9
11
  * generated `.d.ts` can describe the `extends` base under `isolatedDeclarations`
10
12
  * (which forbids an inferred expression in an extends clause).
11
13
  */
12
- type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (args: A) => Cause.YieldableError & {
13
- readonly _tag: Tag;
14
- } & Readonly<A>;
15
- declare const MissingWebSocketBase: TaggedErrorClass<'@playfast/reform-remote-web/MissingWebSocket', {
16
- readonly message: string;
17
- }>;
14
+ type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (
15
+ args: A,
16
+ ) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
17
+
18
+ const MissingWebSocketBase: TaggedErrorClass<
19
+ '@playfast/reform-remote-web/MissingWebSocket',
20
+ { readonly message: string }
21
+ > = Data.TaggedError('@playfast/reform-remote-web/MissingWebSocket')<{
22
+ readonly message: string
23
+ }>
24
+
18
25
  /** Thrown when no `WebSocket` is available and none was injected via options. */
19
- export declare class MissingWebSocket extends MissingWebSocketBase {
20
- }
21
- export {};
22
- //# sourceMappingURL=errors.d.ts.map
26
+ export class MissingWebSocket extends MissingWebSocketBase {}
@@ -6,8 +6,14 @@
6
6
  * from "@playfast/reform-remote-web/transport"`). The select re-exports below keep
7
7
  * the common entry points nameable from the root.
8
8
  */
9
- export * as Transport from './transport.js';
10
- export * as Errors from './errors.js';
11
- export { createWebSocketClientTransport, } from './transport.js';
12
- export { MissingWebSocket } from './errors.js';
13
- //# sourceMappingURL=index.js.map
9
+
10
+ export * as Transport from './transport.js'
11
+ export * as Errors from './errors.js'
12
+
13
+ export {
14
+ type ClientStatus,
15
+ createWebSocketClientTransport,
16
+ type WebSocketClientTransport,
17
+ type WebSocketClientTransportOptions,
18
+ } from './transport.js'
19
+ export { MissingWebSocket } from './errors.js'
@@ -0,0 +1,175 @@
1
+ import { Redacted } from 'effect'
2
+ import type { InvokeMessage, RemoteTransport, ServerMessage } from '@playfast/reform-remote'
3
+ import { MissingWebSocket } from './errors.js'
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
+ export type ClientStatus = 'connecting' | 'open' | 'reconnecting' | 'closed'
19
+
20
+ /**
21
+ * Options for {@link createWebSocketClientTransport}. A single named-options object
22
+ * (no positional args) so every reform-remote adapter constructor has the same
23
+ * shape and the client transports stay drop-in swappable.
24
+ */
25
+ export interface WebSocketClientTransportOptions {
26
+ /** The WebSocket URL to connect to (e.g. `ws://127.0.0.1:8787/reform`). */
27
+ readonly url: string
28
+ /** First reconnect delay; doubles each attempt up to `maxDelayMs`. Default 250ms. */
29
+ readonly baseDelayMs?: number
30
+ /** Ceiling for the backoff delay. Default 5000ms. */
31
+ readonly maxDelayMs?: number
32
+ /** Subprotocols passed to the `WebSocket` constructor. */
33
+ readonly protocols?: string | ReadonlyArray<string>
34
+ /**
35
+ * A bearer token to authenticate the connection. A browser `WebSocket` can't set headers,
36
+ * so the only header-like channel is the subprotocol list: the token rides as an extra
37
+ * `bearer.<token>` subprotocol alongside any {@link protocols}. The server reads it off
38
+ * `Sec-WebSocket-Protocol` at the upgrade. Held `Redacted` so it never logs.
39
+ */
40
+ readonly authToken?: Redacted.Redacted<string>
41
+ /** The `WebSocket` implementation to use. Defaults to the global. */
42
+ readonly WebSocket?: typeof WebSocket
43
+ /** Notified on every connection-status transition. */
44
+ readonly onStatus?: (status: ClientStatus) => void
45
+ }
46
+
47
+ /** A `RemoteTransport` that also exposes its connection status and a manual close. */
48
+ export type WebSocketClientTransport = RemoteTransport<InvokeMessage, ServerMessage> & {
49
+ readonly status: () => ClientStatus
50
+ /**
51
+ * Subscribe to status transitions (returns an unsubscribe). With `status()`
52
+ * this satisfies the React binding's `StatusReporter`, so `useConnectionStatus`
53
+ * can drive a reconnecting badge with no manual store.
54
+ */
55
+ readonly onStatusChange: (listener: () => void) => () => void
56
+ /** Stop reconnecting and close the socket. The transport is inert afterwards. */
57
+ readonly close: () => void
58
+ }
59
+
60
+ const resolveWebSocket = (injected: typeof WebSocket | undefined): typeof WebSocket => {
61
+ const impl = injected ?? (globalThis as { WebSocket?: typeof WebSocket }).WebSocket
62
+ if (impl === undefined) {
63
+ throw new MissingWebSocket({
64
+ message: 'No global WebSocket; pass `WebSocket` in options (e.g. the `ws` package in Node <22).',
65
+ })
66
+ }
67
+ return impl
68
+ }
69
+
70
+ export const createWebSocketClientTransport = (
71
+ options: WebSocketClientTransportOptions,
72
+ ): WebSocketClientTransport => {
73
+ const url = options.url
74
+ const WS = resolveWebSocket(options.WebSocket)
75
+ const baseDelayMs = options.baseDelayMs ?? 250
76
+ const maxDelayMs = options.maxDelayMs ?? 5000
77
+
78
+ // Normalize `protocols` to an array and fold in the auth token as a `bearer.<token>`
79
+ // subprotocol. Computed once (the token doesn't change across reconnects). `undefined`
80
+ // when there's nothing to send, so the no-auth path matches the original behavior.
81
+ const baseProtocols: ReadonlyArray<string> =
82
+ options.protocols === undefined
83
+ ? []
84
+ : typeof options.protocols === 'string'
85
+ ? [options.protocols]
86
+ : options.protocols
87
+ const protocols: ReadonlyArray<string> | undefined =
88
+ options.authToken !== undefined
89
+ ? [`bearer.${Redacted.value(options.authToken)}`, ...baseProtocols]
90
+ : baseProtocols.length === 0
91
+ ? undefined
92
+ : baseProtocols
93
+
94
+ const handlers = new Set<(message: ServerMessage) => void>()
95
+ const statusListeners = new Set<() => void>()
96
+ const queue: Array<InvokeMessage> = []
97
+ // A const holder whose fields swap — the codebase's no-`let` idiom.
98
+ const state: {
99
+ socket: WebSocket | null
100
+ status: ClientStatus
101
+ attempts: number
102
+ timer: ReturnType<typeof setTimeout> | null
103
+ } = { socket: null, status: 'connecting', attempts: 0, timer: null }
104
+
105
+ const setStatus = (status: ClientStatus): void => {
106
+ if (status === state.status) return
107
+ state.status = status
108
+ options.onStatus?.(status)
109
+ for (const listener of statusListeners) listener()
110
+ }
111
+
112
+ 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))
117
+ }
118
+
119
+ const scheduleReconnect = (): void => {
120
+ if (state.status === 'closed') return
121
+ setStatus('reconnecting')
122
+ const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** state.attempts)
123
+ state.attempts += 1
124
+ state.timer = setTimeout(connect, delay)
125
+ }
126
+
127
+ // 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
129
+ // socket on the first drop, so the second event is a no-op and only one
130
+ // reconnect is scheduled. A user `close` flips the status, making this inert.
131
+ const handleDrop = (socket: WebSocket): void => {
132
+ if (state.socket !== socket) return
133
+ state.socket = null
134
+ scheduleReconnect()
135
+ }
136
+
137
+ function connect(): void {
138
+ const socket = protocols === undefined ? new WS(url) : new WS(url, [...protocols])
139
+ state.socket = socket
140
+ socket.addEventListener('open', () => {
141
+ state.attempts = 0
142
+ setStatus('open')
143
+ flush()
144
+ })
145
+ socket.addEventListener('message', (event: MessageEvent) => {
146
+ const message = JSON.parse(event.data as string) as ServerMessage
147
+ for (const handler of handlers) handler(message)
148
+ })
149
+ socket.addEventListener('close', () => handleDrop(socket))
150
+ socket.addEventListener('error', () => handleDrop(socket))
151
+ }
152
+
153
+ connect()
154
+
155
+ return {
156
+ send: (message) => {
157
+ queue.push(message)
158
+ flush()
159
+ },
160
+ onMessage: (handler) => {
161
+ handlers.add(handler)
162
+ return () => void handlers.delete(handler)
163
+ },
164
+ status: () => state.status,
165
+ onStatusChange: (listener) => {
166
+ statusListeners.add(listener)
167
+ return () => void statusListeners.delete(listener)
168
+ },
169
+ close: () => {
170
+ setStatus('closed')
171
+ if (state.timer !== null) clearTimeout(state.timer)
172
+ state.socket?.close()
173
+ },
174
+ }
175
+ }
@@ -1 +0,0 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,KAAK,EAAQ,MAAM,QAAQ,CAAA;AAEzC;;;;GAIG;AAEH;;;;GAIG;AACH,KAAK,gBAAgB,CAAC,GAAG,SAAS,MAAM,EAAE,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI,KAC7E,IAAI,EAAE,CAAC,KACJ,KAAK,CAAC,cAAc,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAA;CAAE,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAA;AAEhE,QAAA,MAAM,oBAAoB,EAAE,gBAAgB,CAC1C,8CAA8C,EAC9C;IAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAG5B,CAAA;AAEF,iFAAiF;AACjF,qBAAa,gBAAiB,SAAQ,oBAAoB;CAAG"}
package/dist/errors.js DELETED
@@ -1,6 +0,0 @@
1
- import { Data } from 'effect';
2
- const MissingWebSocketBase = (Data.TaggedError('@playfast/reform-remote-web/MissingWebSocket'));
3
- /** Thrown when no `WebSocket` is available and none was injected via options. */
4
- export class MissingWebSocket extends MissingWebSocketBase {
5
- }
6
- //# sourceMappingURL=errors.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,IAAI,EAAE,MAAM,QAAQ,CAAA;AAiBzC,MAAM,oBAAoB,GAGtB,CAAA,IAAI,CAAC,WAAW,CAAC,8CAA8C,CAEjE,CAAA,CAAA;AAEF,iFAAiF;AACjF,MAAM,OAAO,gBAAiB,SAAQ,oBAAoB;CAAG"}
package/dist/index.d.ts DELETED
@@ -1,13 +0,0 @@
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
- export * as Transport from './transport.js';
10
- export * as Errors from './errors.js';
11
- export { type ClientStatus, createWebSocketClientTransport, type WebSocketClientTransport, type WebSocketClientTransportOptions, } from './transport.js';
12
- export { MissingWebSocket } from './errors.js';
13
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,SAAS,MAAM,gBAAgB,CAAA;AAC3C,OAAO,KAAK,MAAM,MAAM,aAAa,CAAA;AAErC,OAAO,EACL,KAAK,YAAY,EACjB,8BAA8B,EAC9B,KAAK,wBAAwB,EAC7B,KAAK,+BAA+B,GACrC,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA"}
package/dist/index.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,SAAS,MAAM,gBAAgB,CAAA;AAC3C,OAAO,KAAK,MAAM,MAAM,aAAa,CAAA;AAErC,OAAO,EAEL,8BAA8B,GAG/B,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA"}
@@ -1,55 +0,0 @@
1
- import { Redacted } from 'effect';
2
- import type { InvokeMessage, RemoteTransport, ServerMessage } from '@playfast/reform-remote';
3
- /**
4
- * The browser/web client side of the remote transport. A factory that wraps a
5
- * WebSocket into a `RemoteTransport` you hand to `connect({ transport, views })`:
6
- * it sends `Invoke`s up and surfaces `Snapshot`/`Patches` down, with JSON framing.
7
- *
8
- * It owns reconnection so the caller doesn't have to: a dropped socket is retried
9
- * with exponential backoff, and because the message handlers persist across
10
- * sockets, the server's first frame on the new connection — always a `Snapshot` —
11
- * re-syncs the tree automatically (no stale nodes leak). Works anywhere a global
12
- * `WebSocket` exists (browsers, Bun, Node 22+); inject one via `options` otherwise.
13
- * There is no React hook — this is the transport only.
14
- */
15
- export type ClientStatus = 'connecting' | 'open' | 'reconnecting' | 'closed';
16
- /**
17
- * Options for {@link createWebSocketClientTransport}. A single named-options object
18
- * (no positional args) so every reform-remote adapter constructor has the same
19
- * shape and the client transports stay drop-in swappable.
20
- */
21
- export interface WebSocketClientTransportOptions {
22
- /** The WebSocket URL to connect to (e.g. `ws://127.0.0.1:8787/reform`). */
23
- readonly url: string;
24
- /** First reconnect delay; doubles each attempt up to `maxDelayMs`. Default 250ms. */
25
- readonly baseDelayMs?: number;
26
- /** Ceiling for the backoff delay. Default 5000ms. */
27
- readonly maxDelayMs?: number;
28
- /** Subprotocols passed to the `WebSocket` constructor. */
29
- readonly protocols?: string | ReadonlyArray<string>;
30
- /**
31
- * A bearer token to authenticate the connection. A browser `WebSocket` can't set headers,
32
- * so the only header-like channel is the subprotocol list: the token rides as an extra
33
- * `bearer.<token>` subprotocol alongside any {@link protocols}. The server reads it off
34
- * `Sec-WebSocket-Protocol` at the upgrade. Held `Redacted` so it never logs.
35
- */
36
- readonly authToken?: Redacted.Redacted<string>;
37
- /** The `WebSocket` implementation to use. Defaults to the global. */
38
- readonly WebSocket?: typeof WebSocket;
39
- /** Notified on every connection-status transition. */
40
- readonly onStatus?: (status: ClientStatus) => void;
41
- }
42
- /** A `RemoteTransport` that also exposes its connection status and a manual close. */
43
- export type WebSocketClientTransport = RemoteTransport<InvokeMessage, ServerMessage> & {
44
- readonly status: () => ClientStatus;
45
- /**
46
- * Subscribe to status transitions (returns an unsubscribe). With `status()`
47
- * this satisfies the React binding's `StatusReporter`, so `useConnectionStatus`
48
- * can drive a reconnecting badge with no manual store.
49
- */
50
- readonly onStatusChange: (listener: () => void) => () => void;
51
- /** Stop reconnecting and close the socket. The transport is inert afterwards. */
52
- readonly close: () => void;
53
- };
54
- export declare const createWebSocketClientTransport: (options: WebSocketClientTransportOptions) => WebSocketClientTransport;
55
- //# sourceMappingURL=transport.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAA;AACjC,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAA;AAG5F;;;;;;;;;;;GAWG;AAEH,MAAM,MAAM,YAAY,GAAG,YAAY,GAAG,MAAM,GAAG,cAAc,GAAG,QAAQ,CAAA;AAE5E;;;;GAIG;AACH,MAAM,WAAW,+BAA+B;IAC9C,2EAA2E;IAC3E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,qFAAqF;IACrF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,qDAAqD;IACrD,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,0DAA0D;IAC1D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAA;IACnD;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAA;IAC9C,qEAAqE;IACrE,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,SAAS,CAAA;IACrC,sDAAsD;IACtD,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,YAAY,KAAK,IAAI,CAAA;CACnD;AAED,sFAAsF;AACtF,MAAM,MAAM,wBAAwB,GAAG,eAAe,CAAC,aAAa,EAAE,aAAa,CAAC,GAAG;IACrF,QAAQ,CAAC,MAAM,EAAE,MAAM,YAAY,CAAA;IACnC;;;;OAIG;IACH,QAAQ,CAAC,cAAc,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAA;IAC7D,iFAAiF;IACjF,QAAQ,CAAC,KAAK,EAAE,MAAM,IAAI,CAAA;CAC3B,CAAA;AAYD,eAAO,MAAM,8BAA8B,GACzC,SAAS,+BAA+B,KACvC,wBAuGF,CAAA"}
package/dist/transport.js DELETED
@@ -1,108 +0,0 @@
1
- import { Redacted } from 'effect';
2
- import { MissingWebSocket } from './errors.js';
3
- const resolveWebSocket = (injected) => {
4
- const impl = injected ?? globalThis.WebSocket;
5
- if (impl === undefined) {
6
- throw new MissingWebSocket({
7
- message: 'No global WebSocket; pass `WebSocket` in options (e.g. the `ws` package in Node <22).',
8
- });
9
- }
10
- return impl;
11
- };
12
- export const createWebSocketClientTransport = (options) => {
13
- const url = options.url;
14
- const WS = resolveWebSocket(options.WebSocket);
15
- const baseDelayMs = options.baseDelayMs ?? 250;
16
- const maxDelayMs = options.maxDelayMs ?? 5000;
17
- // Normalize `protocols` to an array and fold in the auth token as a `bearer.<token>`
18
- // subprotocol. Computed once (the token doesn't change across reconnects). `undefined`
19
- // when there's nothing to send, so the no-auth path matches the original behavior.
20
- const baseProtocols = options.protocols === undefined
21
- ? []
22
- : typeof options.protocols === 'string'
23
- ? [options.protocols]
24
- : options.protocols;
25
- const protocols = options.authToken !== undefined
26
- ? [`bearer.${Redacted.value(options.authToken)}`, ...baseProtocols]
27
- : baseProtocols.length === 0
28
- ? undefined
29
- : baseProtocols;
30
- const handlers = new Set();
31
- const statusListeners = new Set();
32
- const queue = [];
33
- // A const holder whose fields swap — the codebase's no-`let` idiom.
34
- const state = { socket: null, status: 'connecting', attempts: 0, timer: null };
35
- const setStatus = (status) => {
36
- if (status === state.status)
37
- return;
38
- state.status = status;
39
- options.onStatus?.(status);
40
- for (const listener of statusListeners)
41
- listener();
42
- };
43
- const flush = () => {
44
- const socket = state.socket;
45
- if (socket === null || socket.readyState !== WS.OPEN)
46
- return;
47
- const pending = queue.splice(0, queue.length);
48
- for (const message of pending)
49
- socket.send(JSON.stringify(message));
50
- };
51
- const scheduleReconnect = () => {
52
- if (state.status === 'closed')
53
- return;
54
- setStatus('reconnecting');
55
- const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** state.attempts);
56
- state.attempts += 1;
57
- state.timer = setTimeout(connect, delay);
58
- };
59
- // A drop while live (or a failed connect) backs off and retries. A socket can
60
- // fire both `error` and `close`, so guard on identity — null out the current
61
- // socket on the first drop, so the second event is a no-op and only one
62
- // reconnect is scheduled. A user `close` flips the status, making this inert.
63
- const handleDrop = (socket) => {
64
- if (state.socket !== socket)
65
- return;
66
- state.socket = null;
67
- scheduleReconnect();
68
- };
69
- function connect() {
70
- const socket = protocols === undefined ? new WS(url) : new WS(url, [...protocols]);
71
- state.socket = socket;
72
- socket.addEventListener('open', () => {
73
- state.attempts = 0;
74
- setStatus('open');
75
- flush();
76
- });
77
- socket.addEventListener('message', (event) => {
78
- const message = JSON.parse(event.data);
79
- for (const handler of handlers)
80
- handler(message);
81
- });
82
- socket.addEventListener('close', () => handleDrop(socket));
83
- socket.addEventListener('error', () => handleDrop(socket));
84
- }
85
- connect();
86
- return {
87
- send: (message) => {
88
- queue.push(message);
89
- flush();
90
- },
91
- onMessage: (handler) => {
92
- handlers.add(handler);
93
- return () => void handlers.delete(handler);
94
- },
95
- status: () => state.status,
96
- onStatusChange: (listener) => {
97
- statusListeners.add(listener);
98
- return () => void statusListeners.delete(listener);
99
- },
100
- close: () => {
101
- setStatus('closed');
102
- if (state.timer !== null)
103
- clearTimeout(state.timer);
104
- state.socket?.close();
105
- },
106
- };
107
- };
108
- //# sourceMappingURL=transport.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"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"}