Skip to content
Merged
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
10 changes: 6 additions & 4 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 { escapeTagValue, Parser, type Raw } from "./parsers.ts";
import { escapeTagValue, parseChunk, type Raw } from "./parsers.ts";
import { loadPlugins, type Plugin } from "./plugins.ts";
import {
type AnyCommand,
Expand Down Expand Up @@ -98,7 +98,7 @@ export class CoreClient<

private decoder = new TextDecoder();
private encoder = new TextEncoder();
private parser = new Parser();
private chunk = "";
private buffer: Uint8Array;

constructor(
Expand Down Expand Up @@ -211,9 +211,11 @@ export class CoreClient<
const chunks = await this.read(conn);
if (chunks === null) break;

const messageGenerator = this.parser.parseMessages(chunks);
const input = this.chunk ? this.chunk + chunks : chunks;
const [messages, remainder] = parseChunk(input);
this.chunk = remainder;

for (const msg of messageGenerator) {
for (const msg of messages) {
this.emit(`raw:${msg.command}`, msg);
}
}
Expand Down
70 changes: 39 additions & 31 deletions core/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,20 @@ export type Raw = Message<string[]> & {

/** Parses an IRC prefix string (e.g. `nick!user@host`) into a {@link Source}. */
export function parseSource(prefix: string): Source {
const source = {} as Source;
const [name, user, host] = prefix.split(/[@!]+/);

source.name = name;
if (user !== undefined && host !== undefined) {
source.mask = { user, host };
const bangIdx = prefix.indexOf("!");
const atIdx = prefix.indexOf("@");

if (bangIdx !== -1 && atIdx > bangIdx) {
return {
name: prefix.slice(0, bangIdx),
mask: {
user: prefix.slice(bangIdx + 1, atIdx),
host: prefix.slice(atIdx + 1),
},
};
}

return source;
return { name: prefix };
}

const UNESCAPE_MAP: Record<string, string> = {
Expand Down Expand Up @@ -121,11 +126,15 @@ function parseMessage(raw: string): Raw {
msg.tags = {};
while (start < end) {
let pos = raw.indexOf(";", start);
if (pos === -1) pos = end;
const [key, rawValue] = raw.slice(start, pos).split("=");
msg.tags[key] = rawValue !== undefined
? unescapeTagValue(rawValue)
: undefined;
if (pos === -1 || pos > end) pos = end;
const eqIdx = raw.indexOf("=", start);
if (eqIdx !== -1 && eqIdx < pos) {
msg.tags[raw.slice(start, eqIdx)] = unescapeTagValue(
raw.slice(eqIdx + 1, pos),
);
} else {
msg.tags[raw.slice(start, pos)] = undefined;
}
start = pos + 1;
}
}
Expand Down Expand Up @@ -165,25 +174,24 @@ function parseMessage(raw: string): Raw {
return msg;
}

/** Stateful parser that handles incremental IRC message chunks split across TCP reads. */
export class Parser {
private chunk = "";
/**
* Parses raw IRC messages from `input` and returns parsed messages with any
* incomplete trailing chunk.
*
* `input` is a string of raw messages each ending with `\r\n`. If the last
* raw message does not end with `\r\n`, it is returned as the remainder to
* be prepended to the next call.
*/
export function parseChunk(input: string): [Raw[], string] {
const results: Raw[] = [];

/**
* Parses `chunks` of raw messages and provides a `Generator<Raw>`.
*
* `chunks` is a string of raw messages each ending with `\r\n`. If the last
* raw message of the `batch` does not end with `\r\n`, it means the message
* is not complete and will be temporarily stored in the `Parser` instance to
* be processed on the next call.
*/
*parseMessages(chunks: string): Generator<Raw> {
this.chunk += chunks;
const batch = this.chunk.split("\r\n");
this.chunk = batch.pop()!;

for (const raw of batch) {
yield parseMessage(raw);
}
let start = 0;
let end: number;

while ((end = input.indexOf("\r\n", start)) !== -1) {
results.push(parseMessage(input.slice(start, end)));
start = end + 2; // skip \r\n
}

return [results, start < input.length ? input.slice(start) : ""];
}
113 changes: 42 additions & 71 deletions core/parsers_test.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,47 @@
import { assertEquals } from "@std/assert";
import { describe } from "../testing/helpers.ts";
import { escapeTagValue, Parser, unescapeTagValue } from "./parsers.ts";
import { escapeTagValue, parseChunk, unescapeTagValue } from "./parsers.ts";

describe("core/parsers", (test) => {
test("parse message without prefix", () => {
const parser = new Parser();
const [msgs] = parseChunk("PING :QimVSbibZg\r\n");

const msg = Array.from(parser.parseMessages("PING :QimVSbibZg\r\n"));

assertEquals(msg, [{
assertEquals(msgs, [{
command: "ping",
params: ["QimVSbibZg"],
}]);
});

test("parse message with server prefix", () => {
const parser = new Parser();

const msg = Array.from(
parser.parseMessages(
":serverhost NOTICE * :*** Looking up your hostname...\r\n",
),
const [msgs] = parseChunk(
":serverhost NOTICE * :*** Looking up your hostname...\r\n",
);

assertEquals(msg, [{
assertEquals(msgs, [{
command: "notice",
params: ["*", "*** Looking up your hostname..."],
source: { name: "serverhost" },
}]);
});

test("parse message with user prefix", () => {
const parser = new Parser();

const msg = Array.from(
parser.parseMessages(":someone!user@host JOIN #channel\r\n"),
const [msgs] = parseChunk(
":someone!user@host JOIN #channel\r\n",
);

assertEquals(msg, [{
assertEquals(msgs, [{
command: "join",
params: ["#channel"],
source: { mask: { host: "host", user: "user" }, name: "someone" },
}]);
});

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

const msg = Array.from(
parser.parseMessages(
"@aaa=bbb;ccc;example.com/ddd=eee :someone!user@host JOIN #channel\r\n",
),
const [msgs] = parseChunk(
"@aaa=bbb;ccc;example.com/ddd=eee :someone!user@host JOIN #channel\r\n",
);

assertEquals(msg, [{
assertEquals(msgs, [{
command: "join",
params: ["#channel"],
source: { mask: { host: "host", user: "user" }, name: "someone" },
Expand All @@ -62,67 +50,51 @@ 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"),
const [msgs] = parseChunk(
"@time=2026-03-24T12:00:00Z PING :server\r\n",
);

assertEquals(msg, [{
assertEquals(msgs, [{
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"),
const [msgs] = parseChunk(
"@key= :nick!u@h PRIVMSG #chan :text\r\n",
);

assertEquals(msg[0].tags, { "key": "" });
assertEquals(msgs[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",
),
const [msgs] = parseChunk(
"@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 });
assertEquals(msgs[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"),
const [msgs1, chunk] = parseChunk(
"@time=2026-03-24T12:00:00Z :nick!u@h PRI",
);
assertEquals(raw1, []);
assertEquals(msgs1, []);

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"]);
const [msgs2] = parseChunk(chunk + "VMSG #chan :hello\r\n");
assertEquals(msgs2.length, 1);
assertEquals(msgs2[0].tags, { "time": "2026-03-24T12:00:00Z" });
assertEquals(msgs2[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",
),
const [msgs] = parseChunk(
"@msg=hello\\sworld\\:test\\\\end :nick!u@h PRIVMSG #chan :text\r\n",
);

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

test("unescape tag values", () => {
Expand Down Expand Up @@ -150,16 +122,14 @@ describe("core/parsers", (test) => {
});

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

const raw1 = Array.from(
parser.parseMessages(":serverhost NOTICE Auth :*** Looking up"),
const [raw1, chunk1] = parseChunk(
":serverhost NOTICE Auth :*** Looking up",
);
assertEquals(raw1, []);

const raw2 = Array.from(parser.parseMessages(
" your hostname...\r\n:serverhost 001 nick :Wel",
));
const [raw2, chunk2] = parseChunk(
chunk1 + " your hostname...\r\n:serverhost 001 nick :Wel",
);
assertEquals(raw2, [
{
source: { name: "serverhost" },
Expand All @@ -168,9 +138,9 @@ describe("core/parsers", (test) => {
},
]);

const raw3 = Array.from(parser.parseMessages(
"come to the server\r\n:nick!user@host JOIN #channel\r\n",
));
const [raw3, chunk3] = parseChunk(
chunk2 + "come to the server\r\n:nick!user@host JOIN #channel\r\n",
);
assertEquals(raw3, [
{
source: { name: "serverhost" },
Expand All @@ -183,10 +153,11 @@ describe("core/parsers", (test) => {
params: ["#channel"],
},
]);
assertEquals(chunk3, "");

const raw4 = Array.from(parser.parseMessages(
const [raw4] = parseChunk(
"PING serverhost\r\n:nick!user@host PRIVMSG #channel ::!@ ;\r\n",
));
);
assertEquals(raw4, [
{
command: "ping",
Expand Down
Loading