@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.
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +5 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/transport.d.ts +20 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +166 -0
- package/dist/transport.js.map +1 -0
- package/package.json +17 -5
- package/src/transport-malformed-frame.test.ts +130 -0
- package/src/transport.ts +20 -8
package/dist/errors.d.ts
ADDED
|
@@ -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 @@
|
|
|
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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
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
|
|
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
|
-
".":
|
|
30
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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))
|