Skip to content

Commit 37c4b87

Browse files
committed
feat(plugins/websocket): add WebSocket transport support
1 parent e3adf94 commit 37c4b87

6 files changed

Lines changed: 452 additions & 16 deletions

File tree

API.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1718,6 +1718,14 @@ client.connect("irc.libera.chat", {
17181718
key: keyContent,
17191719
caCerts: [caContent],
17201720
});
1721+
1722+
// With WebSocket transport (requires Node 22+)
1723+
client.connect("irc.example.com", {
1724+
port: 443,
1725+
tls: true,
1726+
websocket: true,
1727+
path: "/webirc",
1728+
});
17211729
```
17221730

17231731
### command: ctcp

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ Finally you have to establish a connection with the server:
9090
await client.connect("irc.libera.chat");
9191

9292
await client.connect("irc.libera.chat", { port: 6697, tls: true });
93+
94+
// WebSocket transport (requires Node 22+, works on all runtimes)
95+
await client.connect("irc.example.com", {
96+
port: 443,
97+
tls: true,
98+
websocket: true,
99+
path: "/webirc",
100+
});
93101
```
94102

95103
When using Deno, connecting to servers requires the `--allow-net` permission:

core/client.ts

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,21 @@ const PORT = 6667;
5252

5353
/** Options for connecting to an IRC server. TLS fields only available when `tls` is `true`. */
5454
export type ConnectOptions =
55-
| { tls?: false; port?: number }
55+
| {
56+
tls?: false;
57+
port?: number;
58+
/** Enables WebSocket transport instead of TCP. Requires Node 22+. */
59+
websocket?: boolean;
60+
/** WebSocket endpoint path (e.g. "/webirc"). Only used when `websocket` is `true`. */
61+
path?: string;
62+
}
5663
| {
5764
tls: true;
5865
port?: number;
66+
/** Enables WebSocket transport instead of TCP. Requires Node 22+. */
67+
websocket?: boolean;
68+
/** WebSocket endpoint path (e.g. "/webirc"). Only used when `websocket` is `true`. */
69+
path?: string;
5970
/** PEM client certificate content. */
6071
cert?: string;
6172
/** PEM private key content. */
@@ -75,6 +86,8 @@ export interface RemoteAddr {
7586
hostname: string;
7687
port: number;
7788
tls?: boolean;
89+
websocket?: boolean;
90+
path?: string;
7891
cert?: string;
7992
key?: string;
8093
caCerts?: string[];
@@ -144,6 +157,8 @@ export class CoreClient<
144157

145158
const { port = PORT } = options;
146159
const tls = options.tls ?? false;
160+
const websocket = options.websocket ?? false;
161+
const path = options.path;
147162

148163
let tlsFields: { cert?: string; key?: string; caCerts?: string[] } = {};
149164
if (options.tls) {
@@ -166,7 +181,14 @@ export class CoreClient<
166181
};
167182
}
168183

169-
this.state.remoteAddr = { hostname, port, tls, ...tlsFields };
184+
this.state.remoteAddr = {
185+
hostname,
186+
port,
187+
tls,
188+
...(websocket && { websocket }),
189+
...(path !== undefined && { path }),
190+
...tlsFields,
191+
};
170192

171193
if (this.conn !== null) {
172194
this.close();
@@ -176,20 +198,7 @@ export class CoreClient<
176198
this.emit("connecting", publicAddr);
177199

178200
try {
179-
if (tls) {
180-
const { cert, key, caCerts } = this.state.remoteAddr;
181-
this.conn = cert && key
182-
? await this.runtime.connectTls({
183-
hostname,
184-
port,
185-
caCerts,
186-
cert,
187-
key,
188-
})
189-
: await this.runtime.connectTls({ hostname, port, caCerts });
190-
} else {
191-
this.conn = await this.runtime.connect({ hostname, port });
192-
}
201+
this.conn = await this.createConn(hostname, this.state.remoteAddr);
193202
this.emit("connected", publicAddr);
194203
} catch (error) {
195204
this.emitError("connect", error);
@@ -201,6 +210,22 @@ export class CoreClient<
201210
return this.conn;
202211
}
203212

213+
/** Creates the underlying connection. Hookable by plugins to swap transport. */
214+
async createConn(
215+
hostname: string,
216+
remoteAddr: RemoteAddr,
217+
): Promise<Conn> {
218+
const { port, tls, cert, key, caCerts } = remoteAddr;
219+
220+
if (tls) {
221+
return cert && key
222+
? await this.runtime!.connectTls({ hostname, port, caCerts, cert, key })
223+
: await this.runtime!.connectTls({ hostname, port, caCerts });
224+
}
225+
226+
return await this.runtime!.connect({ hostname, port });
227+
}
228+
204229
private getPublicAddr(): PublicAddr {
205230
const { cert: _, key: __, ...publicAddr } = this.state.remoteAddr;
206231
return publicAddr;

plugins/mod.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export { default as who } from "./who.ts";
5959
export { default as monitor } from "./monitor.ts";
6060
export { default as messageSplit } from "./message_split.ts";
6161
export { default as userhostInNames } from "./userhost_in_names.ts";
62+
export { default as websocket } from "./websocket.ts";
6263
export { default as whois } from "./whois.ts";
6364

6465
import action from "./action.ts";
@@ -122,6 +123,7 @@ import who from "./who.ts";
122123
import monitor from "./monitor.ts";
123124
import messageSplit from "./message_split.ts";
124125
import userhostInNames from "./userhost_in_names.ts";
126+
import websocket from "./websocket.ts";
125127
import whois from "./whois.ts";
126128

127129
const plugins = [
@@ -186,6 +188,7 @@ const plugins = [
186188
monitor,
187189
messageSplit,
188190
userhostInNames,
191+
websocket,
189192
whois,
190193
];
191194

plugins/websocket.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { type RemoteAddr } from "../core/client.ts";
2+
import { type AnyPlugins, createPlugin, type Plugin } from "../core/plugins.ts";
3+
import type { Conn } from "../runtime/types.ts";
4+
5+
export type WebSocketFeatures = Record<never, never>;
6+
7+
const SUBPROTOCOLS = ["binary.ircv3.net", "text.ircv3.net"];
8+
9+
function buildWebSocketUrl(
10+
hostname: string,
11+
port: number,
12+
tls: boolean,
13+
path?: string,
14+
): string {
15+
const protocol = tls ? "wss" : "ws";
16+
const base = `${protocol}://${hostname}:${port}`;
17+
const url = new URL(path ?? "/", base);
18+
return url.href;
19+
}
20+
21+
export class WebSocketConn implements Conn {
22+
private chunks: Uint8Array[] = [];
23+
private pendingRead: {
24+
resolve: (n: number | null) => void;
25+
reject: (e: Error) => void;
26+
buffer: Uint8Array;
27+
} | null = null;
28+
private ended = false;
29+
private error: Error | null = null;
30+
private encoder = new TextEncoder();
31+
32+
constructor(private ws: WebSocket) {
33+
ws.onmessage = (event) => {
34+
let chunk: Uint8Array;
35+
36+
if (event.data instanceof ArrayBuffer) {
37+
chunk = new Uint8Array(event.data);
38+
} else if (typeof event.data === "string") {
39+
chunk = this.encoder.encode(event.data);
40+
} else if (event.data instanceof Blob) {
41+
event.data.arrayBuffer().then((buf) => {
42+
this.push(new Uint8Array(buf));
43+
});
44+
return;
45+
} else {
46+
this.fail(new Error("Unexpected WebSocket message type"));
47+
return;
48+
}
49+
50+
this.push(chunk);
51+
};
52+
53+
ws.onclose = () => {
54+
this.ended = true;
55+
56+
if (this.pendingRead) {
57+
const req = this.pendingRead;
58+
this.pendingRead = null;
59+
req.resolve(null);
60+
}
61+
};
62+
63+
ws.onerror = () => {
64+
this.fail(new Error("WebSocket error"));
65+
};
66+
}
67+
68+
private push(chunk: Uint8Array): void {
69+
if (this.pendingRead) {
70+
const req = this.pendingRead;
71+
this.pendingRead = null;
72+
const len = Math.min(chunk.length, req.buffer.length);
73+
req.buffer.set(chunk.subarray(0, len));
74+
if (chunk.length > req.buffer.length) {
75+
this.chunks.push(chunk.subarray(req.buffer.length));
76+
}
77+
req.resolve(len);
78+
} else {
79+
this.chunks.push(chunk);
80+
}
81+
}
82+
83+
private fail(error: Error): void {
84+
this.error = error;
85+
this.ended = true;
86+
87+
if (this.pendingRead) {
88+
const req = this.pendingRead;
89+
this.pendingRead = null;
90+
req.reject(error);
91+
}
92+
}
93+
94+
read(buffer: Uint8Array): Promise<number | null> {
95+
if (this.chunks.length > 0) {
96+
const chunk = this.chunks.shift()!;
97+
const len = Math.min(chunk.length, buffer.length);
98+
buffer.set(chunk.subarray(0, len));
99+
if (chunk.length > buffer.length) {
100+
this.chunks.unshift(chunk.subarray(buffer.length));
101+
}
102+
return Promise.resolve(len);
103+
}
104+
if (this.error) return Promise.reject(this.error);
105+
if (this.ended) return Promise.resolve(null);
106+
107+
return new Promise((resolve, reject) => {
108+
this.pendingRead = { resolve, reject, buffer };
109+
});
110+
}
111+
112+
write(bytes: Uint8Array): Promise<number> {
113+
this.ws.send(bytes);
114+
return Promise.resolve(bytes.length);
115+
}
116+
117+
close(): void {
118+
this.ws.close();
119+
}
120+
}
121+
122+
function connectWebSocket(
123+
hostname: string,
124+
remoteAddr: RemoteAddr,
125+
): Promise<Conn> {
126+
const url = buildWebSocketUrl(
127+
hostname,
128+
remoteAddr.port,
129+
!!remoteAddr.tls,
130+
remoteAddr.path,
131+
);
132+
const ws = new WebSocket(url, SUBPROTOCOLS);
133+
ws.binaryType = "arraybuffer";
134+
135+
return new Promise<Conn>((resolve, reject) => {
136+
let settled = false;
137+
ws.onopen = () => {
138+
settled = true;
139+
resolve(new WebSocketConn(ws));
140+
};
141+
ws.onerror = () => {
142+
if (!settled) {
143+
settled = true;
144+
reject(new Error(`WebSocket connection to ${url} failed`));
145+
}
146+
};
147+
ws.onclose = () => {
148+
if (!settled) {
149+
settled = true;
150+
reject(new Error(`WebSocket connection to ${url} closed before open`));
151+
}
152+
};
153+
});
154+
}
155+
156+
const plugin: Plugin<WebSocketFeatures, AnyPlugins> = createPlugin(
157+
"websocket",
158+
[],
159+
)((client, _options) => {
160+
client.hooks.beforeCall("connect", (_hostname, options = {}) => {
161+
if (options.websocket && options.port === undefined) {
162+
throw new Error("WebSocket requires an explicit port");
163+
}
164+
});
165+
166+
client.hooks.hookCall(
167+
"createConn",
168+
(originalCreateConn, hostname, remoteAddr) => {
169+
if (!remoteAddr.websocket) {
170+
return originalCreateConn(hostname, remoteAddr);
171+
}
172+
173+
if (typeof globalThis.WebSocket === "undefined") {
174+
throw new Error(
175+
"WebSocket transport requires a runtime with WebSocket global " +
176+
"(Node 22+, Deno, Bun, or browser)",
177+
);
178+
}
179+
180+
return connectWebSocket(hostname, remoteAddr);
181+
},
182+
);
183+
});
184+
185+
export default plugin;

0 commit comments

Comments
 (0)