@playfast/reform-remote-web 1.0.3 → 1.2.0

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/README.md CHANGED
@@ -33,7 +33,7 @@ const App = () => {
33
33
  **Reconnection is built in.** A dropped socket is retried with exponential backoff
34
34
  (`baseDelayMs`/`maxDelayMs`), and because the message handlers persist across sockets, the
35
35
  server's first frame on the new connection — always a `Snapshot` — re-syncs the tree
36
- automatically, with no stale nodes left behind. The returned value *is* a `RemoteTransport`
36
+ automatically, with no stale nodes left behind. The returned value _is_ a `RemoteTransport`
37
37
  (pass it straight to `connect` or `<RemoteUI>`) that additionally carries `status()`,
38
38
  `onStatusChange()` — together a `StatusReporter`, so `useConnectionStatus` drives the badge
39
39
  above — and `close()` to stop reconnecting.
@@ -0,0 +1,11 @@
1
+ import { type Cause } from 'effect';
2
+ type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (args: A) => Cause.YieldableError & {
3
+ readonly _tag: Tag;
4
+ } & Readonly<A>;
5
+ declare const MissingWebSocketBase: TaggedErrorClass<'@playfast/reform-remote-web/MissingWebSocket', {
6
+ readonly message: string;
7
+ }>;
8
+ export declare class MissingWebSocket extends MissingWebSocketBase {
9
+ }
10
+ export {};
11
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,KAAK,EAAQ,MAAM,QAAQ,CAAA;AAGzC,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,qBAAa,gBAAiB,SAAQ,oBAAoB;CAAG"}
package/dist/errors.js ADDED
@@ -0,0 +1,5 @@
1
+ import { Data } from 'effect';
2
+ const MissingWebSocketBase = (Data.TaggedError('@playfast/reform-remote-web/MissingWebSocket'));
3
+ export class MissingWebSocket extends MissingWebSocketBase {
4
+ }
5
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,IAAI,EAAE,MAAM,QAAQ,CAAA;AAOzC,MAAM,oBAAoB,GAGtB,CAAA,IAAI,CAAC,WAAW,CAAC,8CAA8C,CAEjE,CAAA,CAAA;AAEF,MAAM,OAAO,gBAAiB,SAAQ,oBAAoB;CAAG"}
@@ -0,0 +1,5 @@
1
+ export * as Transport from './transport.js';
2
+ export * as Errors from './errors.js';
3
+ export { type ClientStatus, createWebSocketClientTransport, type WebSocketClientTransport, type WebSocketClientTransportOptions, } from './transport.js';
4
+ export { MissingWebSocket } from './errors.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,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 ADDED
@@ -0,0 +1,5 @@
1
+ export * as Transport from './transport.js';
2
+ export * as Errors from './errors.js';
3
+ export { createWebSocketClientTransport, } from './transport.js';
4
+ export { MissingWebSocket } from './errors.js';
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,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"}
@@ -0,0 +1,20 @@
1
+ import { Redacted } from 'effect';
2
+ import type { InvokeMessage, RemoteTransport, ServerMessage } from '@playfast/reform-remote';
3
+ export type ClientStatus = 'connecting' | 'open' | 'reconnecting' | 'closed';
4
+ export interface WebSocketClientTransportOptionsExternalApi {
5
+ readonly url: string;
6
+ readonly baseDelayMs?: number;
7
+ readonly maxDelayMs?: number;
8
+ readonly protocols?: string | ReadonlyArray<string>;
9
+ readonly authToken?: Redacted.Redacted<string>;
10
+ readonly WebSocket?: typeof WebSocket;
11
+ readonly onStatus?: (status: ClientStatus) => void;
12
+ }
13
+ export type WebSocketClientTransportOptions = WebSocketClientTransportOptionsExternalApi;
14
+ export type WebSocketClientTransport = RemoteTransport<InvokeMessage, ServerMessage> & {
15
+ readonly status: () => ClientStatus;
16
+ readonly onStatusChange: (listener: () => void) => () => void;
17
+ readonly close: () => void;
18
+ };
19
+ export declare const createWebSocketClientTransport: (options: WebSocketClientTransportOptions) => WebSocketClientTransport;
20
+ //# sourceMappingURL=transport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAAA,OAAO,EAAyB,QAAQ,EAAU,MAAM,QAAQ,CAAA;AAChE,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAA;AAG5F,MAAM,MAAM,YAAY,GAAG,YAAY,GAAG,MAAM,GAAG,cAAc,GAAG,QAAQ,CAAA;AAqD5E,MAAM,WAAW,0CAA0C;IACzD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAA;IAEnD,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAA;IAC9C,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,SAAS,CAAA;IACrC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,YAAY,KAAK,IAAI,CAAA;CACnD;AAED,MAAM,MAAM,+BAA+B,GAAG,0CAA0C,CAAA;AAExF,MAAM,MAAM,wBAAwB,GAAG,eAAe,CAAC,aAAa,EAAE,aAAa,CAAC,GAAG;IACrF,QAAQ,CAAC,MAAM,EAAE,MAAM,YAAY,CAAA;IACnC,QAAQ,CAAC,cAAc,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAA;IAC7D,QAAQ,CAAC,KAAK,EAAE,MAAM,IAAI,CAAA;CAC3B,CAAA;AAmBD,eAAO,MAAM,8BAA8B,GACzC,SAAS,+BAA+B,KACvC,wBAqIF,CAAA"}
@@ -0,0 +1,157 @@
1
+ import { Effect, Fiber, Option, Redacted, Schema } from 'effect';
2
+ import { MissingWebSocket } from './errors.js';
3
+ const DEFAULT_BASE_DELAY_MS = 250;
4
+ const DEFAULT_MAX_DELAY_MS = 5000;
5
+ const WirePropSchema = Schema.Union(Schema.Struct({
6
+ _tag: Schema.Literal('Data'),
7
+ name: Schema.String,
8
+ value: Schema.Unknown,
9
+ }), Schema.Struct({
10
+ _tag: Schema.Literal('Event'),
11
+ name: Schema.String,
12
+ handle: Schema.String,
13
+ }));
14
+ const WireNodeSchema = Schema.Struct({
15
+ id: Schema.String,
16
+ name: Schema.String,
17
+ parentId: Schema.NullOr(Schema.String),
18
+ childIndex: Schema.Number,
19
+ slot: Schema.NullOr(Schema.String),
20
+ key: Schema.NullOr(Schema.String),
21
+ props: Schema.Array(WirePropSchema),
22
+ });
23
+ const WirePatchSchema = Schema.Union(Schema.Struct({ _tag: Schema.Literal('Upsert'), node: WireNodeSchema }), Schema.Struct({ _tag: Schema.Literal('Delete'), id: Schema.String }));
24
+ const InvokeMessageJson = Schema.parseJson(Schema.Struct({
25
+ _tag: Schema.Literal('Invoke'),
26
+ handle: Schema.String,
27
+ payload: Schema.Unknown,
28
+ }));
29
+ const ServerMessageJson = Schema.parseJson(Schema.Union(Schema.Struct({
30
+ _tag: Schema.Literal('Snapshot'),
31
+ tree: Schema.Array(WireNodeSchema),
32
+ }), Schema.Struct({
33
+ _tag: Schema.Literal('Patches'),
34
+ patches: Schema.Array(WirePatchSchema),
35
+ })));
36
+ const encodeInvokeMessage = Schema.encodeSync(InvokeMessageJson);
37
+ const decodeServerMessage = Schema.decodeUnknownSync(ServerMessageJson);
38
+ const resolveWebSocket = (injected) => {
39
+ const globalScope = globalThis;
40
+ const impl = injected ?? globalScope.WebSocket;
41
+ return Option.getOrThrowWith(Option.fromNullable(impl), () => new MissingWebSocket({
42
+ message: 'No global WebSocket; pass `WebSocket` in options (e.g. the `ws` package in Node <22).',
43
+ }));
44
+ };
45
+ export const createWebSocketClientTransport = (options) => {
46
+ const url = options.url;
47
+ const WebSocketImpl = resolveWebSocket(options.WebSocket);
48
+ const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
49
+ const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
50
+ const baseProtocols = options.protocols === undefined
51
+ ? []
52
+ : typeof options.protocols === 'string'
53
+ ? [options.protocols]
54
+ : options.protocols;
55
+ const protocols = options.authToken !== undefined
56
+ ? [`bearer.${Redacted.value(options.authToken)}`, ...baseProtocols]
57
+ : baseProtocols.length === 0
58
+ ? undefined
59
+ : baseProtocols;
60
+ const handlers = new Set();
61
+ const statusListeners = new Set();
62
+ const state = {
63
+ socket: Option.none(),
64
+ status: 'connecting',
65
+ attempts: 0,
66
+ timer: Option.none(),
67
+ queue: [],
68
+ inbox: [],
69
+ };
70
+ const setStatus = (status) => {
71
+ if (status === state.status) {
72
+ return;
73
+ }
74
+ state.status = status;
75
+ options.onStatus?.(status);
76
+ statusListeners.forEach((listener) => listener());
77
+ };
78
+ const flush = () => {
79
+ if (!Option.isSome(state.socket)) {
80
+ return;
81
+ }
82
+ const socket = state.socket.value;
83
+ if (socket.readyState !== WebSocketImpl.OPEN) {
84
+ return;
85
+ }
86
+ const pending = state.queue;
87
+ state.queue = [];
88
+ pending.forEach((message) => socket.send(encodeInvokeMessage(message)));
89
+ };
90
+ const scheduleReconnect = () => {
91
+ if (state.status === 'closed') {
92
+ return;
93
+ }
94
+ setStatus('reconnecting');
95
+ const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** state.attempts);
96
+ state.attempts += 1;
97
+ state.timer = Option.some(Effect.runFork(Effect.sleep(delay).pipe(Effect.andThen(Effect.sync(connect)))));
98
+ };
99
+ // A socket may fire both error and close; clearing first prevents duplicate reconnects.
100
+ const handleDrop = (socket) => {
101
+ if (Option.isNone(state.socket) || state.socket.value !== socket) {
102
+ return;
103
+ }
104
+ state.socket = Option.none();
105
+ scheduleReconnect();
106
+ };
107
+ function connect() {
108
+ const socket = protocols === undefined ? new WebSocketImpl(url) : new WebSocketImpl(url, [...protocols]);
109
+ state.socket = Option.some(socket);
110
+ socket.addEventListener('open', () => {
111
+ state.attempts = 0;
112
+ setStatus('open');
113
+ flush();
114
+ });
115
+ socket.addEventListener('message', (event) => {
116
+ const message = decodeServerMessage(event.data);
117
+ if (handlers.size === 0) {
118
+ state.inbox = [...state.inbox, message];
119
+ return;
120
+ }
121
+ handlers.forEach((handler) => handler(message));
122
+ });
123
+ socket.addEventListener('close', () => handleDrop(socket));
124
+ socket.addEventListener('error', () => handleDrop(socket));
125
+ }
126
+ connect();
127
+ return {
128
+ send: (message) => {
129
+ state.queue = [...state.queue, message];
130
+ flush();
131
+ },
132
+ onMessage: (handler) => {
133
+ handlers.add(handler);
134
+ if (state.inbox.length > 0) {
135
+ const buffered = state.inbox;
136
+ state.inbox = [];
137
+ buffered.forEach((message) => handler(message));
138
+ }
139
+ return () => void handlers.delete(handler);
140
+ },
141
+ status: () => state.status,
142
+ onStatusChange: (listener) => {
143
+ statusListeners.add(listener);
144
+ return () => void statusListeners.delete(listener);
145
+ },
146
+ close: () => {
147
+ setStatus('closed');
148
+ if (Option.isSome(state.timer)) {
149
+ Effect.runSync(Fiber.interrupt(state.timer.value));
150
+ }
151
+ if (Option.isSome(state.socket)) {
152
+ state.socket.value.close();
153
+ }
154
+ },
155
+ };
156
+ };
157
+ //# sourceMappingURL=transport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,48 +1,58 @@
1
1
  {
2
2
  "name": "@playfast/reform-remote-web",
3
- "playbook": "./playbook",
4
- "version": "1.0.3",
5
- "type": "module",
3
+ "version": "1.2.0",
6
4
  "description": "Browser/web WebSocket client transport for reform-remote — streams a server-run reform scene to a thin renderer.",
7
5
  "keywords": [
8
- "reform",
9
6
  "effect",
10
- "websocket",
7
+ "reform",
11
8
  "remote",
12
- "transport"
9
+ "transport",
10
+ "websocket"
13
11
  ],
12
+ "bugs": {
13
+ "url": "https://github.com/playfast/reform/issues"
14
+ },
14
15
  "license": "MIT",
15
16
  "repository": {
16
17
  "type": "git",
17
18
  "url": "https://github.com/playfast/reform.git",
18
19
  "directory": "packages/reform-remote-web"
19
20
  },
20
- "bugs": {
21
- "url": "https://github.com/playfast/reform/issues"
22
- },
23
- "sideEffects": false,
24
- "exports": {
25
- "./package.json": "./package.json",
26
- ".": "./src/index.ts",
27
- "./*": "./src/*.ts"
28
- },
29
21
  "files": [
22
+ "dist",
30
23
  "src",
31
24
  "README.md"
32
25
  ],
26
+ "type": "module",
27
+ "sideEffects": false,
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ "./package.json": "./package.json",
32
+ ".": {
33
+ "playfast-src": "./src/index.ts",
34
+ "types": "./dist/index.d.ts",
35
+ "default": "./dist/index.js"
36
+ },
37
+ "./*": {
38
+ "playfast-src": "./src/*.ts",
39
+ "types": "./dist/*.d.ts",
40
+ "default": "./dist/*.js"
41
+ }
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
33
46
  "scripts": {
34
47
  "clean": "rm -rf dist .tsbuildinfo",
35
48
  "check": "tsc --noEmit",
36
- "build": "tsc -p tsconfig.build.json",
49
+ "build": "rm -rf dist .tsbuildinfo && tsc -p tsconfig.build.json && bun ../../scripts/fix-esm-extensions.ts dist",
37
50
  "test": "vitest run",
38
51
  "test:watch": "vitest",
39
52
  "coverage": "vitest run --coverage",
40
53
  "lint": "oxlint src",
41
- "lint:fix": "oxlint --fix src"
42
- },
43
- "peerDependencies": {
44
- "effect": "*",
45
- "@playfast/reform-remote": "*"
54
+ "lint:fix": "oxlint --fix src",
55
+ "prepack": "bun run build"
46
56
  },
47
57
  "devDependencies": {
48
58
  "@playfast/reform": "*",
@@ -50,7 +60,9 @@
50
60
  "@playfast/reform-remote-node": "*",
51
61
  "react": "^19.0.0"
52
62
  },
53
- "publishConfig": {
54
- "access": "public"
55
- }
63
+ "peerDependencies": {
64
+ "@playfast/reform-remote": "*",
65
+ "effect": "*"
66
+ },
67
+ "playbook": "./playbook"
56
68
  }
