Skip to content

Commit 4b4278b

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

7 files changed

Lines changed: 460 additions & 18 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;

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

0 commit comments

Comments
 (0)