@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.
- package/lib/esm/constants.js +16 -0
- package/lib/esm/index.js +15 -0
- package/lib/esm/stream.js +89 -0
- package/lib/esm/websocket-server.js +409 -0
- package/lib/esm/websocket.js +249 -0
- package/lib/types/constants.d.ts +9 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/index.spec.d.ts +2 -0
- package/lib/types/stream.d.ts +2 -0
- package/lib/types/stream.spec.d.ts +2 -0
- package/lib/types/websocket-server.d.ts +119 -0
- package/lib/types/websocket-server.spec.d.ts +2 -0
- package/lib/types/websocket.d.ts +99 -0
- package/package.json +49 -0
- package/src/constants.ts +15 -0
- package/src/index.spec.ts +144 -0
- package/src/index.ts +25 -0
- package/src/stream.spec.ts +121 -0
- package/src/stream.ts +107 -0
- package/src/test.mts +5 -0
- package/src/websocket-server.spec.ts +354 -0
- package/src/websocket-server.ts +561 -0
- package/src/websocket.ts +391 -0
- package/tsconfig.json +29 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -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
|
+
};
|
package/lib/esm/index.js
ADDED
|
@@ -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
|
+
};
|