@flighthq/socket 0.1.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.
@@ -0,0 +1,2 @@
1
+ export * from './socket';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from './socket';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC"}
@@ -0,0 +1,13 @@
1
+ import type { Socket, SocketBackend, SocketOptions, SocketReadyState, SocketSignals } from '@flighthq/types';
2
+ export declare function attachSocket(socket: Socket): void;
3
+ export declare function closeSocket(socket: Socket, code?: number, reason?: string): void;
4
+ export declare function createSocket(options: Readonly<SocketOptions>): Socket;
5
+ export declare function createWebSocketBackend(): SocketBackend;
6
+ export declare function detachSocket(socket: Socket): void;
7
+ export declare function disposeSocket(socket: Socket): void;
8
+ export declare function enableSocketSignals(socket: Socket): SocketSignals;
9
+ export declare function getSocketBackend(): SocketBackend;
10
+ export declare function getSocketReadyState(socket: Readonly<Socket>): SocketReadyState;
11
+ export declare function sendSocketMessage(socket: Readonly<Socket>, data: string | ArrayBuffer): boolean;
12
+ export declare function setSocketBackend(backend: SocketBackend | null): void;
13
+ //# sourceMappingURL=socket.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"socket.d.ts","sourceRoot":"","sources":["../src/socket.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,MAAM,EACN,aAAa,EAKb,aAAa,EACb,gBAAgB,EAEhB,aAAa,EACd,MAAM,iBAAiB,CAAC;AAIzB,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAEjD;AAKD,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAKhF;AAOD,wBAAgB,YAAY,CAAC,OAAO,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,MAAM,CAUrE;AAMD,wBAAgB,sBAAsB,IAAI,aAAa,CA0BtD;AAID,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAEjD;AAKD,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAIlD;AAKD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,CAWjE;AAGD,wBAAgB,gBAAgB,IAAI,aAAa,CAGhD;AAGD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,gBAAgB,CAE9E;AAID,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,OAAO,CAI/F;AAGD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,aAAa,GAAG,IAAI,GAAG,IAAI,CAEpE"}
package/dist/socket.js ADDED
@@ -0,0 +1,155 @@
1
+ import { createSignal, emitSignal } from '@flighthq/signals';
2
+ // Resumes delivery of backend events to the socket's signals (idempotent). Pair with detachSocket.
3
+ // createSocket leaves a new socket attached, so this is only needed to resume after detachSocket.
4
+ export function attachSocket(socket) {
5
+ socket.runtime.delivering = true;
6
+ }
7
+ // Begins a clean close of the live connection. Transitions readyState connecting/open → 'closing';
8
+ // the backend's close event later transitions it to 'closed'. A no-op when already closing or closed.
9
+ // This is the connection command, distinct from disposeSocket (which releases the entity to GC).
10
+ export function closeSocket(socket, code, reason) {
11
+ const runtime = socket.runtime;
12
+ if (runtime.readyState === 'closing' || runtime.readyState === 'closed')
13
+ return;
14
+ runtime.readyState = 'closing';
15
+ runtime.connection?.closeSocketConnection(code, reason);
16
+ }
17
+ // Allocates a Socket entity plus its runtime and opens the connection through the active backend,
18
+ // wiring the backend's open/message/close/error into the socket's (still inert) signal group. The
19
+ // socket starts in 'connecting' and is left attached (delivering). Enable signals with
20
+ // enableSocketSignals to observe events. A backend that does not support the transport yields a null
21
+ // connection and the socket stays in 'connecting' until closed.
22
+ export function createSocket(options) {
23
+ const runtime = {
24
+ connection: null,
25
+ signals: null,
26
+ readyState: 'connecting',
27
+ delivering: true,
28
+ };
29
+ const socket = { url: options.url, runtime };
30
+ runtime.connection = getSocketBackend().openSocket(options, makeSocketEventSink(runtime));
31
+ return socket;
32
+ }
33
+ // Builds the default web backend over the DOM WebSocket. Created lazily by getSocketBackend — no
34
+ // WebSocket is constructed at import time, so importing the package has no side effect. Returns a
35
+ // null connection when WebSocket is unavailable (non-browser host) rather than throwing; raw TCP/UDP
36
+ // is likewise unsupported here and only reachable through a native backend.
37
+ export function createWebSocketBackend() {
38
+ return {
39
+ openSocket(options, events) {
40
+ if (typeof WebSocket === 'undefined')
41
+ return null;
42
+ const ws = options.protocols !== undefined
43
+ ? new WebSocket(options.url, options.protocols)
44
+ : new WebSocket(options.url);
45
+ ws.binaryType = options.binaryType ?? 'arraybuffer';
46
+ ws.onopen = () => events.handleSocketOpen();
47
+ ws.onmessage = (event) => events.handleSocketMessage(toSocketMessage(event.data));
48
+ ws.onclose = (event) => events.handleSocketClose({ code: event.code, reason: event.reason, wasClean: event.wasClean });
49
+ ws.onerror = () => events.handleSocketError();
50
+ return {
51
+ sendSocketFrame(data) {
52
+ if (ws.readyState !== WebSocket.OPEN)
53
+ return false;
54
+ ws.send(data);
55
+ return true;
56
+ },
57
+ closeSocketConnection(code, reason) {
58
+ ws.close(code, reason);
59
+ },
60
+ };
61
+ },
62
+ };
63
+ }
64
+ // Stops delivery of backend events to the socket's signals. The live connection is untouched — use
65
+ // closeSocket to close it. Safe to call repeatedly; resume with attachSocket.
66
+ export function detachSocket(socket) {
67
+ socket.runtime.delivering = false;
68
+ }
69
+ // Releases the socket to garbage collection: closes the live connection if still open, stops event
70
+ // delivery, and drops the signal group. Distinct from closeSocket — dispose is entity teardown, close
71
+ // is the connection command. After dispose the socket is inert and should not be reused.
72
+ export function disposeSocket(socket) {
73
+ closeSocket(socket);
74
+ detachSocket(socket);
75
+ socket.runtime.signals = null;
76
+ }
77
+ // Opts the socket into its typed event signals, allocating the group on first call and returning it
78
+ // (idempotent — a later call returns the same group). A bare socket that never calls this keeps
79
+ // runtime.signals null and pays no signal allocation or dispatch cost.
80
+ export function enableSocketSignals(socket) {
81
+ const runtime = socket.runtime;
82
+ if (runtime.signals === null) {
83
+ runtime.signals = {
84
+ onSocketOpen: createSignal(),
85
+ onSocketMessage: createSignal(),
86
+ onSocketClose: createSignal(),
87
+ onSocketError: createSignal(),
88
+ };
89
+ }
90
+ return runtime.signals;
91
+ }
92
+ // The active socket backend, or a lazily-created web default. There is always a backend.
93
+ export function getSocketBackend() {
94
+ if (_backend === null)
95
+ _backend = createWebSocketBackend();
96
+ return _backend;
97
+ }
98
+ // The socket's current connection phase, tracked on the runtime from backend events and closeSocket.
99
+ export function getSocketReadyState(socket) {
100
+ return socket.runtime.readyState;
101
+ }
102
+ // Sends a text or binary frame over the live connection. Returns false — a sentinel, not a throw —
103
+ // when the socket is not open or has no connection.
104
+ export function sendSocketMessage(socket, data) {
105
+ const runtime = socket.runtime;
106
+ if (runtime.readyState !== 'open' || runtime.connection === null)
107
+ return false;
108
+ return runtime.connection.sendSocketFrame(data);
109
+ }
110
+ // Installs a native host socket backend (adding TCP/UDP); pass null to fall back to the web default.
111
+ export function setSocketBackend(backend) {
112
+ _backend = backend;
113
+ }
114
+ let _backend = null;
115
+ // Builds the backend→entity sink bound to one socket's runtime: it updates readyState and emits the
116
+ // opt-in signals. Every handler is a no-op once the runtime stops delivering (detach/dispose), so a
117
+ // late backend event after teardown fires nothing.
118
+ function makeSocketEventSink(runtime) {
119
+ return {
120
+ handleSocketOpen() {
121
+ if (!runtime.delivering)
122
+ return;
123
+ runtime.readyState = 'open';
124
+ if (runtime.signals !== null)
125
+ emitSignal(runtime.signals.onSocketOpen);
126
+ },
127
+ handleSocketMessage(message) {
128
+ if (!runtime.delivering)
129
+ return;
130
+ if (runtime.signals !== null)
131
+ emitSignal(runtime.signals.onSocketMessage, message);
132
+ },
133
+ handleSocketClose(info) {
134
+ if (!runtime.delivering)
135
+ return;
136
+ runtime.readyState = 'closed';
137
+ if (runtime.signals !== null)
138
+ emitSignal(runtime.signals.onSocketClose, info);
139
+ },
140
+ handleSocketError() {
141
+ if (!runtime.delivering)
142
+ return;
143
+ if (runtime.signals !== null)
144
+ emitSignal(runtime.signals.onSocketError);
145
+ },
146
+ };
147
+ }
148
+ // Maps a raw WebSocket message payload onto a SocketMessage. A string is a text frame; anything else
149
+ // (with binaryType 'arraybuffer', an ArrayBuffer) is a binary frame.
150
+ function toSocketMessage(data) {
151
+ if (typeof data === 'string')
152
+ return { data, binary: false };
153
+ return { data: data, binary: true };
154
+ }
155
+ //# sourceMappingURL=socket.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"socket.js","sourceRoot":"","sources":["../src/socket.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAc7D,mGAAmG;AACnG,kGAAkG;AAClG,MAAM,UAAU,YAAY,CAAC,MAAc;IACzC,MAAM,CAAC,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;AACnC,CAAC;AAED,mGAAmG;AACnG,sGAAsG;AACtG,iGAAiG;AACjG,MAAM,UAAU,WAAW,CAAC,MAAc,EAAE,IAAa,EAAE,MAAe;IACxE,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;IAC/B,IAAI,OAAO,CAAC,UAAU,KAAK,SAAS,IAAI,OAAO,CAAC,UAAU,KAAK,QAAQ;QAAE,OAAO;IAChF,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC;IAC/B,OAAO,CAAC,UAAU,EAAE,qBAAqB,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AAC1D,CAAC;AAED,kGAAkG;AAClG,kGAAkG;AAClG,uFAAuF;AACvF,qGAAqG;AACrG,gEAAgE;AAChE,MAAM,UAAU,YAAY,CAAC,OAAgC;IAC3D,MAAM,OAAO,GAAkB;QAC7B,UAAU,EAAE,IAAI;QAChB,OAAO,EAAE,IAAI;QACb,UAAU,EAAE,YAAY;QACxB,UAAU,EAAE,IAAI;KACjB,CAAC;IACF,MAAM,MAAM,GAAW,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,OAAO,EAAE,CAAC;IACrD,OAAO,CAAC,UAAU,GAAG,gBAAgB,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,mBAAmB,CAAC,OAAO,CAAC,CAAC,CAAC;IAC1F,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,iGAAiG;AACjG,kGAAkG;AAClG,qGAAqG;AACrG,4EAA4E;AAC5E,MAAM,UAAU,sBAAsB;IACpC,OAAO;QACL,UAAU,CAAC,OAAO,EAAE,MAAM;YACxB,IAAI,OAAO,SAAS,KAAK,WAAW;gBAAE,OAAO,IAAI,CAAC;YAClD,MAAM,EAAE,GACN,OAAO,CAAC,SAAS,KAAK,SAAS;gBAC7B,CAAC,CAAC,IAAI,SAAS,CAAC,OAAO,CAAC,GAAG,EAAE,OAAO,CAAC,SAAqB,CAAC;gBAC3D,CAAC,CAAC,IAAI,SAAS,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YACjC,EAAE,CAAC,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,aAAa,CAAC;YACpD,EAAE,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC,gBAAgB,EAAE,CAAC;YAC5C,EAAE,CAAC,SAAS,GAAG,CAAC,KAAmB,EAAE,EAAE,CAAC,MAAM,CAAC,mBAAmB,CAAC,eAAe,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;YAChG,EAAE,CAAC,OAAO,GAAG,CAAC,KAAiB,EAAE,EAAE,CACjC,MAAM,CAAC,iBAAiB,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC;YACjG,EAAE,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC,iBAAiB,EAAE,CAAC;YAC9C,OAAO;gBACL,eAAe,CAAC,IAAI;oBAClB,IAAI,EAAE,CAAC,UAAU,KAAK,SAAS,CAAC,IAAI;wBAAE,OAAO,KAAK,CAAC;oBACnD,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;oBACd,OAAO,IAAI,CAAC;gBACd,CAAC;gBACD,qBAAqB,CAAC,IAAI,EAAE,MAAM;oBAChC,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;gBACzB,CAAC;aACF,CAAC;QACJ,CAAC;KACF,CAAC;AACJ,CAAC;AAED,mGAAmG;AACnG,8EAA8E;AAC9E,MAAM,UAAU,YAAY,CAAC,MAAc;IACzC,MAAM,CAAC,OAAO,CAAC,UAAU,GAAG,KAAK,CAAC;AACpC,CAAC;AAED,mGAAmG;AACnG,sGAAsG;AACtG,yFAAyF;AACzF,MAAM,UAAU,aAAa,CAAC,MAAc;IAC1C,WAAW,CAAC,MAAM,CAAC,CAAC;IACpB,YAAY,CAAC,MAAM,CAAC,CAAC;IACrB,MAAM,CAAC,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;AAChC,CAAC;AAED,oGAAoG;AACpG,gGAAgG;AAChG,uEAAuE;AACvE,MAAM,UAAU,mBAAmB,CAAC,MAAc;IAChD,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;IAC/B,IAAI,OAAO,CAAC,OAAO,KAAK,IAAI,EAAE,CAAC;QAC7B,OAAO,CAAC,OAAO,GAAG;YAChB,YAAY,EAAE,YAAY,EAAc;YACxC,eAAe,EAAE,YAAY,EAA8C;YAC3E,aAAa,EAAE,YAAY,EAA6C;YACxE,aAAa,EAAE,YAAY,EAAc;SAC1C,CAAC;IACJ,CAAC;IACD,OAAO,OAAO,CAAC,OAAO,CAAC;AACzB,CAAC;AAED,yFAAyF;AACzF,MAAM,UAAU,gBAAgB;IAC9B,IAAI,QAAQ,KAAK,IAAI;QAAE,QAAQ,GAAG,sBAAsB,EAAE,CAAC;IAC3D,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,qGAAqG;AACrG,MAAM,UAAU,mBAAmB,CAAC,MAAwB;IAC1D,OAAO,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC;AACnC,CAAC;AAED,mGAAmG;AACnG,oDAAoD;AACpD,MAAM,UAAU,iBAAiB,CAAC,MAAwB,EAAE,IAA0B;IACpF,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;IAC/B,IAAI,OAAO,CAAC,UAAU,KAAK,MAAM,IAAI,OAAO,CAAC,UAAU,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAC/E,OAAO,OAAO,CAAC,UAAU,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;AAClD,CAAC;AAED,qGAAqG;AACrG,MAAM,UAAU,gBAAgB,CAAC,OAA6B;IAC5D,QAAQ,GAAG,OAAO,CAAC;AACrB,CAAC;AAED,IAAI,QAAQ,GAAyB,IAAI,CAAC;AAE1C,oGAAoG;AACpG,oGAAoG;AACpG,mDAAmD;AACnD,SAAS,mBAAmB,CAAC,OAAsB;IACjD,OAAO;QACL,gBAAgB;YACd,IAAI,CAAC,OAAO,CAAC,UAAU;gBAAE,OAAO;YAChC,OAAO,CAAC,UAAU,GAAG,MAAM,CAAC;YAC5B,IAAI,OAAO,CAAC,OAAO,KAAK,IAAI;gBAAE,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;QACzE,CAAC;QACD,mBAAmB,CAAC,OAAO;YACzB,IAAI,CAAC,OAAO,CAAC,UAAU;gBAAE,OAAO;YAChC,IAAI,OAAO,CAAC,OAAO,KAAK,IAAI;gBAAE,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,eAAe,EAAE,OAAO,CAAC,CAAC;QACrF,CAAC;QACD,iBAAiB,CAAC,IAAI;YACpB,IAAI,CAAC,OAAO,CAAC,UAAU;gBAAE,OAAO;YAChC,OAAO,CAAC,UAAU,GAAG,QAAQ,CAAC;YAC9B,IAAI,OAAO,CAAC,OAAO,KAAK,IAAI;gBAAE,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,aAAa,EAAE,IAAI,CAAC,CAAC;QAChF,CAAC;QACD,iBAAiB;YACf,IAAI,CAAC,OAAO,CAAC,UAAU;gBAAE,OAAO;YAChC,IAAI,OAAO,CAAC,OAAO,KAAK,IAAI;gBAAE,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC;QAC1E,CAAC;KACF,CAAC;AACJ,CAAC;AAED,qGAAqG;AACrG,qEAAqE;AACrE,SAAS,eAAe,CAAC,IAAa;IACpC,IAAI,OAAO,IAAI,KAAK,QAAQ;QAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IAC7D,OAAO,EAAE,IAAI,EAAE,IAAmB,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;AACrD,CAAC"}
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@flighthq/socket",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "src/**/*.test.ts",
16
+ "!dist/**/*.test.js",
17
+ "!dist/**/*.test.d.ts",
18
+ "!dist/**/*.test.js.map",
19
+ "!dist/**/*.test.d.ts.map"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -b",
23
+ "clean": "tsc -b --clean",
24
+ "test": "vitest run --config vitest.config.ts",
25
+ "test:watch": "vitest --watch --config vitest.config.ts",
26
+ "prepack": "npm run clean && npm run clean:dist && npm run build",
27
+ "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
28
+ },
29
+ "dependencies": {
30
+ "@flighthq/signals": "0.1.0",
31
+ "@flighthq/types": "0.1.0"
32
+ },
33
+ "devDependencies": {
34
+ "typescript": "^5.3.0"
35
+ },
36
+ "description": "Bidirectional persistent-connection transport (Socket/WebSocket) over a swappable web/native backend (WebSocket by default)",
37
+ "sideEffects": false
38
+ }
@@ -0,0 +1,443 @@
1
+ import { connectSignal } from '@flighthq/signals';
2
+ import type { SocketBackend, SocketCloseInfo, SocketConnection, SocketEventSink, SocketMessage } from '@flighthq/types';
3
+
4
+ import {
5
+ attachSocket,
6
+ closeSocket,
7
+ createSocket,
8
+ createWebSocketBackend,
9
+ detachSocket,
10
+ disposeSocket,
11
+ enableSocketSignals,
12
+ getSocketBackend,
13
+ getSocketReadyState,
14
+ sendSocketMessage,
15
+ setSocketBackend,
16
+ } from './socket';
17
+
18
+ interface FakeSocket {
19
+ backend: SocketBackend;
20
+ sink: SocketEventSink;
21
+ sent: (string | ArrayBuffer)[];
22
+ closes: { code?: number; reason?: string }[];
23
+ openReturnsNull: boolean;
24
+ sendReturns: boolean;
25
+ lastOptions: { url: string; protocols?: readonly string[]; binaryType?: string } | null;
26
+ }
27
+
28
+ // A mock SocketBackend that records the sink handed to openSocket (so a test can drive
29
+ // open/message/close/error) and captures every send/close. openSocket can be made to return a null
30
+ // connection to exercise the unsupported-transport path.
31
+ function fakeBackend(): FakeSocket {
32
+ const state: FakeSocket = {
33
+ sent: [],
34
+ closes: [],
35
+ openReturnsNull: false,
36
+ sendReturns: true,
37
+ lastOptions: null,
38
+ sink: null as unknown as SocketEventSink,
39
+ backend: null as unknown as SocketBackend,
40
+ };
41
+ state.backend = {
42
+ openSocket(options, events): SocketConnection | null {
43
+ state.sink = events;
44
+ state.lastOptions = { url: options.url, protocols: options.protocols, binaryType: options.binaryType };
45
+ if (state.openReturnsNull) return null;
46
+ return {
47
+ sendSocketFrame(data): boolean {
48
+ state.sent.push(data);
49
+ return state.sendReturns;
50
+ },
51
+ closeSocketConnection(code, reason): void {
52
+ state.closes.push({ code, reason });
53
+ },
54
+ };
55
+ },
56
+ };
57
+ return state;
58
+ }
59
+
60
+ afterEach(() => setSocketBackend(null));
61
+
62
+ describe('attachSocket', () => {
63
+ it('resumes delivery after a detach', () => {
64
+ const fake = fakeBackend();
65
+ setSocketBackend(fake.backend);
66
+ const socket = createSocket({ url: 'ws://x' });
67
+ const signals = enableSocketSignals(socket);
68
+ let opens = 0;
69
+ connectSignal(signals.onSocketOpen, () => opens++);
70
+ detachSocket(socket);
71
+ attachSocket(socket);
72
+ fake.sink.handleSocketOpen();
73
+ expect(opens).toBe(1);
74
+ });
75
+ });
76
+
77
+ describe('closeSocket', () => {
78
+ it('transitions to closing and forwards code/reason to the connection', () => {
79
+ const fake = fakeBackend();
80
+ setSocketBackend(fake.backend);
81
+ const socket = createSocket({ url: 'ws://x' });
82
+ fake.sink.handleSocketOpen();
83
+ closeSocket(socket, 1000, 'bye');
84
+ expect(getSocketReadyState(socket)).toBe('closing');
85
+ expect(fake.closes).toEqual([{ code: 1000, reason: 'bye' }]);
86
+ });
87
+
88
+ it('reaches closed once the backend close event arrives', () => {
89
+ const fake = fakeBackend();
90
+ setSocketBackend(fake.backend);
91
+ const socket = createSocket({ url: 'ws://x' });
92
+ fake.sink.handleSocketOpen();
93
+ closeSocket(socket);
94
+ fake.sink.handleSocketClose({ code: 1000, reason: '', wasClean: true });
95
+ expect(getSocketReadyState(socket)).toBe('closed');
96
+ });
97
+
98
+ it('is a no-op when already closed', () => {
99
+ const fake = fakeBackend();
100
+ setSocketBackend(fake.backend);
101
+ const socket = createSocket({ url: 'ws://x' });
102
+ fake.sink.handleSocketOpen();
103
+ closeSocket(socket);
104
+ fake.sink.handleSocketClose({ code: 1000, reason: '', wasClean: true });
105
+ closeSocket(socket);
106
+ expect(fake.closes).toHaveLength(1);
107
+ });
108
+ });
109
+
110
+ describe('createSocket', () => {
111
+ it('opens through the backend in the connecting state and records the url', () => {
112
+ const fake = fakeBackend();
113
+ setSocketBackend(fake.backend);
114
+ const socket = createSocket({ url: 'ws://host/path' });
115
+ expect(socket.url).toBe('ws://host/path');
116
+ expect(getSocketReadyState(socket)).toBe('connecting');
117
+ expect(fake.lastOptions?.url).toBe('ws://host/path');
118
+ });
119
+
120
+ it('passes protocols and binaryType through to the backend', () => {
121
+ const fake = fakeBackend();
122
+ setSocketBackend(fake.backend);
123
+ createSocket({ url: 'ws://x', protocols: ['a', 'b'], binaryType: 'arraybuffer' });
124
+ expect(fake.lastOptions?.protocols).toEqual(['a', 'b']);
125
+ expect(fake.lastOptions?.binaryType).toBe('arraybuffer');
126
+ });
127
+
128
+ it('tolerates a null connection from an unsupported transport', () => {
129
+ const fake = fakeBackend();
130
+ fake.openReturnsNull = true;
131
+ setSocketBackend(fake.backend);
132
+ const socket = createSocket({ url: 'tcp://x' });
133
+ expect(getSocketReadyState(socket)).toBe('connecting');
134
+ expect(sendSocketMessage(socket, 'x')).toBe(false);
135
+ });
136
+
137
+ it('emits a text message with binary false and a binary message with binary true', () => {
138
+ const fake = fakeBackend();
139
+ setSocketBackend(fake.backend);
140
+ const socket = createSocket({ url: 'ws://x' });
141
+ const signals = enableSocketSignals(socket);
142
+ const received: SocketMessage[] = [];
143
+ connectSignal(signals.onSocketMessage, (m) => received.push(m));
144
+ fake.sink.handleSocketOpen();
145
+ fake.sink.handleSocketMessage({ data: 'hi', binary: false });
146
+ const buffer = new Uint8Array([1, 2]).buffer;
147
+ fake.sink.handleSocketMessage({ data: buffer, binary: true });
148
+ expect(received).toEqual([
149
+ { data: 'hi', binary: false },
150
+ { data: buffer, binary: true },
151
+ ]);
152
+ });
153
+
154
+ it('emits close info with code, reason, and wasClean', () => {
155
+ const fake = fakeBackend();
156
+ setSocketBackend(fake.backend);
157
+ const socket = createSocket({ url: 'ws://x' });
158
+ const signals = enableSocketSignals(socket);
159
+ const infos: SocketCloseInfo[] = [];
160
+ connectSignal(signals.onSocketClose, (i) => infos.push(i));
161
+ fake.sink.handleSocketClose({ code: 1006, reason: 'gone', wasClean: false });
162
+ expect(infos).toEqual([{ code: 1006, reason: 'gone', wasClean: false }]);
163
+ });
164
+
165
+ it('emits onSocketError', () => {
166
+ const fake = fakeBackend();
167
+ setSocketBackend(fake.backend);
168
+ const socket = createSocket({ url: 'ws://x' });
169
+ const signals = enableSocketSignals(socket);
170
+ let errors = 0;
171
+ connectSignal(signals.onSocketError, () => errors++);
172
+ fake.sink.handleSocketError();
173
+ expect(errors).toBe(1);
174
+ });
175
+ });
176
+
177
+ describe('createWebSocketBackend', () => {
178
+ it('constructs a WebSocket with url and protocols and sets binaryType', () => {
179
+ const restore = installFakeWebSocket();
180
+ try {
181
+ createWebSocketBackend().openSocket(
182
+ { url: 'ws://host', protocols: ['chat'], binaryType: 'arraybuffer' },
183
+ noopSink(),
184
+ );
185
+ const ws = FakeWebSocket.last!;
186
+ expect(ws.url).toBe('ws://host');
187
+ expect(ws.protocols).toEqual(['chat']);
188
+ expect(ws.binaryType).toBe('arraybuffer');
189
+ } finally {
190
+ restore();
191
+ }
192
+ });
193
+
194
+ it('translates an incoming string message to binary false', () => {
195
+ const restore = installFakeWebSocket();
196
+ try {
197
+ const received: SocketMessage[] = [];
198
+ createWebSocketBackend().openSocket({ url: 'ws://x' }, sinkCollecting(received));
199
+ FakeWebSocket.last!.onmessage!({ data: 'hello' } as MessageEvent);
200
+ expect(received).toEqual([{ data: 'hello', binary: false }]);
201
+ } finally {
202
+ restore();
203
+ }
204
+ });
205
+
206
+ it('translates an incoming ArrayBuffer message to binary true', () => {
207
+ const restore = installFakeWebSocket();
208
+ try {
209
+ const received: SocketMessage[] = [];
210
+ createWebSocketBackend().openSocket({ url: 'ws://x' }, sinkCollecting(received));
211
+ const buffer = new Uint8Array([9]).buffer;
212
+ FakeWebSocket.last!.onmessage!({ data: buffer } as MessageEvent);
213
+ expect(received[0]).toEqual({ data: buffer, binary: true });
214
+ } finally {
215
+ restore();
216
+ }
217
+ });
218
+
219
+ it('maps close events and open into the sink', () => {
220
+ const restore = installFakeWebSocket();
221
+ try {
222
+ let opened = false;
223
+ const closes: SocketCloseInfo[] = [];
224
+ const sink: SocketEventSink = {
225
+ ...noopSink(),
226
+ handleSocketOpen: () => (opened = true),
227
+ handleSocketClose: (i) => closes.push(i),
228
+ };
229
+ createWebSocketBackend().openSocket({ url: 'ws://x' }, sink);
230
+ const ws = FakeWebSocket.last!;
231
+ ws.onopen!(new Event('open'));
232
+ ws.onclose!({ code: 1000, reason: 'done', wasClean: true } as CloseEvent);
233
+ expect(opened).toBe(true);
234
+ expect(closes).toEqual([{ code: 1000, reason: 'done', wasClean: true }]);
235
+ } finally {
236
+ restore();
237
+ }
238
+ });
239
+
240
+ it('sends only when the WebSocket is OPEN and closes with code/reason', () => {
241
+ const restore = installFakeWebSocket();
242
+ try {
243
+ const connection = createWebSocketBackend().openSocket({ url: 'ws://x' }, noopSink())!;
244
+ const ws = FakeWebSocket.last!;
245
+ ws.readyState = FakeWebSocket.CONNECTING;
246
+ expect(connection.sendSocketFrame('x')).toBe(false);
247
+ ws.readyState = FakeWebSocket.OPEN;
248
+ expect(connection.sendSocketFrame('y')).toBe(true);
249
+ expect(ws.sent).toEqual(['y']);
250
+ connection.closeSocketConnection(1001, 'later');
251
+ expect(ws.closed).toEqual({ code: 1001, reason: 'later' });
252
+ } finally {
253
+ restore();
254
+ }
255
+ });
256
+
257
+ it('returns a null connection when WebSocket is unavailable', () => {
258
+ const original = (globalThis as { WebSocket?: unknown }).WebSocket;
259
+ (globalThis as { WebSocket?: unknown }).WebSocket = undefined;
260
+ try {
261
+ expect(createWebSocketBackend().openSocket({ url: 'ws://x' }, noopSink())).toBeNull();
262
+ } finally {
263
+ (globalThis as { WebSocket?: unknown }).WebSocket = original;
264
+ }
265
+ });
266
+ });
267
+
268
+ describe('detachSocket', () => {
269
+ it('stops backend events from reaching the signals', () => {
270
+ const fake = fakeBackend();
271
+ setSocketBackend(fake.backend);
272
+ const socket = createSocket({ url: 'ws://x' });
273
+ const signals = enableSocketSignals(socket);
274
+ let opens = 0;
275
+ connectSignal(signals.onSocketOpen, () => opens++);
276
+ detachSocket(socket);
277
+ fake.sink.handleSocketOpen();
278
+ expect(opens).toBe(0);
279
+ });
280
+ });
281
+
282
+ describe('disposeSocket', () => {
283
+ it('closes an open connection and detaches so later events fire no signal', () => {
284
+ const fake = fakeBackend();
285
+ setSocketBackend(fake.backend);
286
+ const socket = createSocket({ url: 'ws://x' });
287
+ const signals = enableSocketSignals(socket);
288
+ let messages = 0;
289
+ connectSignal(signals.onSocketMessage, () => messages++);
290
+ fake.sink.handleSocketOpen();
291
+ disposeSocket(socket);
292
+ expect(fake.closes).toHaveLength(1);
293
+ fake.sink.handleSocketMessage({ data: 'x', binary: false });
294
+ expect(messages).toBe(0);
295
+ });
296
+
297
+ it('is safe to call on a fresh socket', () => {
298
+ const fake = fakeBackend();
299
+ setSocketBackend(fake.backend);
300
+ const socket = createSocket({ url: 'ws://x' });
301
+ expect(() => disposeSocket(socket)).not.toThrow();
302
+ });
303
+ });
304
+
305
+ describe('enableSocketSignals', () => {
306
+ it('returns the same group on repeated calls', () => {
307
+ const fake = fakeBackend();
308
+ setSocketBackend(fake.backend);
309
+ const socket = createSocket({ url: 'ws://x' });
310
+ expect(enableSocketSignals(socket)).toBe(enableSocketSignals(socket));
311
+ });
312
+
313
+ it('leaves a bare socket without signals', () => {
314
+ const fake = fakeBackend();
315
+ setSocketBackend(fake.backend);
316
+ const socket = createSocket({ url: 'ws://x' });
317
+ expect(socket.runtime.signals).toBeNull();
318
+ });
319
+ });
320
+
321
+ describe('getSocketBackend', () => {
322
+ it('lazily returns a web backend by default', () => {
323
+ expect(typeof getSocketBackend().openSocket).toBe('function');
324
+ });
325
+
326
+ it('returns the installed backend', () => {
327
+ const fake = fakeBackend();
328
+ setSocketBackend(fake.backend);
329
+ expect(getSocketBackend()).toBe(fake.backend);
330
+ });
331
+ });
332
+
333
+ describe('getSocketReadyState', () => {
334
+ it('reflects connecting → open → closing → closed transitions', () => {
335
+ const fake = fakeBackend();
336
+ setSocketBackend(fake.backend);
337
+ const socket = createSocket({ url: 'ws://x' });
338
+ expect(getSocketReadyState(socket)).toBe('connecting');
339
+ fake.sink.handleSocketOpen();
340
+ expect(getSocketReadyState(socket)).toBe('open');
341
+ closeSocket(socket);
342
+ expect(getSocketReadyState(socket)).toBe('closing');
343
+ fake.sink.handleSocketClose({ code: 1000, reason: '', wasClean: true });
344
+ expect(getSocketReadyState(socket)).toBe('closed');
345
+ });
346
+ });
347
+
348
+ describe('sendSocketMessage', () => {
349
+ it('sends through the connection and returns true when open', () => {
350
+ const fake = fakeBackend();
351
+ setSocketBackend(fake.backend);
352
+ const socket = createSocket({ url: 'ws://x' });
353
+ fake.sink.handleSocketOpen();
354
+ expect(sendSocketMessage(socket, 'ping')).toBe(true);
355
+ expect(fake.sent).toEqual(['ping']);
356
+ });
357
+
358
+ it('returns false without throwing when not open', () => {
359
+ const fake = fakeBackend();
360
+ setSocketBackend(fake.backend);
361
+ const socket = createSocket({ url: 'ws://x' });
362
+ expect(sendSocketMessage(socket, 'ping')).toBe(false);
363
+ expect(fake.sent).toEqual([]);
364
+ });
365
+
366
+ it('propagates a false send result from the connection', () => {
367
+ const fake = fakeBackend();
368
+ fake.sendReturns = false;
369
+ setSocketBackend(fake.backend);
370
+ const socket = createSocket({ url: 'ws://x' });
371
+ fake.sink.handleSocketOpen();
372
+ expect(sendSocketMessage(socket, 'ping')).toBe(false);
373
+ });
374
+ });
375
+
376
+ describe('setSocketBackend', () => {
377
+ it('restores the lazy web default when passed null', () => {
378
+ const fake = fakeBackend();
379
+ setSocketBackend(fake.backend);
380
+ expect(getSocketBackend()).toBe(fake.backend);
381
+ setSocketBackend(null);
382
+ const web = getSocketBackend();
383
+ expect(web).not.toBe(fake.backend);
384
+ expect(typeof web.openSocket).toBe('function');
385
+ });
386
+ });
387
+
388
+ function noopSink(): SocketEventSink {
389
+ return {
390
+ handleSocketOpen() {},
391
+ handleSocketMessage() {},
392
+ handleSocketClose() {},
393
+ handleSocketError() {},
394
+ };
395
+ }
396
+
397
+ function sinkCollecting(received: SocketMessage[]): SocketEventSink {
398
+ return { ...noopSink(), handleSocketMessage: (m) => received.push(m) };
399
+ }
400
+
401
+ // A minimal stand-in for the DOM WebSocket, recording constructor args, sends, and close, and
402
+ // exposing dispatchable onopen/onmessage/onclose/onerror handlers.
403
+ class FakeWebSocket {
404
+ static CONNECTING = 0;
405
+ static OPEN = 1;
406
+ static CLOSING = 2;
407
+ static CLOSED = 3;
408
+ static last: FakeWebSocket | null = null;
409
+
410
+ url: string;
411
+ protocols?: readonly string[];
412
+ binaryType = 'blob';
413
+ readyState = FakeWebSocket.CONNECTING;
414
+ sent: (string | ArrayBuffer)[] = [];
415
+ closed: { code?: number; reason?: string } | null = null;
416
+ onopen: ((event: Event) => void) | null = null;
417
+ onmessage: ((event: MessageEvent) => void) | null = null;
418
+ onclose: ((event: CloseEvent) => void) | null = null;
419
+ onerror: ((event: Event) => void) | null = null;
420
+
421
+ constructor(url: string, protocols?: string | string[]) {
422
+ this.url = url;
423
+ if (protocols !== undefined) this.protocols = typeof protocols === 'string' ? [protocols] : protocols;
424
+ FakeWebSocket.last = this;
425
+ }
426
+
427
+ send(data: string | ArrayBuffer): void {
428
+ this.sent.push(data);
429
+ }
430
+
431
+ close(code?: number, reason?: string): void {
432
+ this.closed = { code, reason };
433
+ }
434
+ }
435
+
436
+ function installFakeWebSocket(): () => void {
437
+ const original = (globalThis as { WebSocket?: unknown }).WebSocket;
438
+ FakeWebSocket.last = null;
439
+ (globalThis as { WebSocket?: unknown }).WebSocket = FakeWebSocket;
440
+ return () => {
441
+ (globalThis as { WebSocket?: unknown }).WebSocket = original;
442
+ };
443
+ }