Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7d1c9ba
feat(core): add IRCv3 protocol commands, tag escaping and outgoing tags
jeromeludmann Mar 25, 2026
f0b301c
feat(plugins/cap): track ACK/NAK/NEW/DEL, batch CAP REQ, add cap-notify
jeromeludmann Mar 25, 2026
00fe55d
feat(plugins/isupport): add generic isupport state map
jeromeludmann Mar 25, 2026
6e52c66
feat(plugins/account-notify): add IRCv3 account-notify cap
jeromeludmann Mar 25, 2026
f63d116
feat(plugins/account-tag): add IRCv3 account-tag cap
jeromeludmann Mar 25, 2026
126d40a
feat(plugins/away-notify): add IRCv3 away-notify cap
jeromeludmann Mar 25, 2026
b80fca4
feat(plugins/chghost): add IRCv3 chghost cap
jeromeludmann Mar 25, 2026
e145440
feat(plugins/echo-message): add IRCv3 echo-message cap
jeromeludmann Mar 25, 2026
3364c55
feat(plugins/extended-join): add IRCv3 extended-join cap
jeromeludmann Mar 25, 2026
875a656
feat(plugins/invite-notify): add IRCv3 invite-notify cap
jeromeludmann Mar 25, 2026
cfac2e5
feat(plugins/message-tags): add IRCv3 message-tags cap
jeromeludmann Mar 25, 2026
a99c094
feat(plugins/server-time): add IRCv3 server-time cap
jeromeludmann Mar 25, 2026
3007109
feat(plugins/setname): add IRCv3 setname cap
jeromeludmann Mar 25, 2026
0168756
feat(plugins/who): add WHO/WHOX support
jeromeludmann Mar 25, 2026
e044a2b
feat(plugins/monitor): add MONITOR support
jeromeludmann Mar 25, 2026
31a2bdd
feat(plugins/message-split): add automatic message splitting
jeromeludmann Mar 25, 2026
3406063
test(e2e): configure Ergo server for E2E testing
jeromeludmann Mar 25, 2026
16b7442
docs: update README, CONTRIBUTING, API and E2E tests
jeromeludmann Mar 25, 2026
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
332 changes: 304 additions & 28 deletions API.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ talk with IRC servers.

