@gjsify/ws 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ const BINARY_TYPES = ["nodebuffer", "arraybuffer", "fragments"];
2
+ const EMPTY_BUFFER = new Uint8Array(0);
3
+ const CONNECTING = 0;
4
+ const OPEN = 1;
5
+ const CLOSING = 2;
6
+ const CLOSED = 3;
7
+ const kWebSocket = /* @__PURE__ */ Symbol("ws:kWebSocket");
8
+ export {
9
+ BINARY_TYPES,
10
+ CLOSED,
11
+ CLOSING,
12
+ CONNECTING,
13
+ EMPTY_BUFFER,
14
+ OPEN,
15
+ kWebSocket
16
+ };
@@ -0,0 +1,15 @@
1
+ import { WebSocket } from "./websocket.js";
2
+ import { WebSocketServer } from "./websocket-server.js";
3
+ import { createWebSocketStream } from "./stream.js";
4
+ WebSocket.WebSocket = WebSocket;
5
+ WebSocket.WebSocketServer = WebSocketServer;
6
+ WebSocket.Server = WebSocketServer;
7
+ WebSocket.createWebSocketStream = createWebSocketStream;
8
+ var index_default = WebSocket;
9
+ export {
10
+ WebSocketServer as Server,
11
+ WebSocket,
12
+ WebSocketServer,
13
+ createWebSocketStream,
14
+ index_default as default
15
+ };
@@ -0,0 +1,89 @@
1
+ import { Duplex } from "node:stream";
2
+ function emitClose(stream) {
3
+ stream.emit("close");
4
+ }
5
+ function duplexOnEnd() {
6
+ if (!this.destroyed && this._writableState.finished) {
7
+ this.destroy();
8
+ }
9
+ }
10
+ function duplexOnError(err) {
11
+ this.removeListener("error", duplexOnError);
12
+ this.destroy();
13
+ if (this.listenerCount("error") === 0) {
14
+ this.emit("error", err);
15
+ }
16
+ }
17
+ function createWebSocketStream(ws, options = {}) {
18
+ let terminateOnDestroy = true;
19
+ const duplex = new Duplex({
20
+ ...options,
21
+ autoDestroy: false,
22
+ emitClose: false,
23
+ objectMode: false,
24
+ writableObjectMode: false
25
+ });
26
+ ws.on("message", (msg, isBinary) => {
27
+ let data;
28
+ if (isBinary || duplex.readableObjectMode) {
29
+ data = msg;
30
+ } else {
31
+ data = typeof msg === "string" ? Buffer.from(msg) : msg;
32
+ }
33
+ if (!duplex.push(data) && typeof ws.pause === "function") ws.pause();
34
+ });
35
+ ws.once("error", (err) => {
36
+ if (duplex.destroyed) return;
37
+ terminateOnDestroy = false;
38
+ duplex.destroy(err);
39
+ });
40
+ ws.once("close", () => {
41
+ if (duplex.destroyed) return;
42
+ duplex.push(null);
43
+ });
44
+ duplex._destroy = function(err, callback) {
45
+ if (ws.readyState === ws.CLOSED) {
46
+ callback(err);
47
+ process.nextTick(emitClose, duplex);
48
+ return;
49
+ }
50
+ let called = false;
51
+ ws.once("error", (e) => {
52
+ called = true;
53
+ callback(e);
54
+ });
55
+ ws.once("close", () => {
56
+ if (!called) callback(err);
57
+ process.nextTick(emitClose, duplex);
58
+ });
59
+ if (terminateOnDestroy) ws.terminate();
60
+ };
61
+ duplex._final = function(callback) {
62
+ if (ws.readyState === ws.CONNECTING) {
63
+ ws.once("open", () => duplex._final(callback));
64
+ return;
65
+ }
66
+ if (ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
67
+ callback();
68
+ return;
69
+ }
70
+ ws.once("close", callback);
71
+ ws.close();
72
+ };
73
+ duplex._read = function() {
74
+ if (typeof ws.resume === "function") ws.resume();
75
+ };
76
+ duplex._write = function(chunk, _encoding, callback) {
77
+ if (ws.readyState === ws.CONNECTING) {
78
+ ws.once("open", () => duplex._write(chunk, _encoding, callback));
79
+ return;
80
+ }
81
+ ws.send(chunk, callback);
82
+ };
83
+ duplex.on("end", duplexOnEnd);
84
+ duplex.on("error", duplexOnError);
85
+ return duplex;
86
+ }
87
+ export {
88
+ createWebSocketStream
89
+ };
@@ -0,0 +1,409 @@
1
+ import { EventEmitter } from "@gjsify/events";
2
+ import { Buffer } from "@gjsify/buffer";
3
+ import { createHash } from "@gjsify/crypto";
4
+ import Soup from "@girs/soup-3.0";
5
+ import GLib from "@girs/glib-2.0";
6
+ import Gio from "@girs/gio-2.0";
7
+ import { ensureMainLoop } from "@gjsify/utils";
8
+ import { CLOSED, CLOSING, CONNECTING, OPEN } from "./constants.js";
9
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
10
+ const WS_KEY_REGEX = /^[+/0-9A-Za-z]{22}==$/;
11
+ class ServerSideWebSocket extends EventEmitter {
12
+ static CONNECTING = CONNECTING;
13
+ static OPEN = OPEN;
14
+ static CLOSING = CLOSING;
15
+ static CLOSED = CLOSED;
16
+ CONNECTING = CONNECTING;
17
+ OPEN = OPEN;
18
+ CLOSING = CLOSING;
19
+ CLOSED = CLOSED;
20
+ readyState = OPEN;
21
+ protocol = "";
22
+ extensions = "";
23
+ url = "";
24
+ _conn;
25
+ constructor(conn, url) {
26
+ super();
27
+ this._conn = conn;
28
+ this.url = url;
29
+ conn.connect("message", (_c, type, bytes) => {
30
+ const data = bytes.get_data();
31
+ if (type === Soup.WebsocketDataType.TEXT) {
32
+ const str = typeof data === "string" ? data : data ? new TextDecoder("utf-8").decode(data) : "";
33
+ this.emit("message", str, false);
34
+ } else {
35
+ const buf = data ? Buffer.from(data) : Buffer.alloc(0);
36
+ this.emit("message", buf, true);
37
+ }
38
+ });
39
+ conn.connect("closed", () => {
40
+ this.readyState = CLOSED;
41
+ const code = conn.get_close_code() || 1005;
42
+ const reason = conn.get_close_data() || "";
43
+ this.emit("close", code, Buffer.from(reason));
44
+ });
45
+ conn.connect("error", (_c, err) => {
46
+ this.emit("error", new Error(err.message));
47
+ });
48
+ }
49
+ send(data, optionsOrCb, cb) {
50
+ const callback = typeof optionsOrCb === "function" ? optionsOrCb : cb;
51
+ try {
52
+ if (typeof data === "string") {
53
+ const bytes = new TextEncoder().encode(data);
54
+ this._conn.send_message(Soup.WebsocketDataType.TEXT, new GLib.Bytes(bytes));
55
+ } else {
56
+ let bytes;
57
+ if (Buffer.isBuffer(data)) {
58
+ const b = data;
59
+ bytes = new GLib.Bytes(new Uint8Array(b.buffer, b.byteOffset, b.byteLength));
60
+ } else if (data instanceof ArrayBuffer) {
61
+ bytes = new GLib.Bytes(new Uint8Array(data));
62
+ } else if (ArrayBuffer.isView(data)) {
63
+ const view = data;
64
+ bytes = new GLib.Bytes(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
65
+ } else {
66
+ throw new TypeError("Unsupported send() payload type");
67
+ }
68
+ this._conn.send_message(Soup.WebsocketDataType.BINARY, bytes);
69
+ }
70
+ if (callback) queueMicrotask(() => callback());
71
+ } catch (err) {
72
+ const e = err instanceof Error ? err : new Error(String(err));
73
+ if (callback) queueMicrotask(() => callback(e));
74
+ else queueMicrotask(() => this.emit("error", e));
75
+ }
76
+ }
77
+ close(code, reason) {
78
+ if (this.readyState === CLOSED || this.readyState === CLOSING) return;
79
+ this.readyState = CLOSING;
80
+ try {
81
+ const reasonStr = reason === void 0 ? null : Buffer.isBuffer(reason) ? reason.toString("utf8") : String(reason);
82
+ this._conn.close(code ?? 1e3, reasonStr);
83
+ } catch (err) {
84
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
85
+ }
86
+ }
87
+ terminate() {
88
+ if (this.readyState === CLOSED) return;
89
+ this.readyState = CLOSING;
90
+ try {
91
+ this._conn.close(1006, null);
92
+ } catch {
93
+ }
94
+ }
95
+ }
96
+ class WebSocketServer extends EventEmitter {
97
+ options;
98
+ clients = /* @__PURE__ */ new Set();
99
+ path;
100
+ _server = null;
101
+ _address = null;
102
+ constructor(options = {}, callback) {
103
+ super();
104
+ this.options = options;
105
+ this.path = options.path ?? "/";
106
+ if (options.noServer) {
107
+ if (options.port !== void 0 || options.server !== void 0) {
108
+ throw new Error(
109
+ "ws.WebSocketServer: { noServer: true } is mutually exclusive with port and server."
110
+ );
111
+ }
112
+ if (callback) this.once("listening", callback);
113
+ return;
114
+ }
115
+ if (options.port === void 0 && !options.server) {
116
+ throw new Error(
117
+ "ws.WebSocketServer requires either options.port or options.server on Gjs."
118
+ );
119
+ }
120
+ if (callback) this.once("listening", callback);
121
+ this._start(options);
122
+ }
123
+ // ── Private helpers ─────────────────────────────────────────────────────
124
+ _buildVerifyClientInfo(msg) {
125
+ const reqHeaders = msg.get_request_headers();
126
+ const headers = {};
127
+ reqHeaders.foreach((name, value) => {
128
+ const lower = name.toLowerCase();
129
+ const existing = headers[lower];
130
+ if (existing === void 0) headers[lower] = value;
131
+ else if (Array.isArray(existing)) existing.push(value);
132
+ else headers[lower] = [existing, value];
133
+ });
134
+ const uri = msg.get_uri();
135
+ const urlPath = uri.get_path() ?? "/";
136
+ const query = uri.get_query();
137
+ const url = query ? `${urlPath}?${query}` : urlPath;
138
+ const remoteHost = msg.get_remote_host() ?? "127.0.0.1";
139
+ const remoteAddr = msg.get_remote_address();
140
+ const remotePort = remoteAddr instanceof Gio.InetSocketAddress ? remoteAddr.get_port() : 0;
141
+ return {
142
+ origin: headers["origin"] ?? "",
143
+ secure: false,
144
+ req: {
145
+ method: msg.get_method(),
146
+ url,
147
+ headers,
148
+ socket: { remoteAddress: remoteHost, remotePort }
149
+ }
150
+ };
151
+ }
152
+ /** Register add_handler (verifyClient) + add_websocket_handler on soupServer.
153
+ * The verifyClient add_handler MUST be registered before add_websocket_handler —
154
+ * Soup processes normal handlers before websocket handlers; setting a status code
155
+ * in add_handler prevents the websocket handler from firing (HTTP-level rejection).
156
+ * Only register add_handler when verifyClient is provided — a no-op handler on the
157
+ * same path as an existing http.Server catch-all can interfere with Soup's routing. */
158
+ _setupHandlers(soupServer, options) {
159
+ if (options.verifyClient) {
160
+ const vc = options.verifyClient;
161
+ soupServer.add_handler(this.path, (_srv, msg) => {
162
+ const reqHeaders = msg.get_request_headers();
163
+ const upgrade = (reqHeaders.get_one("Upgrade") ?? "").toLowerCase();
164
+ if (upgrade !== "websocket") return;
165
+ const info = this._buildVerifyClientInfo(msg);
166
+ if (vc.length >= 2) {
167
+ msg.pause();
168
+ vc(info, (result, code = 401) => {
169
+ if (!result) msg.set_status(code, null);
170
+ msg.unpause();
171
+ });
172
+ } else {
173
+ const ok = vc(info);
174
+ if (!ok) msg.set_status(401, null);
175
+ }
176
+ });
177
+ }
178
+ soupServer.add_websocket_handler(
179
+ this.path,
180
+ null,
181
+ // origin filter — accept any
182
+ null,
183
+ // protocols — Soup accepts all; handleProtocols selects after connect
184
+ (_server, msg, _path, conn) => {
185
+ const url = msg.get_uri()?.to_string() ?? this.path;
186
+ const ws = new ServerSideWebSocket(conn, url);
187
+ if (options.handleProtocols) {
188
+ const raw = msg.get_request_headers().get_one("Sec-WebSocket-Protocol") ?? "";
189
+ const offered = new Set(
190
+ raw.split(",").map((s) => s.trim()).filter(Boolean)
191
+ );
192
+ const req = this._buildVerifyClientInfo(msg).req;
193
+ const selected = options.handleProtocols(offered, req);
194
+ if (selected) ws.protocol = selected;
195
+ }
196
+ if (options.clientTracking !== false) {
197
+ this.clients.add(ws);
198
+ ws.on("close", () => this.clients.delete(ws));
199
+ }
200
+ this.emit("connection", ws, msg);
201
+ }
202
+ );
203
+ }
204
+ _start(options) {
205
+ try {
206
+ if (options.server) {
207
+ const httpServer = options.server;
208
+ const soupServer = httpServer.soupServer;
209
+ if (!soupServer) {
210
+ throw new Error(
211
+ "options.server has no active Soup.Server. Ensure httpServer.listen() was called before creating WebSocketServer."
212
+ );
213
+ }
214
+ this._server = soupServer;
215
+ this._setupHandlers(soupServer, options);
216
+ ensureMainLoop();
217
+ const addr = httpServer.address();
218
+ if (addr) this._address = { address: addr.address, family: addr.family, port: addr.port };
219
+ queueMicrotask(() => this.emit("listening"));
220
+ } else {
221
+ this._server = new Soup.Server({});
222
+ this._setupHandlers(this._server, options);
223
+ const host = options.host ?? "0.0.0.0";
224
+ const port = options.port;
225
+ if (host === "127.0.0.1" || host === "localhost") {
226
+ this._server.listen_local(port, Soup.ServerListenOptions.IPV4_ONLY);
227
+ } else if (host === "::1") {
228
+ this._server.listen_local(port, Soup.ServerListenOptions.IPV6_ONLY);
229
+ } else {
230
+ this._server.listen_all(port, 0);
231
+ }
232
+ const listeners = this._server.get_listeners();
233
+ let actualPort = port;
234
+ if (listeners && listeners.length > 0) {
235
+ const addr = listeners[0].get_local_address();
236
+ if (addr && typeof addr.get_port === "function") actualPort = addr.get_port();
237
+ }
238
+ ensureMainLoop();
239
+ this._address = { address: host, family: "IPv4", port: actualPort };
240
+ queueMicrotask(() => this.emit("listening"));
241
+ }
242
+ } catch (err) {
243
+ queueMicrotask(() => this.emit("error", err instanceof Error ? err : new Error(String(err))));
244
+ }
245
+ }
246
+ // ── Public API ──────────────────────────────────────────────────────────
247
+ address() {
248
+ return this._address;
249
+ }
250
+ close(callback) {
251
+ try {
252
+ for (const ws of this.clients) ws.close();
253
+ this.clients.clear();
254
+ if (!this.options.server) {
255
+ this._server?.disconnect();
256
+ }
257
+ this._server = null;
258
+ this._address = null;
259
+ this.emit("close");
260
+ if (callback) queueMicrotask(() => callback());
261
+ } catch (err) {
262
+ const e = err instanceof Error ? err : new Error(String(err));
263
+ this.emit("error", e);
264
+ if (callback) queueMicrotask(() => callback(e));
265
+ }
266
+ }
267
+ /** Manual WebSocket upgrade — matches npm ws semantics exactly.
268
+ * The caller intercepts 'upgrade' on an http.Server (typically with
269
+ * { noServer: true } on this WebSocketServer) and passes the raw
270
+ * IncomingMessage + net.Socket + head buffer here.
271
+ *
272
+ * Internally: validates headers, runs verifyClient, computes
273
+ * Sec-WebSocket-Accept, emits 'headers' (mutable array), writes the 101
274
+ * response via socket.write(), then creates Soup.WebsocketConnection from
275
+ * the underlying IOStream and calls cb(ws, req). */
276
+ handleUpgrade(req, socket, _head, cb) {
277
+ if (!this._validateUpgradeHeaders(req, socket)) return;
278
+ const key = req.headers?.["sec-websocket-key"] ?? "";
279
+ const doUpgrade = () => this._completeUpgrade(req, socket, key, cb);
280
+ if (this.options.verifyClient) {
281
+ const vc = this.options.verifyClient;
282
+ const info = this._buildVerifyClientInfoFromReq(req);
283
+ if (vc.length >= 2) {
284
+ vc(info, (result, code = 401) => {
285
+ if (!result) {
286
+ this._abortHandshake(socket, code);
287
+ return;
288
+ }
289
+ doUpgrade();
290
+ });
291
+ return;
292
+ }
293
+ if (!vc(info)) {
294
+ this._abortHandshake(socket, 401);
295
+ return;
296
+ }
297
+ }
298
+ doUpgrade();
299
+ }
300
+ shouldHandle(req) {
301
+ if (this.path === "/") return true;
302
+ const url = req?.url ?? "/";
303
+ return url === this.path || url.startsWith(this.path + "?") || url.startsWith(this.path + "/");
304
+ }
305
+ // ── handleUpgrade helpers ───────────────────────────────────────────────
306
+ _validateUpgradeHeaders(req, socket) {
307
+ const h = req.headers ?? {};
308
+ if (req.method !== "GET") {
309
+ this._abortHandshake(socket, 405);
310
+ return false;
311
+ }
312
+ if ((h["upgrade"] ?? "").toLowerCase() !== "websocket") {
313
+ this._abortHandshake(socket, 400);
314
+ return false;
315
+ }
316
+ if (!WS_KEY_REGEX.test(h["sec-websocket-key"] ?? "")) {
317
+ this._abortHandshake(socket, 400);
318
+ return false;
319
+ }
320
+ const ver = Number(h["sec-websocket-version"] ?? "0");
321
+ if (ver !== 13 && ver !== 8) {
322
+ this._abortHandshake(socket, 426);
323
+ return false;
324
+ }
325
+ if (!this.shouldHandle(req)) {
326
+ this._abortHandshake(socket, 400);
327
+ return false;
328
+ }
329
+ return true;
330
+ }
331
+ _completeUpgrade(req, socket, key, cb) {
332
+ const digest = createHash("sha1").update(key + WS_GUID).digest("base64");
333
+ const responseHeaders = [
334
+ "HTTP/1.1 101 Switching Protocols",
335
+ "Upgrade: websocket",
336
+ "Connection: Upgrade",
337
+ `Sec-WebSocket-Accept: ${digest}`
338
+ ];
339
+ let selectedProtocol = null;
340
+ if (this.options.handleProtocols) {
341
+ const raw = req.headers?.["sec-websocket-protocol"] ?? "";
342
+ const offered = new Set(raw.split(",").map((s) => s.trim()).filter(Boolean));
343
+ const sel = this.options.handleProtocols(offered, this._buildVerifyClientInfoFromReq(req).req);
344
+ if (sel) {
345
+ selectedProtocol = sel;
346
+ responseHeaders.push(`Sec-WebSocket-Protocol: ${sel}`);
347
+ }
348
+ }
349
+ this.emit("headers", responseHeaders, req);
350
+ const responseStr = responseHeaders.join("\r\n") + "\r\n\r\n";
351
+ socket.write(responseStr, () => {
352
+ const ioStream = typeof socket._releaseIOStream === "function" ? socket._releaseIOStream() : null;
353
+ if (!ioStream) {
354
+ socket.destroy?.();
355
+ return;
356
+ }
357
+ const rawUrl = req.url ?? "/";
358
+ const uri = GLib.Uri.parse(`ws://localhost${rawUrl}`, GLib.UriFlags.NONE);
359
+ const conn = Soup.WebsocketConnection["new"](
360
+ ioStream,
361
+ uri,
362
+ Soup.WebsocketConnectionType.SERVER,
363
+ null,
364
+ selectedProtocol,
365
+ []
366
+ );
367
+ const ws = new ServerSideWebSocket(conn, rawUrl);
368
+ if (selectedProtocol) ws.protocol = selectedProtocol;
369
+ if (this.options.clientTracking !== false) {
370
+ this.clients.add(ws);
371
+ ws.on("close", () => this.clients.delete(ws));
372
+ }
373
+ cb(ws, req);
374
+ });
375
+ }
376
+ _abortHandshake(socket, code) {
377
+ const statusTexts = {
378
+ 400: "Bad Request",
379
+ 401: "Unauthorized",
380
+ 403: "Forbidden",
381
+ 405: "Method Not Allowed",
382
+ 426: "Upgrade Required"
383
+ };
384
+ const msg = statusTexts[code] ?? "Error";
385
+ socket.write?.(`HTTP/1.1 ${code} ${msg}\r
386
+ Content-Length: 0\r
387
+ Connection: close\r
388
+ \r
389
+ `, () => {
390
+ socket.destroy?.();
391
+ });
392
+ }
393
+ _buildVerifyClientInfoFromReq(req) {
394
+ const h = req.headers ?? {};
395
+ return {
396
+ origin: h["origin"] ?? "",
397
+ secure: false,
398
+ req: {
399
+ method: req.method ?? "GET",
400
+ url: req.url ?? "/",
401
+ headers: h,
402
+ socket: { remoteAddress: req.socket?.remoteAddress ?? "127.0.0.1", remotePort: req.socket?.remotePort ?? 0 }
403
+ }
404
+ };
405
+ }
406
+ }
407
+ export {
408
+ WebSocketServer
409
+ };