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
28 changes: 27 additions & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1182,14 +1195,27 @@ If `tls=true`, attempts to connect using a TLS connection.

Resolves when connected.

`async connect(hostname: string, port: number, tls?: boolean): Promise<Deno.Conn | null>`
`async connect(hostname: string, port: number, tls?: boolean, path?: string): Promise<Deno.Conn | null>`

Note: `path` parameter is ignored when websocket feature not enabled.

```ts
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`.
Expand Down
2 changes: 2 additions & 0 deletions client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -90,6 +91,7 @@ const plugins = [
usermodes,
verbose,
version,
websocket,
whois,
];

Expand Down
59 changes: 41 additions & 18 deletions core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -53,6 +85,7 @@ export interface RemoteAddr {
hostname: string;
port: number;
tls?: boolean;
path?: string;
}

/** How to connect to a server */
Expand All @@ -74,9 +107,9 @@ export class CoreClient<
protected conn: Deno.Conn | null = null;
protected hooks = new Hooks<CoreClient<TEvents>>(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(
Expand Down Expand Up @@ -115,6 +148,7 @@ export class CoreClient<
hostname: string,
port = PORT,
tls = false,
_path?: string,
): Promise<Deno.Conn | null> {
this.state.remoteAddr = { hostname, port, tls };

Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion core/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

4 changes: 2 additions & 2 deletions plugins/antiflood_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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
Expand Down
124 changes: 124 additions & 0 deletions plugins/websocket.ts
Original file line number Diff line number Diff line change
@@ -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", [])<WebsocketFeatures>(
(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;
}
});
},
);
Loading