@playfast/reform-remote-web 1.1.0 → 1.2.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.
@@ -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;AA0D5E,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,wBA4IF,CAAA"}
@@ -0,0 +1,166 @@
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
+ // A frame off the network is untrusted: version skew against a newer server, a
38
+ // truncated write or a hostile endpoint must cost that frame and nothing else. A
39
+ // throw here would escape the socket's own event dispatch — an uncaught exception
40
+ // under `ws`, a lost frame plus a global error in a browser — and the socket never
41
+ // closes, so the reconnect ladder is never armed. Same answer the servers give.
42
+ const decodeServerMessage = Schema.decodeUnknownOption(ServerMessageJson);
43
+ const resolveWebSocket = (injected) => {
44
+ const globalScope = globalThis;
45
+ const impl = injected ?? globalScope.WebSocket;
46
+ return Option.getOrThrowWith(Option.fromNullable(impl), () => new MissingWebSocket({
47
+ message: 'No global WebSocket; pass `WebSocket` in options (e.g. the `ws` package in Node <22).',
48
+ }));
49
+ };
50
+ export const createWebSocketClientTransport = (options) => {
51
+ const url = options.url;
52
+ const WebSocketImpl = resolveWebSocket(options.WebSocket);
53
+ const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
54
+ const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
55
+ const baseProtocols = options.protocols === undefined
56
+ ? []
57
+ : typeof options.protocols === 'string'
58
+ ? [options.protocols]
59
+ : options.protocols;
60
+ const protocols = options.authToken !== undefined
61
+ ? [`bearer.${Redacted.value(options.authToken)}`, ...baseProtocols]
62
+ : baseProtocols.length === 0
63
+ ? undefined
64
+ : baseProtocols;
65
+ const handlers = new Set();
66
+ const statusListeners = new Set();
67
+ const state = {
68
+ socket: Option.none(),
69
+ status: 'connecting',
70
+ attempts: 0,
71
+ timer: Option.none(),
72
+ queue: [],
73
+ inbox: [],
74
+ };
75
+ const setStatus = (status) => {
76
+ if (status === state.status) {
77
+ return;
78
+ }
79
+ state.status = status;
80
+ options.onStatus?.(status);
81
+ statusListeners.forEach((listener) => listener());
82
+ };
83
+ const flush = () => {
84
+ if (!Option.isSome(state.socket)) {
85
+ return;
86
+ }
87
+ const socket = state.socket.value;
88
+ if (socket.readyState !== WebSocketImpl.OPEN) {
89
+ return;
90
+ }
91
+ const pending = state.queue;
92
+ state.queue = [];
93
+ pending.forEach((message) => socket.send(encodeInvokeMessage(message)));
94
+ };
95
+ const scheduleReconnect = () => {
96
+ if (state.status === 'closed') {
97
+ return;
98
+ }
99
+ setStatus('reconnecting');
100
+ const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** state.attempts);
101
+ state.attempts += 1;
102
+ state.timer = Option.some(Effect.runFork(Effect.sleep(delay).pipe(Effect.andThen(Effect.sync(connect)))));
103
+ };
104
+ // A socket may fire both error and close; clearing first prevents duplicate reconnects.
105
+ const handleDrop = (socket) => {
106
+ if (Option.isNone(state.socket) || state.socket.value !== socket) {
107
+ return;
108
+ }
109
+ state.socket = Option.none();
110
+ scheduleReconnect();
111
+ };
112
+ function connect() {
113
+ const socket = protocols === undefined ? new WebSocketImpl(url) : new WebSocketImpl(url, [...protocols]);
114
+ state.socket = Option.some(socket);
115
+ socket.addEventListener('open', () => {
116
+ state.attempts = 0;
117
+ setStatus('open');
118
+ flush();
119
+ });
120
+ socket.addEventListener('message', (event) => {
121
+ Option.match(decodeServerMessage(event.data), {
122
+ onNone: () => Effect.runSync(Effect.logWarning('reform: dropped a server frame this client cannot parse')),
123
+ onSome: (message) => {
124
+ if (handlers.size === 0) {
125
+ state.inbox = [...state.inbox, message];
126
+ return;
127
+ }
128
+ handlers.forEach((handler) => handler(message));
129
+ },
130
+ });
131
+ });
132
+ socket.addEventListener('close', () => handleDrop(socket));
133
+ socket.addEventListener('error', () => handleDrop(socket));
134
+ }
135
+ connect();
136
+ return {
137
+ send: (message) => {
138
+ state.queue = [...state.queue, message];
139
+ flush();
140
+ },
141
+ onMessage: (handler) => {
142
+ handlers.add(handler);
143
+ if (state.inbox.length > 0) {
144
+ const buffered = state.inbox;
145
+ state.inbox = [];
146
+ buffered.forEach((message) => handler(message));
147
+ }
148
+ return () => void handlers.delete(handler);
149
+ },
150
+ status: () => state.status,
151
+ onStatusChange: (listener) => {
152
+ statusListeners.add(listener);
153
+ return () => void statusListeners.delete(listener);
154
+ },
155
+ close: () => {
156
+ setStatus('closed');
157
+ if (Option.isSome(state.timer)) {
158
+ Effect.runSync(Fiber.interrupt(state.timer.value));
159
+ }
160
+ if (Option.isSome(state.socket)) {
161
+ state.socket.value.close();
162
+ }
163
+ },
164
+ };
165
+ };
166
+ //# 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@playfast/reform-remote-web",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Browser/web WebSocket client transport for reform-remote — streams a server-run reform scene to a thin renderer.",
5
5
  "keywords": [
6
6
  "effect",
@@ -19,15 +19,26 @@
19
19
  "directory": "packages/reform-remote-web"
20
20
  },
