diff --git a/API.md b/API.md index 53f155ff..e7928f5b 100644 --- a/API.md +++ b/API.md @@ -18,6 +18,7 @@ - [option: serverPassword](#option-serverpassword) - [option: username](#option-username) - [option: verbose](#option-verbose) + - [option: websocket](#option-websocket) - [Events](#events) - [event: away_reply](#event-away_reply) - [event: connecting](#event-connecting) @@ -421,6 +422,18 @@ const client = new Client({ }); ``` +### option: websocket + +Enables alternate WebSocket transport per IRCv3 spec. + +```ts +const client = new Client({ + websocket: true, +}); +``` + +Default to `false` (use TCP) + ## Events Events are simple messages which are emitted from the client instance. @@ -1182,7 +1195,9 @@ If `tls=true`, attempts to connect using a TLS connection. Resolves when connected. -`async connect(hostname: string, port: number, tls?: boolean): Promise` +`async connect(hostname: string, port: number, tls?: boolean, path?: string): Promise` + +Note: `path` parameter is ignored when websocket feature not enabled. ```ts client.connect("host", 6667); @@ -1190,6 +1205,17 @@ client.connect("host", 6667); client.connect("host", 7000, true); // with TLS ``` +When `websocket` feature enabled defaults to port 80, or 443 if `tls=true`. + +When `websocket` feature enabled, also accepts `path` parameter as string. + +```ts +// Example remote endpoint URL +const remoteUrl = "wss://irc.example.org:8097/pathTo/Irc"; +// Passing said endpoint URL to connect +client.connect("irc.example.org", 8097, true, "pathTo/Irc"); +``` + ### command: ctcp Sends a CTCP message to a `target` with a `command` and a `param`. diff --git a/client.ts b/client.ts index ab78ee79..0aa1411b 100644 --- a/client.ts +++ b/client.ts @@ -45,6 +45,7 @@ import topic from "./plugins/topic.ts"; import usermodes from "./plugins/usermodes.ts"; import verbose from "./plugins/verbose.ts"; import version from "./plugins/version.ts"; +import websocket from "./plugins/websocket.ts"; import whois from "./plugins/whois.ts"; const plugins = [ @@ -90,6 +91,7 @@ const plugins = [ usermodes, verbose, version, + websocket, whois, ]; diff --git a/core/client.ts b/core/client.ts index e784c427..97eefbd3 100644 --- a/core/client.ts +++ b/core/client.ts @@ -13,6 +13,38 @@ import { type AnyRawEventName = `raw:${AnyCommand | AnyReply | AnyError}`; +/** Removes undefined trailing parameters from send() input. */ +export function removeUndefinedParameters(params: (string | undefined)[]) { + for (let i = params.length - 1; i >= 0; --i) { + params[i] === undefined ? params.pop() : i = 0; + } +} + +/** Prefixes trailing parameter with ':'. */ +export function prefixTrailingParameter(params: (string | undefined)[]) { + const last = params.length - 1; + if ( + params.length > 0 && + (params[last]?.[0] === ":" || params[last]?.includes(" ", 1)) + ) { + params[last] = ":" + params[last]; + } +} + +/** Prepares and encodes raw message. */ +export function encodeRawMessage( + command: string, + params: (string | undefined)[], + encoder: TextEncoder, + skipSuffix?: boolean, +) { + const raw = (command + " " + params.join(" ")).trimEnd() + + (skipSuffix ? "" : "\r\n"); + const bytes = encoder.encode(raw); + const tuple: [raw: string, bytes: Uint8Array] = [raw, bytes]; + return tuple; +} + export interface CoreFeatures { options: EventEmitterOptions & { /** Size of the buffer that receives data from server. @@ -53,6 +85,7 @@ export interface RemoteAddr { hostname: string; port: number; tls?: boolean; + path?: string; } /** How to connect to a server */ @@ -74,9 +107,9 @@ export class CoreClient< protected conn: Deno.Conn | null = null; protected hooks = new Hooks>(this); - private decoder = new TextDecoder(); - private encoder = new TextEncoder(); - private parser = new Parser(); + readonly decoder = new TextDecoder(); + readonly encoder = new TextEncoder(); + readonly parser = new Parser(); private buffer: Uint8Array; constructor( @@ -115,6 +148,7 @@ export class CoreClient< hostname: string, port = PORT, tls = false, + _path?: string, ): Promise { this.state.remoteAddr = { hostname, port, tls }; @@ -200,24 +234,13 @@ export class CoreClient< return null; } - // Removes undefined trailing parameters. - for (let i = params.length - 1; i >= 0; --i) { - params[i] === undefined ? params.pop() : i = 0; - } + removeUndefinedParameters(params); - // Prefixes trailing parameter with ':'. - const last = params.length - 1; - if ( - params.length > 0 && - (params[last]?.[0] === ":" || params[last]?.includes(" ", 1)) - ) { - params[last] = ":" + params[last]; - } + prefixTrailingParameter(params); - // Prepares and encodes raw message. - const raw = (command + " " + params.join(" ")).trimEnd() + "\r\n"; - const bytes = this.encoder.encode(raw); + const [raw, bytes] = encodeRawMessage(command, params, this.encoder); + // Prepares and encodes raw message. try { await this.conn.write(bytes); return raw; diff --git a/core/parsers.ts b/core/parsers.ts index c6ccddb2..93eab8ff 100644 --- a/core/parsers.ts +++ b/core/parsers.ts @@ -60,7 +60,7 @@ export function parseSource(prefix: string): Source { // The following is called on each received raw message // and must favor performance over readability. -function parseMessage(raw: string): Raw { +export function parseMessage(raw: string): Raw { const msg = {} as Raw; // Indexes used to move through the raw string diff --git a/deps.ts b/deps.ts index 260d46da..ffd421f4 100644 --- a/deps.ts +++ b/deps.ts @@ -10,7 +10,9 @@ export { assertArrayIncludes } from "https://deno.land/std@0.203.0/assert/assert export { assertEquals } from "https://deno.land/std@0.203.0/assert/assert_equals.ts"; export { assertExists } from "https://deno.land/std@0.203.0/assert/assert_exists.ts"; export { assertMatch } from "https://deno.land/std@0.203.0/assert/assert_match.ts"; +export { assertNotEquals } from "https://deno.land/std@0.203.0/assert/assert_not_equals.ts"; export { assertRejects } from "https://deno.land/std@0.203.0/assert/assert_rejects.ts"; export { assertThrows } from "https://deno.land/std@0.203.0/assert/assert_throws.ts"; export { Queue } from "https://deno.land/x/queue@1.2.0/mod.ts"; + diff --git a/plugins/antiflood_test.ts b/plugins/antiflood_test.ts index d3cf9903..a52ea74d 100644 --- a/plugins/antiflood_test.ts +++ b/plugins/antiflood_test.ts @@ -4,7 +4,7 @@ import { mock } from "../testing/mock.ts"; describe("plugins/antiflood", (test) => { test("send two PRIVMSG with delay", async () => { - const { client, server } = await mock({ floodDelay: 250 }); + const { client, server } = await mock({ floodDelay: 50 }); client.privmsg("#channel", "Hello world"); client.privmsg("#channel", "Hello world, again"); @@ -16,7 +16,7 @@ describe("plugins/antiflood", (test) => { ]); // Wait for second message to make it through - await delay(1000); + await delay(200); raw = server.receive(); // Second message now dispatched to server diff --git a/plugins/websocket.ts b/plugins/websocket.ts new file mode 100644 index 00000000..8d1ccfa6 --- /dev/null +++ b/plugins/websocket.ts @@ -0,0 +1,124 @@ +import { + encodeRawMessage, + prefixTrailingParameter, + removeUndefinedParameters, +} from "../core/client.ts"; +import { parseMessage } from "../core/parsers.ts"; +import { createPlugin } from "../core/plugins.ts"; + +interface WebsocketFeatures { + options: { + websocket?: boolean; + }; +} + +const INSECURE_PORT = 80; +const TLS_PORT = 443; +const TEXT_PROTOCOL = "text.ircv3.net"; +const BINARY_PROTOCOL = "binary.ircv3.net"; + +export default createPlugin("websocket", [])( + (client, options) => { + if (!options.websocket) return; + + let websocket: WebSocket | null = null; + + const openHandler = () => { + client.emit("connected", client.state.remoteAddr); + }; + + const messageHandler = (message: MessageEvent) => { + try { + const msg = parseMessage( + websocket?.protocol === BINARY_PROTOCOL + ? client.decoder.decode(new Uint8Array(message.data)) + : message.data, + ); + client.emit(`raw:${msg.command}`, msg); + } catch (error) { + client.emitError("read", error); + } + }; + + const errorHandler = (event: Event) => { + client.emitError("read", new Error(event.toString())); + }; + + client.hooks.hookCall("connect", (_, hostname, port, tls, path) => { + port = port ?? (tls ? TLS_PORT : INSECURE_PORT); + const websocketPrefix = tls ? "wss://" : "ws://"; + const websocketUrl = new URL( + `${websocketPrefix}${hostname}:${port}${path ? "/" + path : ""}`, + ); + if (websocket !== null) { + websocket.close(1000); + } + + client.state.remoteAddr = { + hostname: websocketUrl.hostname, + port, + tls, + path: websocketUrl.pathname, + }; + const { remoteAddr } = client.state; + client.emit("connecting", remoteAddr); + + try { + websocket = new WebSocket(websocketUrl, [ + BINARY_PROTOCOL, + TEXT_PROTOCOL, + ]); + websocket.binaryType = "arraybuffer"; + websocket.addEventListener("error", errorHandler); + websocket.addEventListener("open", openHandler); + websocket.addEventListener("message", messageHandler); + } catch (error) { + client.emitError("connect", error); + return null; + } + + return null; + }); + + client.hooks.hookCall("send", (_, command, ...params) => { + if (websocket === null) { + client.emitError("write", "Unable to send message", client.send); + return null; + } + + removeUndefinedParameters(params); + + prefixTrailingParameter(params); + + const [raw, bytes] = encodeRawMessage( + command, + params, + client.encoder, + true, + ); + + try { + if (websocket.protocol === BINARY_PROTOCOL) { + websocket.send(bytes); + } else { + websocket.send(raw); + } + return raw; + } catch (error) { + client.emitError("write", error); + return null; + } + }); + + client.hooks.hookCall("disconnect", () => { + try { + websocket?.close(1000); + client.emit("disconnected", client.state.remoteAddr); + } catch (error) { + client.emitError("close", error); + } finally { + websocket = null; + } + }); + }, +); diff --git a/plugins/websocket_test.ts b/plugins/websocket_test.ts new file mode 100644 index 00000000..77c808ea --- /dev/null +++ b/plugins/websocket_test.ts @@ -0,0 +1,118 @@ +import { MockClient } from "./../testing/client.ts"; +import { Server as MockWebSocketServer } from "npm:mock-socket"; +import { assertEquals, assertNotEquals } from "../deps.ts"; +import { delay, describe } from "../testing/helpers.ts"; + +describe("plugins/websocket", (test) => { + const fakeHost = "localhost"; + const fakePath = "irc"; + const fakeUrl = `ws://${fakeHost}/${fakePath}`; + const clientMessages = [ + "NICK me", + "USER me 0 * me", + "CAP REQ multi-prefix", + "CAP END", + ]; + const serverMessage = ":localhost 001 me :Hello from the server, me"; + const decoder = new TextDecoder(); + test("Connects to server and attempts to register", async () => { + const client = new MockClient({ + nick: "me", + websocket: true, + }); + const server = new MockWebSocketServer(fakeUrl); + + let messageCounter = 0; + server.on("connection", (socket) => { + socket.on("message", (payload) => { + const array = payload as Uint8Array; + const data = decoder.decode(array); + assertEquals(data, clientMessages[messageCounter++]); + // Send rpl_welcome when client is done + if (messageCounter >= 4) { + socket.send(serverMessage); + } + }); + }); + + await client.connect(fakeHost, undefined, undefined, fakePath); + // Required to execute websocket micro tasks + await delay(50); + + server.close(); + client.disconnect(); + }); + + test("Handles text and TLS protocols", async () => { + const secureHost = "localhost"; + const secureUrl = "wss://localhost"; + const client = new MockClient({ + nick: "me", + websocket: true, + }); + const server = new MockWebSocketServer(secureUrl, { + selectProtocol: () => "text.ircv3.net", + }); + let messageCounter = 0; + server.on("connection", (socket) => { + socket.on("message", (payload) => { + const data = payload as string; + assertEquals(data, clientMessages[messageCounter++]); + // Send rpl_welcome when client is done + if (messageCounter === 4) { + socket.send(serverMessage); + messageCounter = 0; + } + }); + }); + + // Test undefined port codepath + await client.connect(secureHost, undefined, true); + // Required to execute websocket micro tasks + await delay(50); + client.disconnect(); + + // Test manual port codepath + await client.connect(secureHost, 443, true); + await delay(50); + + server.close(); + client.disconnect(); + }); + + test("Client handles and passes error states", async () => { + const client = new MockClient({ + nick: "me", + websocket: true, + }); + let server = new MockWebSocketServer(fakeUrl); + // Ensure error events are passed + const errorMessage = "failed!"; + const errorTest = (error: Error) => { + assertEquals(error.message, errorMessage); + }; + client.on("error", errorTest); + client.emitError("read", Error(errorMessage)); + await delay(25); + client.off("error", errorTest); + client.disconnect(); + // Ensure invalid state on connect handled + const connectErrorTest = (error: Error) => { + console.log(error.message); + assertNotEquals(error.message, undefined); + }; + client.on("error", connectErrorTest); + server.close(); + await client.connect(fakeHost); + await delay(25); + client.disconnect(); + // Ensure double connect works + server = new MockWebSocketServer(fakeUrl); + await client.connect(fakeHost); + await delay(25); + await client.connect(fakeHost); + await delay(25); + server.close(); + client.disconnect(); + }); +});