@gjsify/ws 0.4.0 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +50 -47
- package/src/constants.ts +0 -15
- package/src/index.spec.ts +0 -144
- package/src/index.ts +0 -25
- package/src/stream.spec.ts +0 -121
- package/src/stream.ts +0 -107
- package/src/test.mts +0 -5
- package/src/websocket-server.spec.ts +0 -354
- package/src/websocket-server.ts +0 -561
- package/src/websocket.ts +0 -391
- package/tsconfig.json +0 -29
- package/tsconfig.tsbuildinfo +0 -1
package/src/websocket-server.ts
DELETED
|
@@ -1,561 +0,0 @@
|
|
|
1
|
-
// WebSocketServer — `ws.WebSocketServer` compatible surface.
|
|
2
|
-
//
|
|
3
|
-
// Reference: refs/ws/lib/websocket-server.js
|
|
4
|
-
//
|
|
5
|
-
// Supported:
|
|
6
|
-
// - `new WebSocketServer({ port, host })` → standalone Soup.Server
|
|
7
|
-
// - `new WebSocketServer({ server: httpServer })` → attach to existing @gjsify/http Server
|
|
8
|
-
// - `new WebSocketServer({ noServer: true })` → caller calls handleUpgrade() manually
|
|
9
|
-
// - `verifyClient(info)` / `verifyClient(info, cb)` — sync + async access control
|
|
10
|
-
// Mechanism (Soup path): add_handler registered BEFORE add_websocket_handler.
|
|
11
|
-
// Mechanism (handleUpgrade path): HTTP 4xx response written before 101.
|
|
12
|
-
// - `handleProtocols(protocols, req)` — select subprotocol from client offer.
|
|
13
|
-
// Note: In the Soup path the 101 response is committed before our callback fires,
|
|
14
|
-
// so client-side ws.protocol won't reflect the selection. In the handleUpgrade
|
|
15
|
-
// path it IS reflected because we write the 101 ourselves.
|
|
16
|
-
// - `handleUpgrade(req, socket, head, cb)` — manual upgrade routing.
|
|
17
|
-
// Computes Sec-WebSocket-Accept, emits 'headers', writes 101 via socket.write(),
|
|
18
|
-
// then creates Soup.WebsocketConnection from the IOStream and calls cb(ws, req).
|
|
19
|
-
// - Emits 'listening' / 'connection' / 'close' / 'error' / 'headers' like ws
|
|
20
|
-
//
|
|
21
|
-
// Not supported:
|
|
22
|
-
// - `ping()`/`pong()` events — libsoup 3 GI does not expose a user-level API;
|
|
23
|
-
// Soup handles control frames internally (Phase 4).
|
|
24
|
-
// - `createWebSocketStream()` (Phase 4)
|
|
25
|
-
|
|
26
|
-
import { EventEmitter } from '@gjsify/events';
|
|
27
|
-
import { Buffer } from '@gjsify/buffer';
|
|
28
|
-
import { createHash } from '@gjsify/crypto';
|
|
29
|
-
import Soup from '@girs/soup-3.0';
|
|
30
|
-
import GLib from '@girs/glib-2.0';
|
|
31
|
-
import Gio from '@girs/gio-2.0';
|
|
32
|
-
import { ensureMainLoop } from '@gjsify/utils';
|
|
33
|
-
import { CLOSED, CLOSING, CONNECTING, OPEN } from './constants.js';
|
|
34
|
-
|
|
35
|
-
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
36
|
-
const WS_KEY_REGEX = /^[+/0-9A-Za-z]{22}==$/;
|
|
37
|
-
|
|
38
|
-
/** Structural duck-type for @gjsify/http Server — avoids a hard dep on @gjsify/http. */
|
|
39
|
-
interface HttpServer {
|
|
40
|
-
soupServer: Soup.Server | null;
|
|
41
|
-
address(): { address: string; family: string; port: number } | null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ── verifyClient types ──────────────────────────────────────────────────────
|
|
45
|
-
|
|
46
|
-
export interface VerifyClientInfo {
|
|
47
|
-
/** Value of the HTTP Origin request header (empty string if absent). */
|
|
48
|
-
origin: string;
|
|
49
|
-
/** Whether the connection uses TLS. Always false on Gjs (Soup plain text). */
|
|
50
|
-
secure: boolean;
|
|
51
|
-
/** Minimal HTTP request object populated from Soup.ServerMessage. */
|
|
52
|
-
req: {
|
|
53
|
-
method: string;
|
|
54
|
-
url: string;
|
|
55
|
-
headers: Record<string, string | string[]>;
|
|
56
|
-
socket: { remoteAddress: string; remotePort: number };
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export type VerifyClientSync = (info: VerifyClientInfo) => boolean;
|
|
61
|
-
export type VerifyClientAsync = (
|
|
62
|
-
info: VerifyClientInfo,
|
|
63
|
-
cb: (result: boolean, code?: number, message?: string, headers?: Record<string, string>) => void,
|
|
64
|
-
) => void;
|
|
65
|
-
|
|
66
|
-
// ── ServerOptions ───────────────────────────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
export interface ServerOptions {
|
|
69
|
-
host?: string;
|
|
70
|
-
port?: number;
|
|
71
|
-
backlog?: number;
|
|
72
|
-
/** Attach to an existing @gjsify/http Server instead of creating a new one. */
|
|
73
|
-
server?: HttpServer;
|
|
74
|
-
/** Pre-upgrade access control hook. Sync: return boolean. Async: call cb(result, code?). */
|
|
75
|
-
verifyClient?: VerifyClientSync | VerifyClientAsync;
|
|
76
|
-
/** Subprotocol selection hook. Receives the Set of client-offered protocols and
|
|
77
|
-
* a minimal request object; return the selected protocol string or false to
|
|
78
|
-
* use none. Server-side ws.protocol is set correctly; client-visible protocol
|
|
79
|
-
* negotiation requires Phase 3 (manual handshake). */
|
|
80
|
-
handleProtocols?: (protocols: Set<string>, req: VerifyClientInfo['req']) => string | false;
|
|
81
|
-
path?: string;
|
|
82
|
-
noServer?: boolean;
|
|
83
|
-
clientTracking?: boolean;
|
|
84
|
-
perMessageDeflate?: boolean | object;
|
|
85
|
-
maxPayload?: number;
|
|
86
|
-
skipUTF8Validation?: boolean;
|
|
87
|
-
allowSynchronousEvents?: boolean;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ── ServerSideWebSocket ─────────────────────────────────────────────────────
|
|
91
|
-
|
|
92
|
-
/** Wraps an accepted Soup.WebsocketConnection in a `ws.WebSocket`-shaped
|
|
93
|
-
* EventEmitter. Kept private to this file: the WebSocket class in
|
|
94
|
-
* ./websocket.ts targets the CLIENT constructor path. Server-accepted
|
|
95
|
-
* connections have different semantics (no URL reconnect, different
|
|
96
|
-
* lifecycle) so we expose a narrower surface. */
|
|
97
|
-
class ServerSideWebSocket extends EventEmitter {
|
|
98
|
-
static readonly CONNECTING = CONNECTING;
|
|
99
|
-
static readonly OPEN = OPEN;
|
|
100
|
-
static readonly CLOSING = CLOSING;
|
|
101
|
-
static readonly CLOSED = CLOSED;
|
|
102
|
-
|
|
103
|
-
readonly CONNECTING = CONNECTING;
|
|
104
|
-
readonly OPEN = OPEN;
|
|
105
|
-
readonly CLOSING = CLOSING;
|
|
106
|
-
readonly CLOSED = CLOSED;
|
|
107
|
-
|
|
108
|
-
readyState = OPEN;
|
|
109
|
-
protocol = '';
|
|
110
|
-
extensions = '';
|
|
111
|
-
url = '';
|
|
112
|
-
|
|
113
|
-
private _conn: Soup.WebsocketConnection;
|
|
114
|
-
|
|
115
|
-
constructor(conn: Soup.WebsocketConnection, url: string) {
|
|
116
|
-
super();
|
|
117
|
-
this._conn = conn;
|
|
118
|
-
this.url = url;
|
|
119
|
-
|
|
120
|
-
conn.connect('message', (_c: Soup.WebsocketConnection, type: number, bytes: GLib.Bytes) => {
|
|
121
|
-
const data = bytes.get_data();
|
|
122
|
-
if (type === Soup.WebsocketDataType.TEXT) {
|
|
123
|
-
const str = typeof data === 'string'
|
|
124
|
-
? data
|
|
125
|
-
: data
|
|
126
|
-
? new TextDecoder('utf-8').decode(data as Uint8Array)
|
|
127
|
-
: '';
|
|
128
|
-
this.emit('message', str, false);
|
|
129
|
-
} else {
|
|
130
|
-
const buf = data ? Buffer.from(data as Uint8Array) : Buffer.alloc(0);
|
|
131
|
-
this.emit('message', buf, true);
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
conn.connect('closed', () => {
|
|
136
|
-
this.readyState = CLOSED;
|
|
137
|
-
const code = conn.get_close_code() || 1005;
|
|
138
|
-
const reason = conn.get_close_data() || '';
|
|
139
|
-
this.emit('close', code, Buffer.from(reason));
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
conn.connect('error', (_c: Soup.WebsocketConnection, err: GLib.Error) => {
|
|
143
|
-
this.emit('error', new Error(err.message));
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
send(
|
|
148
|
-
data: string | Buffer | ArrayBuffer | ArrayBufferView,
|
|
149
|
-
optionsOrCb?: ((err?: Error) => void) | object,
|
|
150
|
-
cb?: (err?: Error) => void,
|
|
151
|
-
): void {
|
|
152
|
-
const callback = typeof optionsOrCb === 'function' ? optionsOrCb : cb;
|
|
153
|
-
try {
|
|
154
|
-
if (typeof data === 'string') {
|
|
155
|
-
const bytes = new TextEncoder().encode(data);
|
|
156
|
-
this._conn.send_message(Soup.WebsocketDataType.TEXT, new GLib.Bytes(bytes));
|
|
157
|
-
} else {
|
|
158
|
-
let bytes: GLib.Bytes;
|
|
159
|
-
if (Buffer.isBuffer(data as any)) {
|
|
160
|
-
const b = data as Buffer;
|
|
161
|
-
bytes = new GLib.Bytes(new Uint8Array(b.buffer, b.byteOffset, b.byteLength));
|
|
162
|
-
} else if (data instanceof ArrayBuffer) {
|
|
163
|
-
bytes = new GLib.Bytes(new Uint8Array(data));
|
|
164
|
-
} else if (ArrayBuffer.isView(data)) {
|
|
165
|
-
const view = data as ArrayBufferView;
|
|
166
|
-
bytes = new GLib.Bytes(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
|
167
|
-
} else {
|
|
168
|
-
throw new TypeError('Unsupported send() payload type');
|
|
169
|
-
}
|
|
170
|
-
this._conn.send_message(Soup.WebsocketDataType.BINARY, bytes);
|
|
171
|
-
}
|
|
172
|
-
if (callback) queueMicrotask(() => callback());
|
|
173
|
-
} catch (err) {
|
|
174
|
-
const e = err instanceof Error ? err : new Error(String(err));
|
|
175
|
-
if (callback) queueMicrotask(() => callback(e));
|
|
176
|
-
else queueMicrotask(() => this.emit('error', e));
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
close(code?: number, reason?: string | Buffer): void {
|
|
181
|
-
if (this.readyState === CLOSED || this.readyState === CLOSING) return;
|
|
182
|
-
this.readyState = CLOSING;
|
|
183
|
-
try {
|
|
184
|
-
const reasonStr = reason === undefined
|
|
185
|
-
? null
|
|
186
|
-
: Buffer.isBuffer(reason as any)
|
|
187
|
-
? (reason as Buffer).toString('utf8')
|
|
188
|
-
: String(reason);
|
|
189
|
-
this._conn.close(code ?? 1000, reasonStr);
|
|
190
|
-
} catch (err) {
|
|
191
|
-
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
terminate(): void {
|
|
196
|
-
if (this.readyState === CLOSED) return;
|
|
197
|
-
this.readyState = CLOSING;
|
|
198
|
-
try { this._conn.close(1006, null); } catch { /* tearing down */ }
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// ── WebSocketServer ─────────────────────────────────────────────────────────
|
|
203
|
-
|
|
204
|
-
/** `ws.WebSocketServer` — listens on a TCP port (or attaches to an existing
|
|
205
|
-
* @gjsify/http Server) and emits 'connection' events wrapping
|
|
206
|
-
* Soup.WebsocketConnection as ws.WebSocket-shaped objects. */
|
|
207
|
-
export class WebSocketServer extends EventEmitter {
|
|
208
|
-
readonly options: ServerOptions;
|
|
209
|
-
readonly clients: Set<ServerSideWebSocket> = new Set();
|
|
210
|
-
readonly path: string;
|
|
211
|
-
|
|
212
|
-
private _server: Soup.Server | null = null;
|
|
213
|
-
private _address: { address: string; family: string; port: number } | null = null;
|
|
214
|
-
|
|
215
|
-
constructor(options: ServerOptions = {}, callback?: () => void) {
|
|
216
|
-
super();
|
|
217
|
-
this.options = options;
|
|
218
|
-
this.path = options.path ?? '/';
|
|
219
|
-
|
|
220
|
-
if (options.noServer) {
|
|
221
|
-
if (options.port !== undefined || options.server !== undefined) {
|
|
222
|
-
throw new Error(
|
|
223
|
-
'ws.WebSocketServer: { noServer: true } is mutually exclusive with port and server.',
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
// noServer mode: caller manages the http.Server and calls handleUpgrade() manually.
|
|
227
|
-
// No Soup.Server is created; no port is bound.
|
|
228
|
-
if (callback) this.once('listening', callback);
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (options.port === undefined && !options.server) {
|
|
233
|
-
throw new Error(
|
|
234
|
-
'ws.WebSocketServer requires either options.port or options.server on Gjs.',
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (callback) this.once('listening', callback);
|
|
239
|
-
this._start(options);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// ── Private helpers ─────────────────────────────────────────────────────
|
|
243
|
-
|
|
244
|
-
private _buildVerifyClientInfo(msg: Soup.ServerMessage): VerifyClientInfo {
|
|
245
|
-
const reqHeaders = msg.get_request_headers();
|
|
246
|
-
const headers: Record<string, string | string[]> = {};
|
|
247
|
-
reqHeaders.foreach((name: string, value: string) => {
|
|
248
|
-
const lower = name.toLowerCase();
|
|
249
|
-
const existing = headers[lower];
|
|
250
|
-
if (existing === undefined) headers[lower] = value;
|
|
251
|
-
else if (Array.isArray(existing)) existing.push(value);
|
|
252
|
-
else headers[lower] = [existing, value];
|
|
253
|
-
});
|
|
254
|
-
const uri = msg.get_uri();
|
|
255
|
-
const urlPath = uri.get_path() ?? '/';
|
|
256
|
-
const query = uri.get_query();
|
|
257
|
-
const url = query ? `${urlPath}?${query}` : urlPath;
|
|
258
|
-
const remoteHost = msg.get_remote_host() ?? '127.0.0.1';
|
|
259
|
-
const remoteAddr = msg.get_remote_address();
|
|
260
|
-
const remotePort = (remoteAddr instanceof Gio.InetSocketAddress)
|
|
261
|
-
? remoteAddr.get_port() : 0;
|
|
262
|
-
return {
|
|
263
|
-
origin: (headers['origin'] as string) ?? '',
|
|
264
|
-
secure: false,
|
|
265
|
-
req: {
|
|
266
|
-
method: msg.get_method(),
|
|
267
|
-
url,
|
|
268
|
-
headers,
|
|
269
|
-
socket: { remoteAddress: remoteHost, remotePort },
|
|
270
|
-
},
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** Register add_handler (verifyClient) + add_websocket_handler on soupServer.
|
|
275
|
-
* The verifyClient add_handler MUST be registered before add_websocket_handler —
|
|
276
|
-
* Soup processes normal handlers before websocket handlers; setting a status code
|
|
277
|
-
* in add_handler prevents the websocket handler from firing (HTTP-level rejection).
|
|
278
|
-
* Only register add_handler when verifyClient is provided — a no-op handler on the
|
|
279
|
-
* same path as an existing http.Server catch-all can interfere with Soup's routing. */
|
|
280
|
-
private _setupHandlers(soupServer: Soup.Server, options: ServerOptions): void {
|
|
281
|
-
// ── Step 1: HTTP interceptor — verifyClient (registered only when needed) ──
|
|
282
|
-
if (options.verifyClient) {
|
|
283
|
-
const vc = options.verifyClient;
|
|
284
|
-
soupServer.add_handler(this.path, (_srv: Soup.Server, msg: Soup.ServerMessage) => {
|
|
285
|
-
const reqHeaders = msg.get_request_headers();
|
|
286
|
-
// Only intercept WebSocket upgrade requests; regular HTTP on same path passes through.
|
|
287
|
-
const upgrade = (reqHeaders.get_one('Upgrade') ?? '').toLowerCase();
|
|
288
|
-
if (upgrade !== 'websocket') return;
|
|
289
|
-
|
|
290
|
-
const info = this._buildVerifyClientInfo(msg);
|
|
291
|
-
|
|
292
|
-
if (vc.length >= 2) {
|
|
293
|
-
// Async version: verifyClient(info, callback)
|
|
294
|
-
msg.pause();
|
|
295
|
-
(vc as VerifyClientAsync)(info, (result: boolean, code = 401) => {
|
|
296
|
-
if (!result) msg.set_status(code, null);
|
|
297
|
-
msg.unpause();
|
|
298
|
-
});
|
|
299
|
-
} else {
|
|
300
|
-
// Sync version: verifyClient(info) => boolean
|
|
301
|
-
const ok = (vc as VerifyClientSync)(info);
|
|
302
|
-
if (!ok) msg.set_status(401, null);
|
|
303
|
-
}
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// ── Step 2: WebSocket handler — fires only if Step 1 didn't reject ──
|
|
308
|
-
soupServer.add_websocket_handler(
|
|
309
|
-
this.path,
|
|
310
|
-
null, // origin filter — accept any
|
|
311
|
-
null, // protocols — Soup accepts all; handleProtocols selects after connect
|
|
312
|
-
(
|
|
313
|
-
_server: Soup.Server,
|
|
314
|
-
msg: Soup.ServerMessage,
|
|
315
|
-
_path: string,
|
|
316
|
-
conn: Soup.WebsocketConnection,
|
|
317
|
-
) => {
|
|
318
|
-
const url = msg.get_uri()?.to_string() ?? this.path;
|
|
319
|
-
const ws = new ServerSideWebSocket(conn, url);
|
|
320
|
-
|
|
321
|
-
if (options.handleProtocols) {
|
|
322
|
-
// Read client-offered protocols from the request header.
|
|
323
|
-
const raw = msg.get_request_headers().get_one('Sec-WebSocket-Protocol') ?? '';
|
|
324
|
-
const offered = new Set(
|
|
325
|
-
raw.split(',').map((s: string) => s.trim()).filter(Boolean),
|
|
326
|
-
);
|
|
327
|
-
const req = this._buildVerifyClientInfo(msg).req;
|
|
328
|
-
const selected = options.handleProtocols(offered, req);
|
|
329
|
-
// Set server-side protocol. Note: the 101 response was already committed
|
|
330
|
-
// by Soup before this fires, so client ws.protocol won't reflect this
|
|
331
|
-
// selection (requires Phase 3 manual handshake for full negotiation).
|
|
332
|
-
if (selected) ws.protocol = selected;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
if (options.clientTracking !== false) {
|
|
336
|
-
this.clients.add(ws);
|
|
337
|
-
ws.on('close', () => this.clients.delete(ws));
|
|
338
|
-
}
|
|
339
|
-
this.emit('connection', ws, msg);
|
|
340
|
-
},
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
private _start(options: ServerOptions): void {
|
|
345
|
-
try {
|
|
346
|
-
if (options.server) {
|
|
347
|
-
// ── Attach to existing @gjsify/http Server ──────────────────────
|
|
348
|
-
const httpServer = options.server;
|
|
349
|
-
const soupServer = httpServer.soupServer;
|
|
350
|
-
if (!soupServer) {
|
|
351
|
-
throw new Error(
|
|
352
|
-
'options.server has no active Soup.Server. ' +
|
|
353
|
-
'Ensure httpServer.listen() was called before creating WebSocketServer.',
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
this._server = soupServer;
|
|
357
|
-
this._setupHandlers(soupServer, options);
|
|
358
|
-
ensureMainLoop();
|
|
359
|
-
const addr = httpServer.address();
|
|
360
|
-
if (addr) this._address = { address: addr.address, family: addr.family, port: addr.port };
|
|
361
|
-
queueMicrotask(() => this.emit('listening'));
|
|
362
|
-
} else {
|
|
363
|
-
// ── Standalone server ────────────────────────────────────────────
|
|
364
|
-
this._server = new Soup.Server({});
|
|
365
|
-
this._setupHandlers(this._server, options);
|
|
366
|
-
|
|
367
|
-
const host = options.host ?? '0.0.0.0';
|
|
368
|
-
const port = options.port!;
|
|
369
|
-
|
|
370
|
-
if (host === '127.0.0.1' || host === 'localhost') {
|
|
371
|
-
this._server.listen_local(port, Soup.ServerListenOptions.IPV4_ONLY);
|
|
372
|
-
} else if (host === '::1') {
|
|
373
|
-
this._server.listen_local(port, Soup.ServerListenOptions.IPV6_ONLY);
|
|
374
|
-
} else {
|
|
375
|
-
this._server.listen_all(port, 0);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Resolve the actual port (0 → OS-assigned)
|
|
379
|
-
const listeners = this._server.get_listeners();
|
|
380
|
-
let actualPort = port;
|
|
381
|
-
if (listeners && listeners.length > 0) {
|
|
382
|
-
const addr = listeners[0].get_local_address() as Gio.InetSocketAddress;
|
|
383
|
-
if (addr && typeof addr.get_port === 'function') actualPort = addr.get_port();
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
ensureMainLoop();
|
|
387
|
-
this._address = { address: host, family: 'IPv4', port: actualPort };
|
|
388
|
-
queueMicrotask(() => this.emit('listening'));
|
|
389
|
-
}
|
|
390
|
-
} catch (err) {
|
|
391
|
-
queueMicrotask(() => this.emit('error', err instanceof Error ? err : new Error(String(err))));
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// ── Public API ──────────────────────────────────────────────────────────
|
|
396
|
-
|
|
397
|
-
address(): { address: string; family: string; port: number } | null {
|
|
398
|
-
return this._address;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
close(callback?: (err?: Error) => void): void {
|
|
402
|
-
try {
|
|
403
|
-
for (const ws of this.clients) ws.close();
|
|
404
|
-
this.clients.clear();
|
|
405
|
-
// Only disconnect the Soup.Server if WE own it (standalone mode).
|
|
406
|
-
// In { server } mode the http.Server owns the Soup.Server lifecycle.
|
|
407
|
-
if (!this.options.server) {
|
|
408
|
-
this._server?.disconnect();
|
|
409
|
-
}
|
|
410
|
-
this._server = null;
|
|
411
|
-
this._address = null;
|
|
412
|
-
this.emit('close');
|
|
413
|
-
if (callback) queueMicrotask(() => callback());
|
|
414
|
-
} catch (err) {
|
|
415
|
-
const e = err instanceof Error ? err : new Error(String(err));
|
|
416
|
-
this.emit('error', e);
|
|
417
|
-
if (callback) queueMicrotask(() => callback(e));
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/** Manual WebSocket upgrade — matches npm ws semantics exactly.
|
|
422
|
-
* The caller intercepts 'upgrade' on an http.Server (typically with
|
|
423
|
-
* { noServer: true } on this WebSocketServer) and passes the raw
|
|
424
|
-
* IncomingMessage + net.Socket + head buffer here.
|
|
425
|
-
*
|
|
426
|
-
* Internally: validates headers, runs verifyClient, computes
|
|
427
|
-
* Sec-WebSocket-Accept, emits 'headers' (mutable array), writes the 101
|
|
428
|
-
* response via socket.write(), then creates Soup.WebsocketConnection from
|
|
429
|
-
* the underlying IOStream and calls cb(ws, req). */
|
|
430
|
-
handleUpgrade(
|
|
431
|
-
req: any,
|
|
432
|
-
socket: any,
|
|
433
|
-
_head: Buffer,
|
|
434
|
-
cb: (ws: ServerSideWebSocket, req: any) => void,
|
|
435
|
-
): void {
|
|
436
|
-
if (!this._validateUpgradeHeaders(req, socket)) return;
|
|
437
|
-
const key = (req.headers?.['sec-websocket-key'] ?? '') as string;
|
|
438
|
-
|
|
439
|
-
const doUpgrade = () => this._completeUpgrade(req, socket, key, cb);
|
|
440
|
-
|
|
441
|
-
if (this.options.verifyClient) {
|
|
442
|
-
const vc = this.options.verifyClient;
|
|
443
|
-
const info = this._buildVerifyClientInfoFromReq(req);
|
|
444
|
-
if (vc.length >= 2) {
|
|
445
|
-
(vc as VerifyClientAsync)(info, (result: boolean, code = 401) => {
|
|
446
|
-
if (!result) { this._abortHandshake(socket, code); return; }
|
|
447
|
-
doUpgrade();
|
|
448
|
-
});
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
if (!(vc as VerifyClientSync)(info)) {
|
|
452
|
-
this._abortHandshake(socket, 401);
|
|
453
|
-
return;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
doUpgrade();
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
shouldHandle(req: { url?: string }): boolean {
|
|
460
|
-
if (this.path === '/') return true;
|
|
461
|
-
const url = req?.url ?? '/';
|
|
462
|
-
return url === this.path || url.startsWith(this.path + '?') || url.startsWith(this.path + '/');
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// ── handleUpgrade helpers ───────────────────────────────────────────────
|
|
466
|
-
|
|
467
|
-
private _validateUpgradeHeaders(req: any, socket: any): boolean {
|
|
468
|
-
const h = req.headers ?? {};
|
|
469
|
-
if (req.method !== 'GET') { this._abortHandshake(socket, 405); return false; }
|
|
470
|
-
if ((h['upgrade'] ?? '').toLowerCase() !== 'websocket') { this._abortHandshake(socket, 400); return false; }
|
|
471
|
-
if (!WS_KEY_REGEX.test(h['sec-websocket-key'] ?? '')) { this._abortHandshake(socket, 400); return false; }
|
|
472
|
-
const ver = Number(h['sec-websocket-version'] ?? '0');
|
|
473
|
-
if (ver !== 13 && ver !== 8) { this._abortHandshake(socket, 426); return false; }
|
|
474
|
-
if (!this.shouldHandle(req)) { this._abortHandshake(socket, 400); return false; }
|
|
475
|
-
return true;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
private _completeUpgrade(
|
|
479
|
-
req: any,
|
|
480
|
-
socket: any,
|
|
481
|
-
key: string,
|
|
482
|
-
cb: (ws: ServerSideWebSocket, req: any) => void,
|
|
483
|
-
): void {
|
|
484
|
-
const digest = createHash('sha1').update(key + WS_GUID).digest('base64');
|
|
485
|
-
|
|
486
|
-
const responseHeaders = [
|
|
487
|
-
'HTTP/1.1 101 Switching Protocols',
|
|
488
|
-
'Upgrade: websocket',
|
|
489
|
-
'Connection: Upgrade',
|
|
490
|
-
`Sec-WebSocket-Accept: ${digest}`,
|
|
491
|
-
];
|
|
492
|
-
|
|
493
|
-
let selectedProtocol: string | null = null;
|
|
494
|
-
if (this.options.handleProtocols) {
|
|
495
|
-
const raw = (req.headers?.['sec-websocket-protocol'] ?? '') as string;
|
|
496
|
-
const offered = new Set(raw.split(',').map((s: string) => s.trim()).filter(Boolean));
|
|
497
|
-
const sel = this.options.handleProtocols(offered, this._buildVerifyClientInfoFromReq(req).req);
|
|
498
|
-
if (sel) {
|
|
499
|
-
selectedProtocol = sel;
|
|
500
|
-
responseHeaders.push(`Sec-WebSocket-Protocol: ${sel}`);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// Emit 'headers' hook — listeners may push additional response headers.
|
|
505
|
-
this.emit('headers', responseHeaders, req);
|
|
506
|
-
|
|
507
|
-
// Write the 101 response then hand the IOStream to Soup.WebsocketConnection.
|
|
508
|
-
const responseStr = responseHeaders.join('\r\n') + '\r\n\r\n';
|
|
509
|
-
socket.write(responseStr, () => {
|
|
510
|
-
const ioStream: Gio.IOStream | null = typeof socket._releaseIOStream === 'function'
|
|
511
|
-
? socket._releaseIOStream()
|
|
512
|
-
: null;
|
|
513
|
-
if (!ioStream) { socket.destroy?.(); return; }
|
|
514
|
-
|
|
515
|
-
const rawUrl = req.url ?? '/';
|
|
516
|
-
const uri = GLib.Uri.parse(`ws://localhost${rawUrl}`, GLib.UriFlags.NONE);
|
|
517
|
-
const conn = Soup.WebsocketConnection['new'](
|
|
518
|
-
ioStream,
|
|
519
|
-
uri,
|
|
520
|
-
Soup.WebsocketConnectionType.SERVER,
|
|
521
|
-
null,
|
|
522
|
-
selectedProtocol,
|
|
523
|
-
[],
|
|
524
|
-
);
|
|
525
|
-
|
|
526
|
-
const ws = new ServerSideWebSocket(conn, rawUrl);
|
|
527
|
-
if (selectedProtocol) ws.protocol = selectedProtocol;
|
|
528
|
-
|
|
529
|
-
if (this.options.clientTracking !== false) {
|
|
530
|
-
this.clients.add(ws);
|
|
531
|
-
ws.on('close', () => this.clients.delete(ws));
|
|
532
|
-
}
|
|
533
|
-
cb(ws, req);
|
|
534
|
-
});
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
private _abortHandshake(socket: any, code: number): void {
|
|
538
|
-
const statusTexts: Record<number, string> = {
|
|
539
|
-
400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden',
|
|
540
|
-
405: 'Method Not Allowed', 426: 'Upgrade Required',
|
|
541
|
-
};
|
|
542
|
-
const msg = statusTexts[code] ?? 'Error';
|
|
543
|
-
socket.write?.(`HTTP/1.1 ${code} ${msg}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`, () => {
|
|
544
|
-
socket.destroy?.();
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
private _buildVerifyClientInfoFromReq(req: any): VerifyClientInfo {
|
|
549
|
-
const h = req.headers ?? {};
|
|
550
|
-
return {
|
|
551
|
-
origin: (h['origin'] as string) ?? '',
|
|
552
|
-
secure: false,
|
|
553
|
-
req: {
|
|
554
|
-
method: req.method ?? 'GET',
|
|
555
|
-
url: req.url ?? '/',
|
|
556
|
-
headers: h,
|
|
557
|
-
socket: { remoteAddress: req.socket?.remoteAddress ?? '127.0.0.1', remotePort: req.socket?.remotePort ?? 0 },
|
|
558
|
-
},
|
|
559
|
-
};
|
|
560
|
-
}
|
|
561
|
-
}
|