21
21
  "files": [
22
+ "dist",
22
23
  "src",
23
24
  "README.md"
24
25
  ],
25
26
  "type": "module",
26
27
  "sideEffects": false,
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
27
30
  "exports": {
28
31
  "./package.json": "./package.json",
29
- ".": "./src/index.ts",
30
- "./*": "./src/*.ts"
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
+ }
31
42
  },
32
43
  "publishConfig": {
33
44
  "access": "public"
@@ -35,12 +46,13 @@
35
46
  "scripts": {
36
47
  "clean": "rm -rf dist .tsbuildinfo",
37
48
  "check": "tsc --noEmit",
38
- "build": "tsc -p tsconfig.build.json",
49
+ "build": "rm -rf dist .tsbuildinfo && tsc -p tsconfig.build.json && bun ../../scripts/fix-esm-extensions.ts dist",
39
50
  "test": "vitest run",
40
51
  "test:watch": "vitest",
41
52
  "coverage": "vitest run --coverage",
42
53
  "lint": "oxlint src",
43
- "lint:fix": "oxlint --fix src"
54
+ "lint:fix": "oxlint --fix src",
55
+ "prepack": "bun run build"
44
56
  },
45
57
  "devDependencies": {
46
58
  "@playfast/reform": "*",
@@ -0,0 +1,130 @@
1
+ import { expect, test } from 'vitest'
2
+ import { Data, Option } from 'effect'
3
+ import type { ServerMessage } from '@playfast/reform-remote'
4
+ import { createWebSocketClientTransport } from './transport'
5
+
6
+ /**
7
+ * Round-3 adversarial audit — claim 4.
8
+ *
9
+ * `reform-remote-node` and `reform-remote-bun` swapped `Schema.decodeUnknownSync`
10
+ * for `Schema.decodeUnknownOption` on the invoke frame, with the reason: "the frame
11
+ * comes from the network... A throw here lands inside a `socket.on('message')`
12
+ * listener, where it escapes as a stream error and takes the shared serving path
13
+ * down with it."
14
+ *
15
+ * The identical construct is still in the client transport. `decodeServerMessage`
16
+ * is `Schema.decodeUnknownSync(ServerMessageJson)` (transport.ts:56) and it is
17
+ * called straight inside `socket.addEventListener('message', ...)` (transport.ts:184),
18
+ * so one frame this client cannot parse — version skew against a newer server, a
19
+ * truncated write, a hostile endpoint — throws out of the socket's own event
20
+ * dispatch. Under the `ws` package (which this transport documents for Node < 22)
21
+ * that is an uncaught exception and the host process exits; in a browser it is a
22
+ * global error and the frame is lost, and if the lost frame was the opening
23
+ * Snapshot the app renders null forever because the transport's own reconnect
24
+ * ladder is never armed (the socket did not close).
25
+ *
26
+ * Dropping the frame is the answer the servers already give.
27
+ */
28
+
29
+ class NoSocket extends Data.TaggedError('NoSocket')<{ readonly message: string }> {}
30
+
31
+ type Listener = (event: unknown) => void
32
+
33
+ // Mirrors the fake in `transport.test.ts`: no real I/O, events fired by hand.
34
+ class ProbeSocket {
35
+ static readonly CONNECTING = 0
36
+ static readonly OPEN = 1
37
+ static readonly CLOSING = 2
38
+ static readonly CLOSED = 3
39
+ static instances: ProbeSocket[] = []
40
+
41
+ readyState: number = ProbeSocket.CONNECTING
42
+ readonly sent: string[] = []
43
+ readonly url: string
44
+ private readonly listeners = new Map<string, Set<Listener>>()
45
+
46
+ constructor(url: string) {
47
+ this.url = url
48
+ ProbeSocket.instances.push(this)
49
+ }
50
+
51
+ addEventListener(type: string, cb: Listener): void {
52
+ const set = this.listeners.get(type) ?? new Set<Listener>()
53
+ set.add(cb)
54
+ this.listeners.set(type, set)
55
+ }
56
+
57
+ removeEventListener(type: string, cb: Listener): void {
58
+ this.listeners.get(type)?.delete(cb)
59
+ }
60
+
61
+ send(data: string): void {
62
+ this.sent.push(data)
63
+ }
64
+
65
+ close(): void {
66
+ this.readyState = ProbeSocket.CLOSED
67
+ }
68
+
69
+ emitOpen(): void {
70
+ this.readyState = ProbeSocket.OPEN
71
+ this.fire('open', {})
72
+ }
73
+
74
+ // Whatever the endpoint actually wrote, verbatim — the point of the test.
75
+ emitRaw(data: string): void {
76
+ this.fire('message', { data })
77
+ }
78
+
79
+ private fire(type: string, event: unknown): void {
80
+ this.listeners.get(type)?.forEach((cb) => cb(event))
81
+ }
82
+ }
83
+
84
+ const makeProbe = () => {
85
+ ProbeSocket.instances = []
86
+ const transport = createWebSocketClientTransport({
87
+ url: 'ws://audit3',
88
+ // Same single cast as `transport.test.ts`: `typeof WebSocket` is a DOM
89
+ // constructor type the fake cannot structurally satisfy.
90
+ WebSocket: ProbeSocket as unknown as typeof WebSocket,
91
+ })
92
+ const socket = Option.getOrThrowWith(
93
+ Option.fromNullable(ProbeSocket.instances[0]),
94
+ () => new NoSocket({ message: 'transport did not construct a socket' }),
95
+ )
96
+ return { transport, socket }
97
+ }
98
+
99
+ const SNAPSHOT = JSON.stringify({ _tag: 'Snapshot', tree: [] })
100
+
101
+ test('a server frame this client cannot parse is dropped, not thrown at the socket', () => {
102
+ const { transport, socket } = makeProbe()
103
+ socket.emitOpen()
104
+ const received: ServerMessage[] = []
105
+ transport.onMessage((message) => received.push(message))
106
+
107
+ // Control — a well-formed frame is delivered.
108
+ socket.emitRaw(SNAPSHOT)
109
+ expect(received.length).toBe(1)
110
+
111
+ const thrown: string[] = []
112
+ const deliver = (label: string, data: string): void => {
113
+ try {
114
+ socket.emitRaw(data)
115
+ } catch {
116
+ thrown.push(label)
117
+ }
118
+ }
119
+ deliver('not json', 'this-is-not-json')
120
+ deliver('unknown message tag', JSON.stringify({ _tag: 'Nope' }))
121
+ deliver('unknown patch tag', JSON.stringify({ _tag: 'Patches', patches: [{ _tag: 'Weird' }] }))
122
+ deliver('node missing fields', JSON.stringify({ _tag: 'Snapshot', tree: [{ id: 'x' }] }))
123
+ expect(thrown).toEqual([])
124
+
125
+ // …and the connection is still usable afterwards: an unparseable frame costs
126
+ // that frame, nothing else.
127
+ socket.emitRaw(SNAPSHOT)
128
+ expect(received.length).toBe(2)
129
+ transport.close()
130
+ })
package/src/transport.ts CHANGED
@@ -52,8 +52,13 @@ const ServerMessageJson: Schema.Schema<ServerMessage, string, never> = Schema.pa
52
52
  ),
53
53
  )
