diff --git a/API.md b/API.md index a65ad9f..1c7b669 100644 --- a/API.md +++ b/API.md @@ -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 diff --git a/README.md b/README.md index 82caa1b..cfbd396 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/core/client.ts b/core/client.ts index 3c5c807..0eeb7fc 100644 --- a/core/client.ts +++ b/core/client.ts @@ -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. */ @@ -75,6 +86,8 @@ export interface RemoteAddr { hostname: string; port: number; tls?: boolean; + websocket?: boolean; + path?: string; cert?: string; key?: string; caCerts?: string[]; @@ -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) { @@ -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(); @@ -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); @@ -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 { + 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; diff --git a/package-lock.json b/package-lock.json index 32a786d..1334bd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "irc-client-ts", - "version": "0.23.0", + "version": "0.23.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "irc-client-ts", - "version": "0.23.0", + "version": "0.23.1", "license": "MIT", "devDependencies": { "@std/assert": "npm:@jsr/std__assert@^1.0.19", diff --git a/plugins/mod.ts b/plugins/mod.ts index 8f161d0..f8a5b70 100644 --- a/plugins/mod.ts +++ b/plugins/mod.ts @@ -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"; @@ -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 = [ @@ -186,6 +188,7 @@ const plugins = [ monitor, messageSplit, userhostInNames, + websocket, whois, ]; diff --git a/plugins/websocket.ts b/plugins/websocket.ts new file mode 100644 index 0000000..487c712 --- /dev/null +++ b/plugins/websocket.ts @@ -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; + +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 { + 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 { + this.ws.send(bytes); + return Promise.resolve(bytes.length); + } + + close(): void { + this.ws.close(); + } +} + +function connectWebSocket( + hostname: string, + remoteAddr: RemoteAddr, +): Promise { + const url = buildWebSocketUrl( + hostname, + remoteAddr.port, + !!remoteAddr.tls, + remoteAddr.path, + ); + const ws = new WebSocket(url, SUBPROTOCOLS); + ws.binaryType = "arraybuffer"; + + return new Promise((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 = 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; diff --git a/plugins/websocket_test.ts b/plugins/websocket_test.ts new file mode 100644 index 0000000..abf388b --- /dev/null +++ b/plugins/websocket_test.ts @@ -0,0 +1,207 @@ +import { assertEquals, assertRejects, assertThrows } from "@std/assert"; +import { describe } from "../testing/helpers.ts"; +import { WebSocketConn } from "./websocket.ts"; +import { mock } from "../testing/mock.ts"; + +class MockWebSocket extends EventTarget { + binaryType = "arraybuffer"; + readyState = 1; + protocol = "binary.ircv3.net"; + + onopen: ((ev: Event) => void) | null = null; + onclose: ((ev: CloseEvent) => void) | null = null; + onerror: ((ev: Event) => void) | null = null; + onmessage: ((ev: MessageEvent) => void) | null = null; + + sent: unknown[] = []; + closed = false; + + send(data: unknown): void { + this.sent.push(data); + } + + close(): void { + this.closed = true; + } + + simulateMessage(data: ArrayBuffer | string | Blob): void { + this.onmessage?.(new MessageEvent("message", { data })); + } + + simulateClose(): void { + this.onclose?.({ type: "close" } as CloseEvent); + } + + simulateError(): void { + this.onerror?.(new Event("error")); + } +} + +describe("plugins/websocket", (test) => { + test("WebSocketConn read receives ArrayBuffer data", async () => { + const ws = new MockWebSocket(); + const conn = new WebSocketConn(ws as unknown as WebSocket); + + const encoder = new TextEncoder(); + const data = encoder.encode("PING :test\r\n"); + ws.simulateMessage(data.buffer as ArrayBuffer); + + const buffer = new Uint8Array(4096); + const n = await conn.read(buffer); + + assertEquals(n, data.length); + assertEquals(buffer.subarray(0, n!), data); + }); + + test("WebSocketConn read receives string data", async () => { + const ws = new MockWebSocket(); + const conn = new WebSocketConn(ws as unknown as WebSocket); + + ws.simulateMessage("PING :test\r\n"); + + const buffer = new Uint8Array(4096); + const n = await conn.read(buffer); + + const encoder = new TextEncoder(); + const expected = encoder.encode("PING :test\r\n"); + assertEquals(n, expected.length); + assertEquals(buffer.subarray(0, n!), expected); + }); + + test("WebSocketConn read receives Blob data", async () => { + const ws = new MockWebSocket(); + const conn = new WebSocketConn(ws as unknown as WebSocket); + + const encoder = new TextEncoder(); + const data = encoder.encode("PING :test\r\n"); + const blob = new Blob([data]); + ws.simulateMessage(blob); + + const buffer = new Uint8Array(4096); + const n = await conn.read(buffer); + + assertEquals(n, data.length); + assertEquals(buffer.subarray(0, n!), data); + }); + + test("WebSocketConn read returns null on close", async () => { + const ws = new MockWebSocket(); + const conn = new WebSocketConn(ws as unknown as WebSocket); + + ws.simulateClose(); + + const buffer = new Uint8Array(4096); + const n = await conn.read(buffer); + + assertEquals(n, null); + }); + + test("WebSocketConn read rejects on error", async () => { + const ws = new MockWebSocket(); + const conn = new WebSocketConn(ws as unknown as WebSocket); + + ws.simulateError(); + + const buffer = new Uint8Array(4096); + await assertRejects( + () => conn.read(buffer), + Error, + "WebSocket error", + ); + }); + + test("WebSocketConn read rejects after error, not null", async () => { + const ws = new MockWebSocket(); + const conn = new WebSocketConn(ws as unknown as WebSocket); + + ws.simulateError(); + + const buffer = new Uint8Array(4096); + await assertRejects( + () => conn.read(buffer), + Error, + "WebSocket error", + ); + + await assertRejects( + () => conn.read(buffer), + Error, + "WebSocket error", + ); + }); + + test("WebSocketConn read pending rejects on error", async () => { + const ws = new MockWebSocket(); + const conn = new WebSocketConn(ws as unknown as WebSocket); + + const buffer = new Uint8Array(4096); + const readPromise = conn.read(buffer); + + ws.simulateError(); + + await assertRejects( + () => readPromise, + Error, + "WebSocket error", + ); + }); + + test("WebSocketConn read pending resolves null on close", async () => { + const ws = new MockWebSocket(); + const conn = new WebSocketConn(ws as unknown as WebSocket); + + const buffer = new Uint8Array(4096); + const readPromise = conn.read(buffer); + + ws.simulateClose(); + + const n = await readPromise; + assertEquals(n, null); + }); + + test("WebSocketConn write sends bytes", async () => { + const ws = new MockWebSocket(); + const conn = new WebSocketConn(ws as unknown as WebSocket); + + const encoder = new TextEncoder(); + const bytes = encoder.encode("PRIVMSG #test :hello\r\n"); + const n = await conn.write(bytes); + + assertEquals(n, bytes.length); + assertEquals(ws.sent.length, 1); + assertEquals(ws.sent[0], bytes); + }); + + test("WebSocketConn close calls ws.close", () => { + const ws = new MockWebSocket(); + const conn = new WebSocketConn(ws as unknown as WebSocket); + + conn.close(); + + assertEquals(ws.closed, true); + }); + + test("throw if websocket: true without port", async () => { + const { client } = await mock( + {}, + { withConnection: false }, + ); + + assertThrows( + () => { + client.connect("irc.example.com", { websocket: true }); + }, + Error, + "WebSocket requires an explicit port", + ); + }); + + test("no-op when websocket is not set", async () => { + const { client, server } = await mock(); + + const raw = server.receive(); + assertEquals(raw.length, 0); + + assertEquals(client.state.remoteAddr.websocket, undefined); + }); +});