@@ -31,11 +31,19 @@ class CounterUi extends ui('Counter', {
31
31
  props: S.Struct({ count: S.Number }),
32
32
  events: { bump: S.Struct({ by: S.Number }) },
33
33
  }) {}
34
- class Counter extends Composition.make('Counter', { title: 'Counter', ui: CounterUi, states: [Count] }) {}
34
+
35
+ class Counter extends Composition.make('Counter', {
36
+ title: 'Counter',
37
+ ui: CounterUi,
38
+ states: [Count],
39
+ })<Counter>() {}
35
40
 
36
41
  const counterScene = () => {
37
42
  const presentation = Layer.mergeAll(
38
- provide(CounterUi, Ui.make(CounterUi, ({ count }) => `count:${count}`)),
43
+ provide(
44
+ CounterUi,
45
+ Ui.make(CounterUi, ({ count }) => `count:${count}`),
46
+ ),
39
47
  StateGroup.live(Counters, { count: 0 }),
40
48
  )
41
49
  const app = Layer.mergeAll(
@@ -60,7 +68,8 @@ const probeView = (probe: Probe) =>
60
68
  return null
61
69
  })
62
70
 
63
- const draw = (node: ReactNode): void => void renderToStaticMarkup(createElement(Fragment, null, node))
71
+ const draw = (node: ReactNode): void =>
72
+ void renderToStaticMarkup(createElement(Fragment, null, node))
64
73
 