54
54
  const encodeInvokeMessage: (message: InvokeMessage) => string = Schema.encodeSync(InvokeMessageJson)
55
- const decodeServerMessage: (input: unknown) => ServerMessage =
56
- Schema.decodeUnknownSync(ServerMessageJson)
55
+ // A frame off the network is untrusted: version skew against a newer server, a
56
+ // truncated write or a hostile endpoint must cost that frame and nothing else. A
57
+ // throw here would escape the socket's own event dispatch — an uncaught exception
58
+ // under `ws`, a lost frame plus a global error in a browser — and the socket never
59
+ // closes, so the reconnect ladder is never armed. Same answer the servers give.
60
+ const decodeServerMessage: (input: unknown) => Option.Option<ServerMessage> =
61
+ Schema.decodeUnknownOption(ServerMessageJson)
57
62
 
58
63
  export interface WebSocketClientTransportOptionsExternalApi {
59
64
  readonly url: string
@@ -184,12 +189,19 @@ export const createWebSocketClientTransport = (
184
189
  flush()
185
190
  })
186
191
  socket.addEventListener('message', (event: MessageEvent<unknown>) => {
187
- const message = decodeServerMessage(event.data)
188
- if (handlers.size === 0) {
189
- state.inbox = [...state.inbox, message]
190
- return
191
- }
192
- handlers.forEach((handler) => handler(message))
192
+ Option.match(decodeServerMessage(event.data), {
193
+ onNone: () =>
194
+ Effect.runSync(
195
+ Effect.logWarning('reform: dropped a server frame this client cannot parse'),
196
+ ),
197
+ onSome: (message) => {
198
+ if (handlers.size === 0) {
199
+ state.inbox = [...state.inbox, message]
200
+ return
201
+ }
202
+ handlers.forEach((handler) => handler(message))
203
+ },
204
+ })
193
205
  })
194
206
  socket.addEventListener('close', () => handleDrop(socket))
195
207
  socket.addEventListener('error', () => handleDrop(socket))