Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -1718,6 +1718,14 @@ client.connect("irc.libera.chat", {
key: keyContent,
caCerts: [caContent],
});

// With WebSocket transport (requires Node 22+)
client.connect("irc.example.com", {
port: 443,
tls: true,
websocket: true,
path: "/webirc",
});
```

### command: ctcp
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ Finally you have to establish a connection with the server:
await client.connect("irc.libera.chat");

await client.connect("irc.libera.chat", { port: 6697, tls: true });

// WebSocket transport (requires Node 22+, works on all runtimes)
await client.connect("irc.example.com", {
port: 443,
tls: true,
websocket: true,
path: "/webirc",
});
```

When using Deno, connecting to servers requires the `--allow-net` permission:
Expand Down
57 changes: 41 additions & 16 deletions core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,21 @@ const PORT = 6667;

/** Options for connecting to an IRC server. TLS fields only available when `tls` is `true`. */
export type ConnectOptions =
| { tls?: false; port?: number }
| {
tls?: false;
port?: number;
/** Enables WebSocket transport instead of TCP. Requires Node 22+. */
websocket?: boolean;
/** WebSocket endpoint path (e.g. "/webirc"). Only used when `websocket` is `true`. */
path?: string;
}
| {
tls: true;
port?: number;
/** Enables WebSocket transport instead of TCP. Requires Node 22+. */
websocket?: boolean;
/** WebSocket endpoint path (e.g. "/webirc"). Only used when `websocket` is `true`. */
path?: string;
/** PEM client certificate content. */
cert?: string;
/** PEM private key content. */
Expand All @@ -75,6 +86,8 @@ export interface RemoteAddr {
hostname: string;
port: number;
tls?: boolean;
websocket?: boolean;
path?: string;
cert?: string;
key?: string;
caCerts?: string[];
Expand Down Expand Up @@ -144,6 +157,8 @@ export class CoreClient<

const { port = PORT } = options;
const tls = options.tls ?? false;
const websocket = options.websocket ?? false;
const path = options.path;

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

this.state.remoteAddr = { hostname, port, tls, ...tlsFields };
this.state.remoteAddr = {
hostname,
port,
tls,
...(websocket && { websocket }),
...(path !== undefined && { path }),
...tlsFields,
};

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

try {
if (tls) {
const { cert, key, caCerts } = this.state.remoteAddr;
this.conn = cert && key
? await this.runtime.connectTls({
hostname,
port,
caCerts,
cert,
key,
})
: await this.runtime.connectTls({ hostname, port, caCerts });
} else {
this.conn = await this.runtime.connect({ hostname, port });
}
this.conn = await this.createConn(hostname, this.state.remoteAddr);
this.emit("connected", publicAddr);
} catch (error) {
this.emitError("connect", error);
Expand All @@ -201,6 +210,22 @@ export class CoreClient<
return this.conn;
}

/** Creates the underlying connection. Hookable by plugins to swap transport. */
async createConn(
hostname: string,
remoteAddr: RemoteAddr,
): Promise<Conn> {
const { port, tls, cert, key, caCerts } = remoteAddr;

if (tls) {
return cert && key
? await this.runtime!.connectTls({ hostname, port, caCerts, cert, key })
: await this.runtime!.connectTls({ hostname, port, caCerts });
}

return await this.runtime!.connect({ hostname, port });
}

private getPublicAddr(): PublicAddr {
const { cert: _, key: __, ...publicAddr } = this.state.remoteAddr;
return publicAddr;
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions plugins/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export { default as who } from "./who.ts";
export { default as monitor } from "./monitor.ts";
export { default as messageSplit } from "./message_split.ts";
export { default as userhostInNames } from "./userhost_in_names.ts";
export { default as websocket } from "./websocket.ts";
export { default as whois } from "./whois.ts";

import action from "./action.ts";
Expand Down Expand Up @@ -122,6 +123,7 @@ import who from "./who.ts";
import monitor from "./monitor.ts";
import messageSplit from "./message_split.ts";
import userhostInNames from "./userhost_in_names.ts";
import websocket from "./websocket.ts";
import whois from "./whois.ts";

const plugins = [
Expand Down Expand Up @@ -186,6 +188,7 @@ const plugins = [
monitor,
messageSplit,
userhostInNames,
websocket,
whois,
];

Expand Down
191 changes: 191 additions & 0 deletions plugins/websocket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// WebSocket transport for IRC, per IRCv3 WebSocket draft.
//
// Based on the original work by @aronson and @xyzshantaram:
// https://github.com/jeromeludmann/irc/issues/12
// https://github.com/jeromeludmann/irc/pull/13

import { type RemoteAddr } from "../core/client.ts";
import { type AnyPlugins, createPlugin, type Plugin } from "../core/plugins.ts";
import type { Conn } from "../runtime/types.ts";

export type WebSocketFeatures = Record<never, never>;

const SUBPROTOCOLS = ["binary.ircv3.net", "text.ircv3.net"];

function buildWebSocketUrl(
hostname: string,
port: number,
tls: boolean,
path?: string,
): string {
const protocol = tls ? "wss" : "ws";
const base = `${protocol}://${hostname}:${port}`;
const url = new URL(path ?? "/", base);
return url.href;
}

export class WebSocketConn implements Conn {
private chunks: Uint8Array[] = [];
private pendingRead: {
resolve: (n: number | null) => void;
reject: (e: Error) => void;
buffer: Uint8Array;
} | null = null;
private ended = false;
private error: Error | null = null;
private encoder = new TextEncoder();

constructor(private ws: WebSocket) {
ws.onmessage = (event) => {
let chunk: Uint8Array;

if (event.data instanceof ArrayBuffer) {
chunk = new Uint8Array(event.data);
} else if (typeof event.data === "string") {
chunk = this.encoder.encode(event.data);
} else if (event.data instanceof Blob) {
event.data.arrayBuffer().then((buf) => {
this.push(new Uint8Array(buf));
});
return;
} else {
this.fail(new Error("Unexpected WebSocket message type"));
return;
}

this.push(chunk);
};

ws.onclose = () => {
this.ended = true;

if (this.pendingRead) {
const req = this.pendingRead;
this.pendingRead = null;
req.resolve(null);
}
};

ws.onerror = () => {
this.fail(new Error("WebSocket error"));
};
}

private push(chunk: Uint8Array): void {
if (this.pendingRead) {
const req = this.pendingRead;
this.pendingRead = null;
const len = Math.min(chunk.length, req.buffer.length);
req.buffer.set(chunk.subarray(0, len));
if (chunk.length > req.buffer.length) {
this.chunks.push(chunk.subarray(req.buffer.length));
}
req.resolve(len);
} else {
this.chunks.push(chunk);
}
}

private fail(error: Error): void {
this.error = error;
this.ended = true;

if (this.pendingRead) {
const req = this.pendingRead;
this.pendingRead = null;
req.reject(error);
}
}

read(buffer: Uint8Array): Promise<number | null> {
if (this.chunks.length > 0) {
const chunk = this.chunks.shift()!;
const len = Math.min(chunk.length, buffer.length);
buffer.set(chunk.subarray(0, len));
if (chunk.length > buffer.length) {
this.chunks.unshift(chunk.subarray(buffer.length));
}
return Promise.resolve(len);
}
if (this.error) return Promise.reject(this.error);
if (this.ended) return Promise.resolve(null);

return new Promise((resolve, reject) => {
this.pendingRead = { resolve, reject, buffer };
});
}

write(bytes: Uint8Array): Promise<number> {
this.ws.send(bytes);
return Promise.resolve(bytes.length);
}

close(): void {
this.ws.close();
}
}

function connectWebSocket(
hostname: string,
remoteAddr: RemoteAddr,
): Promise<Conn> {
const url = buildWebSocketUrl(
hostname,
remoteAddr.port,
!!remoteAddr.tls,
remoteAddr.path,
);
const ws = new WebSocket(url, SUBPROTOCOLS);
ws.binaryType = "arraybuffer";

return new Promise<Conn>((resolve, reject) => {
let settled = false;
ws.onopen = () => {
settled = true;
resolve(new WebSocketConn(ws));
};
ws.onerror = () => {
if (!settled) {
settled = true;
reject(new Error(`WebSocket connection to ${url} failed`));
}
};
ws.onclose = () => {
if (!settled) {
settled = true;
reject(new Error(`WebSocket connection to ${url} closed before open`));
}
};
});
}

const plugin: Plugin<WebSocketFeatures, AnyPlugins> = createPlugin(
"websocket",
[],
)((client, _options) => {
client.hooks.beforeCall("connect", (_hostname, options = {}) => {
if (options.websocket && options.port === undefined) {
throw new Error("WebSocket requires an explicit port");
}
});

client.hooks.hookCall(
"createConn",
(originalCreateConn, hostname, remoteAddr) => {
if (!remoteAddr.websocket) {
return originalCreateConn(hostname, remoteAddr);
}

if (typeof globalThis.WebSocket === "undefined") {
throw new Error(
"WebSocket transport requires a runtime with WebSocket global " +
"(Node 22+, Deno, Bun, or browser)",
);
}

return connectWebSocket(hostname, remoteAddr);
},
);
});

export default plugin;
Loading
Loading