From f33baf88e386d2cb735d075012473d864be498ac Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Tue, 17 Oct 2023 19:01:06 -0500 Subject: [PATCH 01/16] Implement websocket listener --- client.ts | 2 + core/client.ts | 6 +-- core/parsers.ts | 2 +- deps.ts | 5 +++ plugins/websocket.ts | 103 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 plugins/websocket.ts 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..d3f81137 100644 --- a/core/client.ts +++ b/core/client.ts @@ -47,7 +47,7 @@ export function generateRawEvents< } const BUFFER_SIZE = 4096; -const PORT = 6667; +export const PORT = 6667; export interface RemoteAddr { hostname: string; @@ -75,8 +75,8 @@ export class CoreClient< protected hooks = new Hooks>(this); private decoder = new TextDecoder(); - private encoder = new TextEncoder(); - private parser = new Parser(); + readonly encoder = new TextEncoder(); + readonly parser = new Parser(); private buffer: Uint8Array; constructor( 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..9a41fbd3 100644 --- a/deps.ts +++ b/deps.ts @@ -14,3 +14,8 @@ export { assertRejects } from "https://deno.land/std@0.203.0/assert/assert_rejec 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"; + +export type { WebSocketClient } from "https://deno.land/x/websocket@v0.1.4/mod.ts"; +export { StandardWebSocketClient } from "https://deno.land/x/websocket@v0.1.4/mod.ts"; + + diff --git a/plugins/websocket.ts b/plugins/websocket.ts new file mode 100644 index 00000000..2c170f9a --- /dev/null +++ b/plugins/websocket.ts @@ -0,0 +1,103 @@ +import { PORT } from "../core/client.ts"; +import { parseMessage } from "../core/parsers.ts"; +import { createPlugin } from "../core/plugins.ts"; +import { StandardWebSocketClient, WebSocketClient } from "../deps.ts"; + +interface WebsocketFeatures { + options: { + websocket?: boolean; + }; +} + +export default createPlugin("websocket", [])( + (client, options) => { + if (!options.websocket) return; + + let websocket: WebSocketClient | null = null; + + const openHandler = () => { + client.emit("connected", client.state.remoteAddr); + }; + + const messageHandler = (message: MessageEvent) => { + try { + const msg = parseMessage(message.data); + client.emit(`raw:${msg.command}`, msg); + } catch (error) { + client.emitError("read", error); + } + }; + + const errorHandler = (error: string | symbol) => { + client.emitError("read", new Error(error.toString())); + }; + + client.hooks.hookCall("connect", async (_, hostname, port, tls) => { + port = port ?? PORT; + const websocketPrefix = tls ? "wss://" : "ws://"; + const websocketUrl = `${websocketPrefix}${hostname}:${port}`; + if (websocket !== null) { + await websocket.close(0); + } + + client.state.remoteAddr = { hostname, port, tls }; + const { remoteAddr } = client.state; + client.emit("connecting", remoteAddr); + + try { + websocket = new StandardWebSocketClient(websocketUrl); + websocket.on("error", errorHandler); + websocket.on("open", openHandler); + websocket.on("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; + } + // Removes undefined trailing parameters. + for (let i = params.length - 1; i >= 0; --i) { + params[i] === undefined ? params.pop() : i = 0; + } + + // Prefixes trailing parameter with ':'. + 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. + const raw = (command + " " + params.join(" ")).trimEnd() + "\r\n"; + const bytes = client.encoder.encode(raw); + + try { + websocket.send(bytes); + return raw; + } catch (error) { + client.emitError("write", error); + return null; + } + }); + + client.hooks.hookCall("disconnect", async () => { + try { + await websocket?.close(0); + client.emit("disconnected", client.state.remoteAddr); + } catch (error) { + client.emitError("close", error); + } finally { + websocket = null; + } + }); + }, +); From 49ff38b1ecdefc2bb73102a22e7cc6d57d46acaa Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Wed, 18 Oct 2023 18:08:11 -0500 Subject: [PATCH 02/16] Remove dependency on 3rd party library for websockets --- deps.ts | 6 ++---- plugins/websocket.ts | 30 +++++++++++++++--------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/deps.ts b/deps.ts index 9a41fbd3..8cde06ff 100644 --- a/deps.ts +++ b/deps.ts @@ -8,14 +8,12 @@ export { export { assertArrayIncludes } from "https://deno.land/std@0.203.0/assert/assert_array_includes.ts"; export { assertEquals } from "https://deno.land/std@0.203.0/assert/assert_equals.ts"; +export { assertNotEquals } from "https://deno.land/std@0.203.0/assert/assert_not_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 { 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 { on } from "https://deno.land/std@0.92.0/node/events.ts"; export { Queue } from "https://deno.land/x/queue@1.2.0/mod.ts"; -export type { WebSocketClient } from "https://deno.land/x/websocket@v0.1.4/mod.ts"; -export { StandardWebSocketClient } from "https://deno.land/x/websocket@v0.1.4/mod.ts"; - - diff --git a/plugins/websocket.ts b/plugins/websocket.ts index 2c170f9a..7ef33e3e 100644 --- a/plugins/websocket.ts +++ b/plugins/websocket.ts @@ -1,7 +1,5 @@ -import { PORT } from "../core/client.ts"; import { parseMessage } from "../core/parsers.ts"; import { createPlugin } from "../core/plugins.ts"; -import { StandardWebSocketClient, WebSocketClient } from "../deps.ts"; interface WebsocketFeatures { options: { @@ -9,11 +7,13 @@ interface WebsocketFeatures { }; } +const PORT = 8067; + export default createPlugin("websocket", [])( (client, options) => { if (!options.websocket) return; - let websocket: WebSocketClient | null = null; + let websocket: WebSocket | null = null; const openHandler = () => { client.emit("connected", client.state.remoteAddr); @@ -28,27 +28,27 @@ export default createPlugin("websocket", [])( } }; - const errorHandler = (error: string | symbol) => { - client.emitError("read", new Error(error.toString())); + const errorHandler = (event: Event) => { + client.emitError("read", new Error(event.toString())); }; - client.hooks.hookCall("connect", async (_, hostname, port, tls) => { + client.hooks.hookCall("connect", async (_, serverAndPath, port, tls) => { port = port ?? PORT; const websocketPrefix = tls ? "wss://" : "ws://"; - const websocketUrl = `${websocketPrefix}${hostname}:${port}`; + const websocketUrl = `${websocketPrefix}${serverAndPath}:${port}`; if (websocket !== null) { - await websocket.close(0); + websocket.close(1000); } - client.state.remoteAddr = { hostname, port, tls }; + client.state.remoteAddr = { hostname: serverAndPath, port, tls }; const { remoteAddr } = client.state; client.emit("connecting", remoteAddr); try { - websocket = new StandardWebSocketClient(websocketUrl); - websocket.on("error", errorHandler); - websocket.on("open", openHandler); - websocket.on("message", messageHandler); + websocket = new WebSocket(websocketUrl); + websocket.addEventListener("error", errorHandler); + websocket.addEventListener("open", openHandler); + websocket.addEventListener("message", messageHandler); } catch (error) { client.emitError("connect", error); return null; @@ -89,9 +89,9 @@ export default createPlugin("websocket", [])( } }); - client.hooks.hookCall("disconnect", async () => { + client.hooks.hookCall("disconnect", () => { try { - await websocket?.close(0); + websocket?.close(1000); client.emit("disconnected", client.state.remoteAddr); } catch (error) { client.emitError("close", error); From 6906b3692b0f71fd4e7eded87bc686ecd0f9a4b9 Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Wed, 18 Oct 2023 18:12:13 -0500 Subject: [PATCH 03/16] Remove errant async in websocket.ts --- plugins/websocket.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/websocket.ts b/plugins/websocket.ts index 7ef33e3e..6ebb1e01 100644 --- a/plugins/websocket.ts +++ b/plugins/websocket.ts @@ -32,7 +32,7 @@ export default createPlugin("websocket", [])( client.emitError("read", new Error(event.toString())); }; - client.hooks.hookCall("connect", async (_, serverAndPath, port, tls) => { + client.hooks.hookCall("connect", (_, serverAndPath, port, tls) => { port = port ?? PORT; const websocketPrefix = tls ? "wss://" : "ws://"; const websocketUrl = `${websocketPrefix}${serverAndPath}:${port}`; From f73aa9439797b0a9569cb1e3f25786cf370dc74a Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Wed, 18 Oct 2023 21:30:08 -0500 Subject: [PATCH 04/16] Implement websocket plugin register test --- deps.ts | 2 -- plugins/websocket_test.ts | 45 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 plugins/websocket_test.ts diff --git a/deps.ts b/deps.ts index 8cde06ff..92565c99 100644 --- a/deps.ts +++ b/deps.ts @@ -8,12 +8,10 @@ export { export { assertArrayIncludes } from "https://deno.land/std@0.203.0/assert/assert_array_includes.ts"; export { assertEquals } from "https://deno.land/std@0.203.0/assert/assert_equals.ts"; -export { assertNotEquals } from "https://deno.land/std@0.203.0/assert/assert_not_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 { 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 { on } from "https://deno.land/std@0.92.0/node/events.ts"; export { Queue } from "https://deno.land/x/queue@1.2.0/mod.ts"; diff --git a/plugins/websocket_test.ts b/plugins/websocket_test.ts new file mode 100644 index 00000000..7e7da92f --- /dev/null +++ b/plugins/websocket_test.ts @@ -0,0 +1,45 @@ +import { MockClient } from "./../testing/client.ts"; +import { Server as MockWebSocketServer } from "npm:mock-socket"; +import { assertEquals } from "../deps.ts"; +import { delay, describe } from "../testing/helpers.ts"; + +describe("plugins/websocket", (test) => { + test("Connects to server and attempts to register", async () => { + const clientMessages = [ + "NICK me\r\n", + "USER me 0 * me\r\n", + "CAP REQ multi-prefix\r\n", + "CAP END\r\n", + ]; + const serverMessage = ":localhost 001 me :Hello from the server, me"; + const fakeHost = "localhost"; + const fakePort = 8067; + const fakeUrl = `ws://${fakeHost}:${fakePort}`; + const decoder = new TextDecoder(); + let messageCounter = 0; + + const client = new MockClient({ + nick: "me", + websocket: true, + }); + const server = new MockWebSocketServer(fakeUrl); + 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, fakePort); + // Required to execute websocket micro tasks + await delay(50); + + server.close(); + client.disconnect(); + }); +}); From 2d240c3298a17ca0943f0836d8b10625591fd261 Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Wed, 18 Oct 2023 21:30:17 -0500 Subject: [PATCH 05/16] Speed up execution time of antiflood test --- plugins/antiflood_test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 1be3d129256caf476486ece119d0fd1b972718bc Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Wed, 18 Oct 2023 21:41:22 -0500 Subject: [PATCH 06/16] Add documentation --- API.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/API.md b/API.md index 53f155ff..8ed469c0 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. From a04c6ee82e20cdb3be289f1dc8680ab7fab186fd Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Wed, 18 Oct 2023 21:43:58 -0500 Subject: [PATCH 07/16] Remove PORT export --- core/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/client.ts b/core/client.ts index d3f81137..2e33f31f 100644 --- a/core/client.ts +++ b/core/client.ts @@ -47,7 +47,7 @@ export function generateRawEvents< } const BUFFER_SIZE = 4096; -export const PORT = 6667; +const PORT = 6667; export interface RemoteAddr { hostname: string; From d647c2e81207fad928d37549546c13c482b99561 Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Thu, 19 Oct 2023 17:06:44 -0500 Subject: [PATCH 08/16] Implement path property on remoteAddr and default to 80/443 --- core/client.ts | 1 + plugins/websocket.ts | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/core/client.ts b/core/client.ts index 2e33f31f..03670298 100644 --- a/core/client.ts +++ b/core/client.ts @@ -53,6 +53,7 @@ export interface RemoteAddr { hostname: string; port: number; tls?: boolean; + path?: string; } /** How to connect to a server */ diff --git a/plugins/websocket.ts b/plugins/websocket.ts index 6ebb1e01..0e47ea08 100644 --- a/plugins/websocket.ts +++ b/plugins/websocket.ts @@ -7,7 +7,8 @@ interface WebsocketFeatures { }; } -const PORT = 8067; +const INSECURE_PORT = 80; +const TLS_PORT = 443; export default createPlugin("websocket", [])( (client, options) => { @@ -33,14 +34,21 @@ export default createPlugin("websocket", [])( }; client.hooks.hookCall("connect", (_, serverAndPath, port, tls) => { - port = port ?? PORT; + port = port ?? tls ? TLS_PORT : INSECURE_PORT; const websocketPrefix = tls ? "wss://" : "ws://"; - const websocketUrl = `${websocketPrefix}${serverAndPath}:${port}`; + const websocketUrl = new URL( + `${websocketPrefix}${serverAndPath}:${port}`, + ); if (websocket !== null) { websocket.close(1000); } - client.state.remoteAddr = { hostname: serverAndPath, port, tls }; + client.state.remoteAddr = { + hostname: websocketUrl.hostname, + port, + tls, + path: websocketUrl.pathname, + }; const { remoteAddr } = client.state; client.emit("connecting", remoteAddr); From b0d9b84d2f96bfacb2e62a4e7f337d9e8aeadb89 Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Thu, 19 Oct 2023 17:20:09 -0500 Subject: [PATCH 09/16] Improve test coverage by extracting methods from client send() --- core/client.ts | 49 ++++++++++++++++++++++++++++++-------------- plugins/websocket.ts | 24 ++++++++-------------- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/core/client.ts b/core/client.ts index 03670298..fee177c3 100644 --- a/core/client.ts +++ b/core/client.ts @@ -13,6 +13,36 @@ 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, +) { + const raw = (command + " " + params.join(" ")).trimEnd() + "\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. @@ -201,24 +231,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/plugins/websocket.ts b/plugins/websocket.ts index 0e47ea08..5bfd83ca 100644 --- a/plugins/websocket.ts +++ b/plugins/websocket.ts @@ -1,3 +1,8 @@ +import { + encodeRawMessage, + prefixTrailingParameter, + removeUndefinedParameters, +} from "../core/client.ts"; import { parseMessage } from "../core/parsers.ts"; import { createPlugin } from "../core/plugins.ts"; @@ -70,23 +75,12 @@ export default createPlugin("websocket", [])( client.emitError("write", "Unable to send message", client.send); return null; } - // Removes undefined trailing parameters. - for (let i = params.length - 1; i >= 0; --i) { - params[i] === undefined ? params.pop() : i = 0; - } - // Prefixes trailing parameter with ':'. - const last = params.length - 1; - if ( - params.length > 0 && - (params[last]?.[0] === ":" || params[last]?.includes(" ", 1)) - ) { - params[last] = ":" + params[last]; - } + removeUndefinedParameters(params); + + prefixTrailingParameter(params); - // Prepares and encodes raw message. - const raw = (command + " " + params.join(" ")).trimEnd() + "\r\n"; - const bytes = client.encoder.encode(raw); + const [raw, bytes] = encodeRawMessage(command, params, client.encoder); try { websocket.send(bytes); From 93059208191afb09bf3bc5edb9313ca4ad116837 Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Thu, 19 Oct 2023 17:29:17 -0500 Subject: [PATCH 10/16] Update websocket test to use default port 80 --- plugins/websocket_test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/websocket_test.ts b/plugins/websocket_test.ts index 7e7da92f..fd5ab603 100644 --- a/plugins/websocket_test.ts +++ b/plugins/websocket_test.ts @@ -13,8 +13,7 @@ describe("plugins/websocket", (test) => { ]; const serverMessage = ":localhost 001 me :Hello from the server, me"; const fakeHost = "localhost"; - const fakePort = 8067; - const fakeUrl = `ws://${fakeHost}:${fakePort}`; + const fakeUrl = `ws://${fakeHost}`; const decoder = new TextDecoder(); let messageCounter = 0; @@ -35,7 +34,7 @@ describe("plugins/websocket", (test) => { }); }); - await client.connect(fakeHost, fakePort); + await client.connect(fakeHost); // Required to execute websocket micro tasks await delay(50); From b84b76febbb4771481e87ea198402584759b14bd Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Thu, 19 Oct 2023 18:13:34 -0500 Subject: [PATCH 11/16] Negotiate and implement both binary and text subprotocol --- core/client.ts | 6 ++++-- plugins/websocket.ts | 29 ++++++++++++++++++++++++----- plugins/websocket_test.ts | 8 ++++---- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/core/client.ts b/core/client.ts index fee177c3..aaec39bd 100644 --- a/core/client.ts +++ b/core/client.ts @@ -36,8 +36,10 @@ export function encodeRawMessage( command: string, params: (string | undefined)[], encoder: TextEncoder, + skipSuffix?: boolean, ) { - const raw = (command + " " + params.join(" ")).trimEnd() + "\r\n"; + const raw = (command + " " + params.join(" ")).trimEnd() + + (skipSuffix ? "" : "\r\n"); const bytes = encoder.encode(raw); const tuple: [raw: string, bytes: Uint8Array] = [raw, bytes]; return tuple; @@ -105,7 +107,7 @@ export class CoreClient< protected conn: Deno.Conn | null = null; protected hooks = new Hooks>(this); - private decoder = new TextDecoder(); + readonly decoder = new TextDecoder(); readonly encoder = new TextEncoder(); readonly parser = new Parser(); private buffer: Uint8Array; diff --git a/plugins/websocket.ts b/plugins/websocket.ts index 5bfd83ca..7f0b897a 100644 --- a/plugins/websocket.ts +++ b/plugins/websocket.ts @@ -14,6 +14,8 @@ interface WebsocketFeatures { 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) => { @@ -27,7 +29,11 @@ export default createPlugin("websocket", [])( const messageHandler = (message: MessageEvent) => { try { - const msg = parseMessage(message.data); + 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); @@ -39,7 +45,7 @@ export default createPlugin("websocket", [])( }; client.hooks.hookCall("connect", (_, serverAndPath, port, tls) => { - port = port ?? tls ? TLS_PORT : INSECURE_PORT; + port = port ?? (tls ? TLS_PORT : INSECURE_PORT); const websocketPrefix = tls ? "wss://" : "ws://"; const websocketUrl = new URL( `${websocketPrefix}${serverAndPath}:${port}`, @@ -58,7 +64,11 @@ export default createPlugin("websocket", [])( client.emit("connecting", remoteAddr); try { - websocket = new WebSocket(websocketUrl); + websocket = new WebSocket(websocketUrl, [ + BINARY_PROTOCOL, + TEXT_PROTOCOL, + ]); + websocket.binaryType = "arraybuffer"; websocket.addEventListener("error", errorHandler); websocket.addEventListener("open", openHandler); websocket.addEventListener("message", messageHandler); @@ -80,10 +90,19 @@ export default createPlugin("websocket", [])( prefixTrailingParameter(params); - const [raw, bytes] = encodeRawMessage(command, params, client.encoder); + const [raw, bytes] = encodeRawMessage( + command, + params, + client.encoder, + true, + ); try { - websocket.send(bytes); + if (websocket.protocol === BINARY_PROTOCOL) { + websocket.send(bytes); + } else { + websocket.send(raw); + } return raw; } catch (error) { client.emitError("write", error); diff --git a/plugins/websocket_test.ts b/plugins/websocket_test.ts index fd5ab603..9211ed78 100644 --- a/plugins/websocket_test.ts +++ b/plugins/websocket_test.ts @@ -6,10 +6,10 @@ import { delay, describe } from "../testing/helpers.ts"; describe("plugins/websocket", (test) => { test("Connects to server and attempts to register", async () => { const clientMessages = [ - "NICK me\r\n", - "USER me 0 * me\r\n", - "CAP REQ multi-prefix\r\n", - "CAP END\r\n", + "NICK me", + "USER me 0 * me", + "CAP REQ multi-prefix", + "CAP END", ]; const serverMessage = ":localhost 001 me :Hello from the server, me"; const fakeHost = "localhost"; From 44da7a4b3a3c244d59c66e26036459fd046e6522 Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Thu, 19 Oct 2023 19:53:27 -0500 Subject: [PATCH 12/16] Clarify documentation on default ports with websockets --- API.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/API.md b/API.md index 8ed469c0..2c446868 100644 --- a/API.md +++ b/API.md @@ -1203,6 +1203,8 @@ client.connect("host", 6667); client.connect("host", 7000, true); // with TLS ``` +When `websocket` feature enabled defaults to port 80, or 443 if `tls=true`. + ### command: ctcp Sends a CTCP message to a `target` with a `command` and a `param`. From 6d27d4cfcf4ceb4dbbda4a74dfc56c8b0ad5d919 Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Sat, 21 Oct 2023 17:31:04 -0500 Subject: [PATCH 13/16] Improve websocket unit tests --- deps.ts | 1 + plugins/websocket_test.ts | 91 +++++++++++++++++++++++++++++++++------ 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/deps.ts b/deps.ts index 92565c99..ffd421f4 100644 --- a/deps.ts +++ b/deps.ts @@ -10,6 +10,7 @@ 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"; diff --git a/plugins/websocket_test.ts b/plugins/websocket_test.ts index 9211ed78..e6d2dc2e 100644 --- a/plugins/websocket_test.ts +++ b/plugins/websocket_test.ts @@ -1,27 +1,27 @@ import { MockClient } from "./../testing/client.ts"; import { Server as MockWebSocketServer } from "npm:mock-socket"; -import { assertEquals } from "../deps.ts"; +import { assertEquals, assertNotEquals } from "../deps.ts"; import { delay, describe } from "../testing/helpers.ts"; describe("plugins/websocket", (test) => { + const fakeHost = "localhost"; + const fakeUrl = `ws://${fakeHost}`; + 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 clientMessages = [ - "NICK me", - "USER me 0 * me", - "CAP REQ multi-prefix", - "CAP END", - ]; - const serverMessage = ":localhost 001 me :Hello from the server, me"; - const fakeHost = "localhost"; - const fakeUrl = `ws://${fakeHost}`; - const decoder = new TextDecoder(); - let messageCounter = 0; - 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; @@ -41,4 +41,69 @@ describe("plugins/websocket", (test) => { 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, + }); + const 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(); + }); }); From 11b1c01041786e264d2613b36b7f11c7f9ee4082 Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Sat, 21 Oct 2023 17:37:26 -0500 Subject: [PATCH 14/16] Improve unit test to handle double connect --- plugins/websocket_test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/plugins/websocket_test.ts b/plugins/websocket_test.ts index e6d2dc2e..30fbb2e9 100644 --- a/plugins/websocket_test.ts +++ b/plugins/websocket_test.ts @@ -84,7 +84,7 @@ describe("plugins/websocket", (test) => { nick: "me", websocket: true, }); - const server = new MockWebSocketServer(fakeUrl); + let server = new MockWebSocketServer(fakeUrl); // Ensure error events are passed const errorMessage = "failed!"; const errorTest = (error: Error) => { @@ -105,5 +105,13 @@ describe("plugins/websocket", (test) => { 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(); }); }); From a39b2bf64422c7106ce138a41e6164ab4f53e057 Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Sat, 21 Oct 2023 17:57:01 -0500 Subject: [PATCH 15/16] Implement remote endpoint path support for websockets --- API.md | 9 +++++++++ core/client.ts | 1 + plugins/websocket.ts | 4 ++-- plugins/websocket_test.ts | 5 +++-- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/API.md b/API.md index 2c446868..3e6ce27d 100644 --- a/API.md +++ b/API.md @@ -1205,6 +1205,15 @@ 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 +const 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/core/client.ts b/core/client.ts index aaec39bd..97eefbd3 100644 --- a/core/client.ts +++ b/core/client.ts @@ -148,6 +148,7 @@ export class CoreClient< hostname: string, port = PORT, tls = false, + _path?: string, ): Promise { this.state.remoteAddr = { hostname, port, tls }; diff --git a/plugins/websocket.ts b/plugins/websocket.ts index 7f0b897a..8d1ccfa6 100644 --- a/plugins/websocket.ts +++ b/plugins/websocket.ts @@ -44,11 +44,11 @@ export default createPlugin("websocket", [])( client.emitError("read", new Error(event.toString())); }; - client.hooks.hookCall("connect", (_, serverAndPath, port, tls) => { + 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}${serverAndPath}:${port}`, + `${websocketPrefix}${hostname}:${port}${path ? "/" + path : ""}`, ); if (websocket !== null) { websocket.close(1000); diff --git a/plugins/websocket_test.ts b/plugins/websocket_test.ts index 30fbb2e9..77c808ea 100644 --- a/plugins/websocket_test.ts +++ b/plugins/websocket_test.ts @@ -5,7 +5,8 @@ import { delay, describe } from "../testing/helpers.ts"; describe("plugins/websocket", (test) => { const fakeHost = "localhost"; - const fakeUrl = `ws://${fakeHost}`; + const fakePath = "irc"; + const fakeUrl = `ws://${fakeHost}/${fakePath}`; const clientMessages = [ "NICK me", "USER me 0 * me", @@ -34,7 +35,7 @@ describe("plugins/websocket", (test) => { }); }); - await client.connect(fakeHost); + await client.connect(fakeHost, undefined, undefined, fakePath); // Required to execute websocket micro tasks await delay(50); From 22bfd60b6aece39787506b1a8d355eaab58e1a11 Mon Sep 17 00:00:00 2001 From: Isaac Aronson Date: Sat, 21 Oct 2023 17:57:01 -0500 Subject: [PATCH 16/16] Improve README documentation for path parameter --- API.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/API.md b/API.md index 3e6ce27d..e7928f5b 100644 --- a/API.md +++ b/API.md @@ -1195,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); @@ -1211,7 +1213,7 @@ When `websocket` feature enabled, also accepts `path` parameter as string. // Example remote endpoint URL const remoteUrl = "wss://irc.example.org:8097/pathTo/Irc"; // Passing said endpoint URL to connect -const client.connect("irc.example.org", 8097, true, "pathTo/Irc"); +client.connect("irc.example.org", 8097, true, "pathTo/Irc"); ``` ### command: ctcp