65
74
  const listening = (host: NodeWebSocketServer): Promise<number> =>
66
75
  new Promise((resolve) =>
package/src/transport.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Option, Redacted } from 'effect'
1
+ import { Effect, Fiber, Option, Redacted, Schema } from 'effect'
2
2
  import type { InvokeMessage, RemoteTransport, ServerMessage } from '@playfast/reform-remote'
3
3
  import { MissingWebSocket } from './errors.js'
4
4
 
@@ -7,12 +7,60 @@ export type ClientStatus = 'connecting' | 'open' | 'reconnecting' | 'closed'
7
7
  const DEFAULT_BASE_DELAY_MS = 250
8
8
  const DEFAULT_MAX_DELAY_MS = 5000
9
9
 
10
+ const WirePropSchema = Schema.Union(
11
+ Schema.Struct({
12
+ _tag: Schema.Literal('Data'),
13
+ name: Schema.String,
14
+ value: Schema.Unknown,
15
+ }),
16
+ Schema.Struct({
17
+ _tag: Schema.Literal('Event'),
18
+ name: Schema.String,
19
+ handle: Schema.String,
20
+ }),
21
+ )
22
+ const WireNodeSchema = Schema.Struct({
23
+ id: Schema.String,
24
+ name: Schema.String,
25
+ parentId: Schema.NullOr(Schema.String),
26
+ childIndex: Schema.Number,
27
+ slot: Schema.NullOr(Schema.String),
28
+ key: Schema.NullOr(Schema.String),
29
+ props: Schema.Array(WirePropSchema),
30
+ })
31
+ const WirePatchSchema = Schema.Union(
32
+ Schema.Struct({ _tag: Schema.Literal('Upsert'), node: WireNodeSchema }),
33
+ Schema.Struct({ _tag: Schema.Literal('Delete'), id: Schema.String }),
34
+ )
35
+ const InvokeMessageJson: Schema.Schema<InvokeMessage, string, never> = Schema.parseJson(
36
+ Schema.Struct({
37
+ _tag: Schema.Literal('Invoke'),
38
+ handle: Schema.String,
39
+ payload: Schema.Unknown,
40
+ }),
41
+ )
42
+ const ServerMessageJson: Schema.Schema<ServerMessage, string, never> = Schema.parseJson(
43
+ Schema.Union(
44
+ Schema.Struct({
45
+ _tag: Schema.Literal('Snapshot'),
46
+ tree: Schema.Array(WireNodeSchema),
47
+ }),
48
+ Schema.Struct({
49
+ _tag: Schema.Literal('Patches'),
50
+ patches: Schema.Array(WirePatchSchema),
51
+ }),
52
+ ),
53
+ )
54
+ const encodeInvokeMessage: (message: InvokeMessage) => string = Schema.encodeSync(InvokeMessageJson)
55
+ const decodeServerMessage: (input: unknown) => ServerMessage =
56
+ Schema.decodeUnknownSync(ServerMessageJson)
57
+
10
58
  export interface WebSocketClientTransportOptionsExternalApi {
11
59
  readonly url: string
12
60
  readonly baseDelayMs?: number
13
61
  readonly maxDelayMs?: number
14
62
  readonly protocols?: string | ReadonlyArray<string>
15
- // Browser WebSocket can't set headers; auth rides as `bearer.<token>` subprotocol. Redacted so it never logs.
63
+ // Browser WebSocket cannot set headers; auth uses a bearer subprotocol and stays redacted.
16
64
  readonly authToken?: Redacted.Redacted<string>
17
65
  readonly WebSocket?: typeof WebSocket
18
66
  readonly onStatus?: (status: ClientStatus) => void
@@ -33,13 +81,14 @@ interface GlobalWebSocketHolderExternalApi {
33
81
  const resolveWebSocket = (injected: typeof WebSocket | undefined): typeof WebSocket => {
34
82
  const globalScope: GlobalWebSocketHolderExternalApi = globalThis
35
83
  const impl = injected ?? globalScope.WebSocket
36
- if (impl === undefined) {
37
- // oxlint-disable-next-line reform-rules/no-throw -- sync public transport factory invariant; no Effect context at construction
38
- throw new MissingWebSocket({
39
- message: 'No global WebSocket; pass `WebSocket` in options (e.g. the `ws` package in Node <22).',
40
- })
41
- }
42
- return impl
84
+ return Option.getOrThrowWith(
85
+ Option.fromNullable(impl),
86
+ () =>
87
+ new MissingWebSocket({
88
+ message:
89
+ 'No global WebSocket; pass `WebSocket` in options (e.g. the `ws` package in Node <22).',
90
+ }),
91
+ )
43
92
  }
44
93
 
45
94
  export const createWebSocketClientTransport = (
@@ -69,11 +118,9 @@ export const createWebSocketClientTransport = (
69
118
  socket: Option.Option<WebSocket>
70
119
  status: ClientStatus
71
120
  attempts: number
72
- timer: Option.Option<ReturnType<typeof setTimeout>>
121
+ timer: Option.Option<Fiber.RuntimeFiber<void, never>>
73
122
  queue: ReadonlyArray<InvokeMessage>
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.
123
+ // The opening Snapshot can arrive before RemoteUI subscribes.
77
124
  inbox: ReadonlyArray<ServerMessage>
78
125
  } = {
79
126
  socket: Option.none(),
@@ -103,8 +150,7 @@ export const createWebSocketClientTransport = (
103
150
  }
104
151
  const pending = state.queue
105
152
  state.queue = []
106
- // oxlint-disable-next-line reform-rules/no-json-parse-stringify -- wire frame encode; InvokeMessage is a structural type with no Schema here
107
- pending.forEach((message) => socket.send(JSON.stringify(message)))
153
+ pending.forEach((message) => socket.send(encodeInvokeMessage(message)))
108
154
  }
109
155
 
110
156
  const scheduleReconnect = (): void => {
@@ -114,11 +160,12 @@ export const createWebSocketClientTransport = (
114
160
  setStatus('reconnecting')
115
161
  const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** state.attempts)
116
162
  state.attempts += 1
117
- // oxlint-disable-next-line reform-rules/no-set-timeout-interval -- DOM transport reconnect backoff; sync factory has no Effect runtime
118
- state.timer = Option.some(setTimeout(connect, delay))
163
+ state.timer = Option.some(
164
+ Effect.runFork(Effect.sleep(delay).pipe(Effect.andThen(Effect.sync(connect)))),
165
+ )
119
166
  }
120
167
 
121
- // Socket may fire both error and close; clear on first drop so only one reconnect is scheduled.
168
+ // A socket may fire both error and close; clearing first prevents duplicate reconnects.
122
169
  const handleDrop = (socket: WebSocket): void => {
123
170
  if (Option.isNone(state.socket) || state.socket.value !== socket) {
124
171
  return
@@ -128,19 +175,17 @@ export const createWebSocketClientTransport = (
128
175
  }
129
176
 
130
177
  function connect(): void {
131
- const socket = protocols === undefined ? new WebSocketImpl(url) : new WebSocketImpl(url, [...protocols])
178
+ const socket =
179
+ protocols === undefined ? new WebSocketImpl(url) : new WebSocketImpl(url, [...protocols])
132
180
  state.socket = Option.some(socket)
133
181
  socket.addEventListener('open', () => {
134
182
  state.attempts = 0
135
183
  setStatus('open')
136
184
  flush()
137
185
  })
138
- socket.addEventListener('message', (event: MessageEvent) => {
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
140
- const message = JSON.parse(event.data as string) as ServerMessage
186
+ socket.addEventListener('message', (event: MessageEvent<unknown>) => {
187
+ const message = decodeServerMessage(event.data)
141
188
  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
189
  state.inbox = [...state.inbox, message]
145
190
  return
146
191
  }
@@ -153,14 +198,12 @@ export const createWebSocketClientTransport = (
153
198
  connect()
154
199
 
155
200
  return {
156
- send: (message) => {
201
+ send: (message: InvokeMessage): void => {
157
202
  state.queue = [...state.queue, message]
158
203
  flush()
159
204
  },
160
- onMessage: (handler) => {
205
+ onMessage: (handler: (message: ServerMessage) => void): (() => void) => {
161
206
  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
207
  if (state.inbox.length > 0) {
165
208
  const buffered = state.inbox
166
209
  state.inbox = []
@@ -169,14 +212,14 @@ export const createWebSocketClientTransport = (
169
212
  return () => void handlers.delete(handler)
170
213
  },
171
214
  status: () => state.status,
172
- onStatusChange: (listener) => {
215
+ onStatusChange: (listener: () => void): (() => void) => {
173
216
  statusListeners.add(listener)
174
217
  return () => void statusListeners.delete(listener)
175
218
  },
176
219
  close: () => {
177
220
  setStatus('closed')
178
221
  if (Option.isSome(state.timer)) {
179
- clearTimeout(state.timer.value)
222
+ Effect.runSync(Fiber.interrupt(state.timer.value))
180
223
  }
181
224
  if (Option.isSome(state.socket)) {
182
225
  state.socket.value.close()