- **Cross-runtime** — runs on Deno and Node.js with the same API
- **Fully typed** — events, commands and state are inferred from TypeScript, no guessing
- **Plugin architecture** — 40+ built-in plugins (SASL, DCC, CTCP, reconnect, flood control...)
- **TLS & SASL** — PLAIN, EXTERNAL (client certificates), NickServ fallback
- **Plugin architecture** — 50+ built-in plugins (SASL, DCC, CTCP, reconnect, flood control...)
- **IRCv3** — server-time, echo-message, message-tags, MONITOR, SASL, and [more](API.md#ircv3-support)
- **Zero dependencies** — no external runtime dependencies

Any feedback and contributions are welcome.
Expand Down
39 changes: 36 additions & 3 deletions core/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type ClientError, type ErrorArgs, toClientError } from "./errors.ts";
import { EventEmitter, type EventEmitterOptions } from "./events.ts";
import { Hooks } from "./hooks.ts";
import { Parser, type Raw } from "./parsers.ts";
import { escapeTagValue, Parser, type Raw } from "./parsers.ts";
import { loadPlugins, type Plugin } from "./plugins.ts";
import {
type AnyCommand,
Expand Down Expand Up @@ -260,7 +260,30 @@ export class CoreClient<
async send(
command: AnyRawCommand,
...params: (string | undefined)[]
): Promise<string | null> {
): Promise<string | null>;

/** Sends a raw message with IRCv3 tags to the server. */
async send(
tags: Record<string, string | undefined>,
command: AnyRawCommand,
...params: (string | undefined)[]
): Promise<string | null>;

// deno-lint-ignore no-explicit-any
async send(first: any, ...rest: any[]): Promise<string | null> {
let tags: Record<string, string | undefined> | undefined;
let command: AnyRawCommand;
let params: (string | undefined)[];

if (typeof first === "object") {
tags = first;
command = rest.shift();
params = rest;
} else {
command = first;
params = rest;
}

if (this.conn === null) {
this.emitError("write", "Unable to send message", this.send);
return null;
Expand All @@ -280,8 +303,18 @@ export class CoreClient<
params[last] = ":" + params[last];
}

// Encodes tags prefix.
let tagStr = "";
if (tags) {
tagStr = "@" + Object.entries(tags)
.map(([k, v]) => v !== undefined ? `${k}=${escapeTagValue(v)}` : k)
.join(";") +
" ";
}

// Prepares and encodes raw message.
const raw = (command + " " + params.join(" ")).trimEnd() + "\r\n";
const raw = tagStr +
(command + " " + params.join(" ")).trimEnd() + "\r\n";
const bytes = this.encoder.encode(raw);

try {
Expand Down
53 changes: 53 additions & 0 deletions core/client_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,59 @@ describe("core/client", (test) => {
]);
});

test("send raw message with tags", async () => {
const { client, server } = mock();

await client.connect("host");

client.send({ "+typing": "active" }, "TAGMSG", "#channel");
const raw = server.receive();

assertEquals(raw, ["@+typing=active TAGMSG #channel"]);
});

test("send raw message with tags and value escaping", async () => {
const { client, server } = mock();

await client.connect("host");

client.send({ "+msg": "hello world" }, "TAGMSG", "#channel");
const raw = server.receive();

assertEquals(raw, ["@+msg=hello\\sworld TAGMSG #channel"]);
});

test("send raw message with tag without value", async () => {
const { client, server } = mock();

await client.connect("host");

client.send({ "+typing": undefined }, "TAGMSG", "#channel");
const raw = server.receive();

assertEquals(raw, ["@+typing TAGMSG #channel"]);
});

test("send raw message with multiple tags", async () => {
const { client, server } = mock();

await client.connect("host");

client.send(
{ "+reply": "msgid123", "+typing": "active" },
"PRIVMSG",
"#channel",
"hello",
);
const raw = server.receive();

assertEquals(raw.length, 1);
assertEquals(raw[0].startsWith("@"), true);
assertEquals(raw[0].includes("+reply=msgid123"), true);
assertEquals(raw[0].includes("+typing=active"), true);
assertEquals(raw[0].includes("PRIVMSG #channel hello"), true);
});

test("fail to send raw message if not connected", async () => {
const { client } = mock();

Expand Down
48 changes: 46 additions & 2 deletions core/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,48 @@ export function parseSource(prefix: string): Source {
return source;
}

const UNESCAPE_MAP: Record<string, string> = {
":": ";",
"s": " ",
"\\": "\\",
"r": "\r",
"n": "\n",
};

/** Decodes IRCv3 tag value escaping. */
export function unescapeTagValue(value: string): string {
if (value.indexOf("\\") === -1) return value;
let result = "";
for (let i = 0; i < value.length; i++) {
if (value[i] === "\\" && i + 1 < value.length) {
result += UNESCAPE_MAP[value[++i]] ?? value[i];
} else {
result += value[i];
}
}
return result;
}

/** Encodes a string as an IRCv3 tag value. */
export function escapeTagValue(value: string): string {
return value.replace(/[; \\\r\n]/g, (c) => {
switch (c) {
case ";":
return "\\:";
case " ":
return "\\s";
case "\\":
return "\\\\";
case "\r":
return "\\r";
case "\n":
return "\\n";
default:
return c;
}
});
}

// The following is called on each received raw message
// and must favor performance over readability.
function parseMessage(raw: string): Raw {
Expand All @@ -80,8 +122,10 @@ function parseMessage(raw: string): Raw {
while (start < end) {
let pos = raw.indexOf(";", start);
if (pos === -1) pos = end;
const [key, value] = raw.slice(start, pos).split("=");
msg.tags[key] = value;
const [key, rawValue] = raw.slice(start, pos).split("=");
msg.tags[key] = rawValue !== undefined
? unescapeTagValue(rawValue)
: undefined;
start = pos + 1;
}
}
Expand Down
90 changes: 89 additions & 1 deletion core/parsers_test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { assertEquals } from "@std/assert";
import { describe } from "../testing/helpers.ts";
import { Parser } from "./parsers.ts";
import { escapeTagValue, Parser, unescapeTagValue } from "./parsers.ts";

describe("core/parsers", (test) => {
test("parse message without prefix", () => {
Expand Down Expand Up @@ -61,6 +61,94 @@ describe("core/parsers", (test) => {
}]);
});

test("parse message with tags but no source", () => {
const parser = new Parser();

const msg = Array.from(
parser.parseMessages("@time=2026-03-24T12:00:00Z PING :server\r\n"),
);

assertEquals(msg, [{
command: "ping",
params: ["server"],
tags: { "time": "2026-03-24T12:00:00Z" },
}]);
});

test("parse message with empty tag value", () => {
const parser = new Parser();

const msg = Array.from(
parser.parseMessages("@key= :nick!u@h PRIVMSG #chan :text\r\n"),
);

assertEquals(msg[0].tags, { "key": "" });
});

test("parse message with multiple escaped tags", () => {
const parser = new Parser();

const msg = Array.from(
parser.parseMessages(
"@a=1\\s2;b=x\\:y;c :nick!u@h PRIVMSG #chan :text\r\n",
),
);

assertEquals(msg[0].tags, { "a": "1 2", "b": "x;y", "c": undefined });
});

test("parse tags split across chunks", () => {
const parser = new Parser();

const raw1 = Array.from(
parser.parseMessages("@time=2026-03-24T12:00:00Z :nick!u@h PRI"),
);
assertEquals(raw1, []);

const raw2 = Array.from(
parser.parseMessages("VMSG #chan :hello\r\n"),
);
assertEquals(raw2.length, 1);
assertEquals(raw2[0].tags, { "time": "2026-03-24T12:00:00Z" });
assertEquals(raw2[0].params, ["#chan", "hello"]);
});

test("parse message with escaped tag values", () => {
const parser = new Parser();

const msg = Array.from(
parser.parseMessages(
"@msg=hello\\sworld\\:test\\\\end :nick!u@h PRIVMSG #chan :text\r\n",
),
);

assertEquals(msg[0].tags, { "msg": "hello world;test\\end" });
});

test("unescape tag values", () => {
assertEquals(unescapeTagValue("hello\\sworld"), "hello world");
assertEquals(unescapeTagValue("a\\:b"), "a;b");
assertEquals(unescapeTagValue("a\\\\b"), "a\\b");
assertEquals(unescapeTagValue("a\\rb"), "a\rb");
assertEquals(unescapeTagValue("a\\nb"), "a\nb");
assertEquals(unescapeTagValue("no\\escape"), "noescape");
assertEquals(unescapeTagValue("plain"), "plain");
});

test("escape tag values", () => {
assertEquals(escapeTagValue("hello world"), "hello\\sworld");
assertEquals(escapeTagValue("a;b"), "a\\:b");
assertEquals(escapeTagValue("a\\b"), "a\\\\b");
assertEquals(escapeTagValue("a\rb"), "a\\rb");
assertEquals(escapeTagValue("a\nb"), "a\\nb");
assertEquals(escapeTagValue("plain"), "plain");
});

test("roundtrip tag escape/unescape", () => {
const original = "hello world;semi\\backslash\r\n";
assertEquals(unescapeTagValue(escapeTagValue(original)), original);
});

test("parse chunks of raw messages", () => {
const parser = new Parser();

Expand Down
10 changes: 10 additions & 0 deletions core/protocol.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
const COMMANDS = {
"ADMIN": "admin",
"AUTHENTICATE": "authenticate",
"ACCOUNT": "account",
"AWAY": "away",
"CAP": "cap",
"CHGHOST": "chghost",
"CONNECT": "connect",
"ERROR": "error",
"INFO": "info",
Expand All @@ -13,6 +15,7 @@ const COMMANDS = {
"LINKS": "links",
"LIST": "list",
"MODE": "mode",
"MONITOR": "monitor",
"MOTD": "motd",
"NICK": "nick",
"NAMES": "names",
Expand All @@ -24,7 +27,9 @@ const COMMANDS = {
"PONG": "pong",
"PRIVMSG": "privmsg",
"QUIT": "quit",
"SETNAME": "setname",
"STATS": "stats",
"TAGMSG": "tagmsg",
"TIME": "time",
"TOPIC": "topic",
"TRACE": "trace",
Expand Down Expand Up @@ -167,6 +172,10 @@ const REPLIES = {
"394": "rpl_endofusers",
"395": "rpl_nousers",
"396": "rpl_hosthidden",
"730": "rpl_mononline",
"731": "rpl_monoffline",
"732": "rpl_monlist",
"733": "rpl_endofmonlist",
"900": "rpl_loggedin",
"901": "rpl_loggedout",
"903": "rpl_saslsuccess",
Expand Down Expand Up @@ -266,6 +275,7 @@ const ERRORS = {
"550": "err_badhostmask",
"551": "err_hostunavail",
"552": "err_usingsline",
"734": "err_monlistfull",
"902": "err_nicklocked",
"904": "err_saslfail",
"905": "err_sasltoolong",
Expand Down
21 changes: 19 additions & 2 deletions deno.lock

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

Loading
Loading