serverless-ircd 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +47 -0
- package/.github/workflows/deploy-cf.yml +97 -0
- package/LICENSE +29 -0
- package/README.md +323 -0
- package/apps/cf-worker/package.json +37 -0
- package/apps/cf-worker/scripts/smoke.mjs +175 -0
- package/apps/cf-worker/src/worker.ts +59 -0
- package/apps/cf-worker/tests/smoke.test.ts +150 -0
- package/apps/cf-worker/tsconfig.build.json +17 -0
- package/apps/cf-worker/tsconfig.test.json +18 -0
- package/apps/cf-worker/vitest.config.ts +31 -0
- package/apps/cf-worker/wrangler.test.toml +33 -0
- package/apps/cf-worker/wrangler.toml +98 -0
- package/apps/local-cli/package.json +35 -0
- package/apps/local-cli/src/line-scanner.ts +60 -0
- package/apps/local-cli/src/main.ts +145 -0
- package/apps/local-cli/src/motd-file.ts +45 -0
- package/apps/local-cli/src/server.ts +409 -0
- package/apps/local-cli/tests/e2e.test.ts +346 -0
- package/apps/local-cli/tests/id-factory.test.ts +25 -0
- package/apps/local-cli/tests/line-scanner.test.ts +81 -0
- package/apps/local-cli/tests/motd-file.test.ts +71 -0
- package/apps/local-cli/tests/tcp.test.ts +358 -0
- package/apps/local-cli/tsconfig.build.json +17 -0
- package/apps/local-cli/tsconfig.test.json +15 -0
- package/apps/local-cli/vitest.config.ts +24 -0
- package/biome.json +52 -0
- package/package.json +35 -0
- package/packages/cf-adapter/package.json +38 -0
- package/packages/cf-adapter/src/cf-runtime.ts +308 -0
- package/packages/cf-adapter/src/channel-do.ts +374 -0
- package/packages/cf-adapter/src/connection-do.ts +542 -0
- package/packages/cf-adapter/src/env.ts +67 -0
- package/packages/cf-adapter/src/index.ts +38 -0
- package/packages/cf-adapter/src/registry-do.ts +130 -0
- package/packages/cf-adapter/src/serialize.ts +142 -0
- package/packages/cf-adapter/src/sharding.ts +101 -0
- package/packages/cf-adapter/tests/cf-harness.ts +264 -0
- package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
- package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
- package/packages/cf-adapter/tests/integration/harness.ts +102 -0
- package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
- package/packages/cf-adapter/tests/integration/index.ts +17 -0
- package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
- package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
- package/packages/cf-adapter/tests/sharding.test.ts +100 -0
- package/packages/cf-adapter/tests/worker/main.ts +33 -0
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
- package/packages/cf-adapter/tsconfig.build.json +16 -0
- package/packages/cf-adapter/tsconfig.test.json +18 -0
- package/packages/cf-adapter/vitest.config.ts +32 -0
- package/packages/cf-adapter/wrangler.test.toml +50 -0
- package/packages/in-memory-runtime/package.json +29 -0
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
- package/packages/in-memory-runtime/src/index.ts +9 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
- package/packages/in-memory-runtime/tsconfig.build.json +16 -0
- package/packages/in-memory-runtime/tsconfig.test.json +14 -0
- package/packages/in-memory-runtime/vitest.config.ts +21 -0
- package/packages/irc-core/package.json +29 -0
- package/packages/irc-core/src/caps/capabilities.ts +96 -0
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/commands/away.ts +82 -0
- package/packages/irc-core/src/commands/cap.ts +257 -0
- package/packages/irc-core/src/commands/chghost.ts +30 -0
- package/packages/irc-core/src/commands/index.ts +40 -0
- package/packages/irc-core/src/commands/invite.ts +156 -0
- package/packages/irc-core/src/commands/isupport.ts +133 -0
- package/packages/irc-core/src/commands/join.ts +309 -0
- package/packages/irc-core/src/commands/kick.ts +162 -0
- package/packages/irc-core/src/commands/list.ts +126 -0
- package/packages/irc-core/src/commands/mode.ts +655 -0
- package/packages/irc-core/src/commands/motd.ts +146 -0
- package/packages/irc-core/src/commands/names.ts +164 -0
- package/packages/irc-core/src/commands/part.ts +118 -0
- package/packages/irc-core/src/commands/ping.ts +47 -0
- package/packages/irc-core/src/commands/privmsg.ts +256 -0
- package/packages/irc-core/src/commands/quit.ts +70 -0
- package/packages/irc-core/src/commands/registration.ts +251 -0
- package/packages/irc-core/src/commands/topic.ts +171 -0
- package/packages/irc-core/src/commands/who.ts +169 -0
- package/packages/irc-core/src/commands/whois.ts +165 -0
- package/packages/irc-core/src/effects.ts +184 -0
- package/packages/irc-core/src/index.ts +20 -0
- package/packages/irc-core/src/ports.ts +153 -0
- package/packages/irc-core/src/protocol/batch.ts +85 -0
- package/packages/irc-core/src/protocol/index.ts +18 -0
- package/packages/irc-core/src/protocol/messages.ts +36 -0
- package/packages/irc-core/src/protocol/numerics.ts +155 -0
- package/packages/irc-core/src/protocol/outbound.ts +145 -0
- package/packages/irc-core/src/protocol/parser.ts +175 -0
- package/packages/irc-core/src/protocol/serializer.ts +71 -0
- package/packages/irc-core/src/state/channel.ts +293 -0
- package/packages/irc-core/src/state/connection.ts +153 -0
- package/packages/irc-core/src/state/index.ts +25 -0
- package/packages/irc-core/src/state/registry.ts +22 -0
- package/packages/irc-core/src/types.ts +83 -0
- package/packages/irc-core/tests/batch.test.ts +79 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
- package/packages/irc-core/tests/commands/away.test.ts +233 -0
- package/packages/irc-core/tests/commands/batch.test.ts +178 -0
- package/packages/irc-core/tests/commands/cap.test.ts +499 -0
- package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
- package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
- package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
- package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
- package/packages/irc-core/tests/commands/invite.test.ts +321 -0
- package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
- package/packages/irc-core/tests/commands/join.test.ts +687 -0
- package/packages/irc-core/tests/commands/kick.test.ts +316 -0
- package/packages/irc-core/tests/commands/list.test.ts +452 -0
- package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
- package/packages/irc-core/tests/commands/motd.test.ts +322 -0
- package/packages/irc-core/tests/commands/names.test.ts +342 -0
- package/packages/irc-core/tests/commands/part.test.ts +265 -0
- package/packages/irc-core/tests/commands/ping.test.ts +144 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
- package/packages/irc-core/tests/commands/quit.test.ts +220 -0
- package/packages/irc-core/tests/commands/registration.test.ts +599 -0
- package/packages/irc-core/tests/commands/topic.test.ts +337 -0
- package/packages/irc-core/tests/commands/who.test.ts +441 -0
- package/packages/irc-core/tests/commands/whois.test.ts +422 -0
- package/packages/irc-core/tests/effects.test.ts +147 -0
- package/packages/irc-core/tests/message-tags.test.ts +182 -0
- package/packages/irc-core/tests/numerics.test.ts +98 -0
- package/packages/irc-core/tests/outbound.test.ts +232 -0
- package/packages/irc-core/tests/parser.test.ts +162 -0
- package/packages/irc-core/tests/ports.test.ts +101 -0
- package/packages/irc-core/tests/serializer.test.ts +164 -0
- package/packages/irc-core/tests/state/channel.test.ts +351 -0
- package/packages/irc-core/tests/state/connection.test.ts +122 -0
- package/packages/irc-core/tests/state/registry.test.ts +20 -0
- package/packages/irc-core/tests/types.test.ts +127 -0
- package/packages/irc-core/tsconfig.build.json +12 -0
- package/packages/irc-core/tsconfig.test.json +10 -0
- package/packages/irc-core/vitest.config.ts +21 -0
- package/packages/irc-server/package.json +28 -0
- package/packages/irc-server/src/actor.ts +449 -0
- package/packages/irc-server/src/dispatch.ts +120 -0
- package/packages/irc-server/src/index.ts +11 -0
- package/packages/irc-server/src/runtime.ts +61 -0
- package/packages/irc-server/tests/actor.test.ts +816 -0
- package/packages/irc-server/tests/dispatch.test.ts +512 -0
- package/packages/irc-server/tests/runtime.test.ts +52 -0
- package/packages/irc-server/tsconfig.build.json +13 -0
- package/packages/irc-server/tsconfig.test.json +11 -0
- package/packages/irc-server/vitest.config.ts +21 -0
- package/pnpm-workspace.yaml +4 -0
- package/tools/tcp-ws-forwarder/package.json +32 -0
- package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
- package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
- package/tools/tcp-ws-forwarder/src/main.ts +114 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
- package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
- package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
- package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
- package/tsconfig.base.json +25 -0
- package/turbo.json +29 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { IrcMessage, IrcSource } from './messages.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error thrown when a wire line cannot be parsed as an IRC message.
|
|
5
|
+
* The actor layer treats these as a protocol violation (and typically drops
|
|
6
|
+
* or 421s the offending input) rather than crashing.
|
|
7
|
+
*/
|
|
8
|
+
export class IrcParseError extends Error {
|
|
9
|
+
constructor(
|
|
10
|
+
message: string,
|
|
11
|
+
/** The original input that failed to parse, for diagnostics. */
|
|
12
|
+
readonly input: string,
|
|
13
|
+
) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'IrcParseError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TAG_ESCAPES: Readonly<Record<string, string>> = {
|
|
20
|
+
':': ';',
|
|
21
|
+
s: ' ',
|
|
22
|
+
'\\': '\\',
|
|
23
|
+
r: '\r',
|
|
24
|
+
n: '\n',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reverses {@link escapeTagValue} per the IRCv3 Message Tags spec. A `\`
|
|
29
|
+
* followed by one of the escape codes (`:`, `s`, `\`, `r`, `n`) yields the
|
|
30
|
+
* corresponding raw character; `\` followed by any other character drops
|
|
31
|
+
* the backslash (per spec: unknown escapes resolve to the literal char).
|
|
32
|
+
*/
|
|
33
|
+
export function unescapeTagValue(value: string): string {
|
|
34
|
+
let out = '';
|
|
35
|
+
for (let i = 0; i < value.length; i++) {
|
|
36
|
+
const ch = value.charAt(i);
|
|
37
|
+
if (ch === '\\' && i + 1 < value.length) {
|
|
38
|
+
const next = value.charAt(i + 1);
|
|
39
|
+
out += TAG_ESCAPES[next] ?? next;
|
|
40
|
+
i++;
|
|
41
|
+
} else {
|
|
42
|
+
out += ch;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseTags(section: string): Record<string, string> {
|
|
49
|
+
const tags: Record<string, string> = {};
|
|
50
|
+
for (const entry of section.split(';')) {
|
|
51
|
+
if (entry === '') continue;
|
|
52
|
+
const eq = entry.indexOf('=');
|
|
53
|
+
if (eq === -1) {
|
|
54
|
+
tags[entry] = '';
|
|
55
|
+
} else {
|
|
56
|
+
tags[entry.slice(0, eq)] = unescapeTagValue(entry.slice(eq + 1));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return tags;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseSource(raw: string): IrcSource {
|
|
63
|
+
// Grammar: servername | nick ['!' user] ['@' host]
|
|
64
|
+
// '!' and '@' are independent, so split host (trailing) first, then user.
|
|
65
|
+
let beforeHost = raw;
|
|
66
|
+
let host: string | undefined;
|
|
67
|
+
const at = raw.lastIndexOf('@');
|
|
68
|
+
if (at !== -1) {
|
|
69
|
+
host = raw.slice(at + 1);
|
|
70
|
+
beforeHost = raw.slice(0, at);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let name = beforeHost;
|
|
74
|
+
let user: string | undefined;
|
|
75
|
+
const bang = beforeHost.indexOf('!');
|
|
76
|
+
if (bang !== -1) {
|
|
77
|
+
name = beforeHost.slice(0, bang);
|
|
78
|
+
user = beforeHost.slice(bang + 1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const source: IrcSource = { name };
|
|
82
|
+
if (user !== undefined) source.user = user;
|
|
83
|
+
if (host !== undefined) source.host = host;
|
|
84
|
+
return source;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const COMMAND_RE = /^(?:[A-Za-z]+|[0-9]{3})$/;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parses one IRC message from a single line (with or without a trailing
|
|
91
|
+
* CR/LF). Throws {@link IrcParseError} on malformed input.
|
|
92
|
+
*
|
|
93
|
+
* Lenient where real-world clients are: tolerates missing CR/LF and collapses
|
|
94
|
+
* accidental runs of spaces between tokens. Strict where the grammar is: a
|
|
95
|
+
* command token is required and must be all-letters or exactly three digits.
|
|
96
|
+
*/
|
|
97
|
+
export function parse(line: string): IrcMessage {
|
|
98
|
+
// Strip any trailing CR/LF (frame tolerance for ws text frames or TCP lines).
|
|
99
|
+
const stripped = line.replace(/[\r\n]+$/u, '');
|
|
100
|
+
let rest = stripped;
|
|
101
|
+
if (rest.length === 0) {
|
|
102
|
+
throw new IrcParseError('empty message', line);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let tags: Record<string, string> = {};
|
|
106
|
+
if (rest.startsWith('@')) {
|
|
107
|
+
const space = rest.indexOf(' ');
|
|
108
|
+
if (space === -1) {
|
|
109
|
+
throw new IrcParseError('tag section with no message body', line);
|
|
110
|
+
}
|
|
111
|
+
tags = parseTags(rest.slice(1, space));
|
|
112
|
+
rest = rest.slice(space + 1).trimStart();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let source: IrcSource | undefined;
|
|
116
|
+
if (rest.startsWith(':')) {
|
|
117
|
+
const space = rest.indexOf(' ');
|
|
118
|
+
if (space === -1) {
|
|
119
|
+
throw new IrcParseError('source prefix with no command', line);
|
|
120
|
+
}
|
|
121
|
+
source = parseSource(rest.slice(1, space));
|
|
122
|
+
rest = rest.slice(space + 1).trimStart();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (rest.length === 0) {
|
|
126
|
+
throw new IrcParseError('missing command', line);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Split command from the parameter remainder.
|
|
130
|
+
const firstSpace = rest.indexOf(' ');
|
|
131
|
+
let command: string;
|
|
132
|
+
let remainder: string;
|
|
133
|
+
if (firstSpace === -1) {
|
|
134
|
+
command = rest;
|
|
135
|
+
remainder = '';
|
|
136
|
+
} else {
|
|
137
|
+
command = rest.slice(0, firstSpace);
|
|
138
|
+
remainder = rest.slice(firstSpace + 1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
command = command.toUpperCase();
|
|
142
|
+
if (!COMMAND_RE.test(command)) {
|
|
143
|
+
throw new IrcParseError(`invalid command token: ${command}`, line);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const params = parseParams(remainder);
|
|
147
|
+
|
|
148
|
+
const message: IrcMessage = { tags, command, params };
|
|
149
|
+
if (source !== undefined) message.source = source;
|
|
150
|
+
return message;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseParams(remainder: string): string[] {
|
|
154
|
+
if (remainder.length === 0) return [];
|
|
155
|
+
|
|
156
|
+
let middle: string;
|
|
157
|
+
let trailing: string | undefined;
|
|
158
|
+
|
|
159
|
+
if (remainder.startsWith(':')) {
|
|
160
|
+
// The whole remainder is a trailing param.
|
|
161
|
+
middle = '';
|
|
162
|
+
trailing = remainder.slice(1);
|
|
163
|
+
} else {
|
|
164
|
+
const trailingMarker = remainder.indexOf(' :');
|
|
165
|
+
if (trailingMarker === -1) {
|
|
166
|
+
middle = remainder;
|
|
167
|
+
} else {
|
|
168
|
+
middle = remainder.slice(0, trailingMarker);
|
|
169
|
+
trailing = remainder.slice(trailingMarker + 2);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const middles = middle.length === 0 ? [] : middle.split(' ').filter((p) => p.length > 0);
|
|
174
|
+
return trailing === undefined ? middles : [...middles, trailing];
|
|
175
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { IrcMessage, IrcSource } from './messages.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Escapes a raw tag value per the IRCv3 Message Tags spec so it can appear
|
|
5
|
+
* inside an `@...` tag section without containing literal `;`, space, `\`,
|
|
6
|
+
* CR or LF (all of which are structural or forbidden in the wire format).
|
|
7
|
+
*
|
|
8
|
+
* Escape table (the reverse of {@link unescapeTagValue}):
|
|
9
|
+
* `;` → `\:` ` ` → `\s` `\` → `\\` CR → `\r` LF → `\n`
|
|
10
|
+
*
|
|
11
|
+
* Because `\` is doubled first, the mapping is a bijection and
|
|
12
|
+
* `unescapeTagValue(escapeTagValue(s)) === s` for every string.
|
|
13
|
+
*/
|
|
14
|
+
export function escapeTagValue(value: string): string {
|
|
15
|
+
return value
|
|
16
|
+
.replace(/\\/g, '\\\\')
|
|
17
|
+
.replace(/;/g, '\\:')
|
|
18
|
+
.replace(/ /g, '\\s')
|
|
19
|
+
.replace(/\r/g, '\\r')
|
|
20
|
+
.replace(/\n/g, '\\n');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatSource(source: IrcSource): string {
|
|
24
|
+
let out = source.name;
|
|
25
|
+
if (source.user !== undefined) out = `${out}!${source.user}`;
|
|
26
|
+
if (source.host !== undefined) out = `${out}@${source.host}`;
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Serializes a message to its canonical wire form WITHOUT a trailing CR/LF.
|
|
32
|
+
* Use {@link serializeFrame} when the transport expects CRLF framing.
|
|
33
|
+
*
|
|
34
|
+
* The last parameter is ALWAYS emitted in trailing (`:`) form. This matches
|
|
35
|
+
* the de-facto IRC server convention (replies and message bodies always carry
|
|
36
|
+
* the colon) and parses correctly on every conformant client.
|
|
37
|
+
*/
|
|
38
|
+
export function serialize(message: IrcMessage): string {
|
|
39
|
+
const lead: string[] = [];
|
|
40
|
+
|
|
41
|
+
const tagEntries = Object.entries(message.tags);
|
|
42
|
+
if (tagEntries.length > 0) {
|
|
43
|
+
const tagSection = tagEntries
|
|
44
|
+
.map(([key, value]) => (value === '' ? key : `${key}=${escapeTagValue(value)}`))
|
|
45
|
+
.join(';');
|
|
46
|
+
lead.push(`@${tagSection}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (message.source !== undefined) {
|
|
50
|
+
lead.push(`:${formatSource(message.source)}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
lead.push(message.command);
|
|
54
|
+
|
|
55
|
+
const { params } = message;
|
|
56
|
+
if (params.length === 0) {
|
|
57
|
+
return lead.join(' ');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const lastIndex = params.length - 1;
|
|
61
|
+
let out = lead.join(' ');
|
|
62
|
+
for (const [i, param] of params.entries()) {
|
|
63
|
+
out += i === lastIndex ? ` :${param}` : ` ${param}`;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Serializes a message and appends the canonical IRC CR/LF frame terminator. */
|
|
69
|
+
export function serializeFrame(message: IrcMessage): string {
|
|
70
|
+
return `${serialize(message)}\r\n`;
|
|
71
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel-level state shapes.
|
|
3
|
+
*
|
|
4
|
+
* The Channel entity is the authority for the roster, channel modes, topic,
|
|
5
|
+
* and ban masks. `ChannelDelta` is the value type emitted by reducers and
|
|
6
|
+
* applied by the runtime — it is how a Connection entity mutates Channel
|
|
7
|
+
* state without owning it (PLAN §2.1 location-of-authority).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ConnId, Nick } from './connection.js';
|
|
11
|
+
|
|
12
|
+
/** Channel name with original case preserved. */
|
|
13
|
+
export type ChanName = string;
|
|
14
|
+
|
|
15
|
+
/** Per-connection membership within a channel. */
|
|
16
|
+
export interface RosterEntry {
|
|
17
|
+
conn: ConnId;
|
|
18
|
+
nick: Nick;
|
|
19
|
+
op: boolean;
|
|
20
|
+
voice: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Read-only roster: connection id -> entry. */
|
|
24
|
+
export type Roster = ReadonlyMap<ConnId, RosterEntry>;
|
|
25
|
+
|
|
26
|
+
/** Topic record (text + setter + epoch ms). */
|
|
27
|
+
export interface ChannelTopic {
|
|
28
|
+
text: string;
|
|
29
|
+
setter: string;
|
|
30
|
+
setAt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Channel-mode flags. Boolean modes are always present. The two
|
|
35
|
+
* parameterized modes — `k` (key) and `l` (limit) — are optional and use
|
|
36
|
+
* `undefined` to signal "unset", exactly matching how `MODE` deltas encode
|
|
37
|
+
* clearing them.
|
|
38
|
+
*/
|
|
39
|
+
export interface ChannelModes {
|
|
40
|
+
// boolean modes
|
|
41
|
+
inviteOnly: boolean; // i
|
|
42
|
+
topicLock: boolean; // t
|
|
43
|
+
noExternal: boolean; // n
|
|
44
|
+
moderated: boolean; // m
|
|
45
|
+
secret: boolean; // s
|
|
46
|
+
private: boolean; // p
|
|
47
|
+
// parameterized modes
|
|
48
|
+
key?: string; // k
|
|
49
|
+
limit?: number; // l
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Boolean mode letters we know how to toggle. */
|
|
53
|
+
export type BooleanChannelMode =
|
|
54
|
+
| 'inviteOnly'
|
|
55
|
+
| 'topicLock'
|
|
56
|
+
| 'noExternal'
|
|
57
|
+
| 'moderated'
|
|
58
|
+
| 'secret'
|
|
59
|
+
| 'private';
|
|
60
|
+
|
|
61
|
+
/** Parameterized mode letters we know how to set/clear. */
|
|
62
|
+
export type ParameterChannelMode = 'key' | 'limit';
|
|
63
|
+
|
|
64
|
+
/** Any toggleable channel mode name. */
|
|
65
|
+
export type ChannelModeName = BooleanChannelMode | ParameterChannelMode;
|
|
66
|
+
|
|
67
|
+
/** One mode toggle inside a {@link ChannelDelta}. */
|
|
68
|
+
export interface ModeChange {
|
|
69
|
+
mode: ChannelModeName;
|
|
70
|
+
set: boolean;
|
|
71
|
+
/** Required for `key` (string) and `limit` (number) on set. */
|
|
72
|
+
arg?: string | number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One roster mutation inside a {@link ChannelDelta}. */
|
|
76
|
+
export interface MembershipDelta {
|
|
77
|
+
type: 'add' | 'remove';
|
|
78
|
+
conn: ConnId;
|
|
79
|
+
/** Required on `add`; ignored on `remove`. */
|
|
80
|
+
nick?: Nick;
|
|
81
|
+
op?: boolean;
|
|
82
|
+
voice?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One ban-mask mutation inside a {@link ChannelDelta}. */
|
|
86
|
+
export interface BanMaskChange {
|
|
87
|
+
type: 'add' | 'remove';
|
|
88
|
+
mask: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A batch of mutations to apply atomically to a channel. Every field is
|
|
93
|
+
* optional; absent fields mean "no change to that aspect".
|
|
94
|
+
*
|
|
95
|
+
* `topic: null` clears the topic; an absent `topic` field leaves it alone.
|
|
96
|
+
*/
|
|
97
|
+
export interface ChannelDelta {
|
|
98
|
+
memberships?: MembershipDelta[];
|
|
99
|
+
modeChanges?: ModeChange[];
|
|
100
|
+
banMaskChanges?: BanMaskChange[];
|
|
101
|
+
/** `null` clears the topic; a {@link ChannelTopic} replaces it. */
|
|
102
|
+
topic?: ChannelTopic | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Mutable authoritative channel state. */
|
|
106
|
+
export interface ChannelState {
|
|
107
|
+
name: ChanName;
|
|
108
|
+
nameLower: string;
|
|
109
|
+
modes: ChannelModes;
|
|
110
|
+
banMasks: Set<string>;
|
|
111
|
+
topic?: ChannelTopic;
|
|
112
|
+
members: Map<ConnId, RosterEntry>;
|
|
113
|
+
/**
|
|
114
|
+
* Lowercased nicks with a standing invite (created by `INVITE`). A later
|
|
115
|
+
* `JOIN` by that nick bypasses `+i` and consumes the entry.
|
|
116
|
+
*
|
|
117
|
+
* Tracked by nick (not ConnId) because the INVITE reducer is pure and
|
|
118
|
+
* cannot resolve a nick to a connection id itself; the JOIN reducer
|
|
119
|
+
* compares against `ctx.connection.nick`.
|
|
120
|
+
*/
|
|
121
|
+
pendingInvites: Set<Nick>;
|
|
122
|
+
createdAt: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Read-only snapshot of a channel used by NAMES, WHO, LIST, PRIVMSG fanout. */
|
|
126
|
+
export interface ChanSnapshot {
|
|
127
|
+
readonly name: ChanName;
|
|
128
|
+
readonly nameLower: string;
|
|
129
|
+
readonly modes: Readonly<ChannelModes>;
|
|
130
|
+
readonly banMasks: ReadonlySet<string>;
|
|
131
|
+
readonly topic?: Readonly<ChannelTopic>;
|
|
132
|
+
readonly members: Roster;
|
|
133
|
+
readonly pendingInvites: ReadonlySet<Nick>;
|
|
134
|
+
readonly createdAt: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Returns a fresh {@link ChannelModes} with every flag disabled. */
|
|
138
|
+
export function emptyModes(): ChannelModes {
|
|
139
|
+
return {
|
|
140
|
+
inviteOnly: false,
|
|
141
|
+
topicLock: false,
|
|
142
|
+
noExternal: false,
|
|
143
|
+
moderated: false,
|
|
144
|
+
secret: false,
|
|
145
|
+
private: false,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface CreateChannelOptions {
|
|
150
|
+
name: ChanName;
|
|
151
|
+
nameLower: string;
|
|
152
|
+
createdAt: number;
|
|
153
|
+
topic?: ChannelTopic;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Constructs an empty channel with default modes. */
|
|
157
|
+
export function createChannel(opts: CreateChannelOptions): ChannelState {
|
|
158
|
+
const chan: ChannelState = {
|
|
159
|
+
name: opts.name,
|
|
160
|
+
nameLower: opts.nameLower,
|
|
161
|
+
modes: emptyModes(),
|
|
162
|
+
banMasks: new Set<string>(),
|
|
163
|
+
members: new Map<ConnId, RosterEntry>(),
|
|
164
|
+
pendingInvites: new Set<ConnId>(),
|
|
165
|
+
createdAt: opts.createdAt,
|
|
166
|
+
};
|
|
167
|
+
if (opts.topic !== undefined) chan.topic = opts.topic;
|
|
168
|
+
return chan;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Returns the canonical IRC prefix string for a roster entry.
|
|
173
|
+
* Op (`@`) sorts before voice (`+`); a user with both is `@+`.
|
|
174
|
+
*/
|
|
175
|
+
export function prefixOf(entry: RosterEntry): string {
|
|
176
|
+
let prefix = '';
|
|
177
|
+
if (entry.op) prefix += '@';
|
|
178
|
+
if (entry.voice) prefix += '+';
|
|
179
|
+
return prefix;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Freezes a mutable channel state into an immutable snapshot view. */
|
|
183
|
+
export function toSnapshot(chan: ChannelState): ChanSnapshot {
|
|
184
|
+
return {
|
|
185
|
+
name: chan.name,
|
|
186
|
+
nameLower: chan.nameLower,
|
|
187
|
+
modes: chan.modes,
|
|
188
|
+
banMasks: chan.banMasks,
|
|
189
|
+
members: chan.members,
|
|
190
|
+
pendingInvites: chan.pendingInvites,
|
|
191
|
+
createdAt: chan.createdAt,
|
|
192
|
+
...(chan.topic !== undefined ? { topic: chan.topic } : {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Returns an empty {@link ChannelDelta} (no changes). */
|
|
197
|
+
export function emptyChannelDelta(): ChannelDelta {
|
|
198
|
+
return {
|
|
199
|
+
memberships: [],
|
|
200
|
+
modeChanges: [],
|
|
201
|
+
banMaskChanges: [],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Applies a delta to a channel, returning a new {@link ChannelState} with
|
|
207
|
+
* copies of the mutated substructures (modes, members, ban masks). Source
|
|
208
|
+
* state is left untouched so callers may continue reading it.
|
|
209
|
+
*/
|
|
210
|
+
export function applyChannelDelta(chan: ChannelState, delta: ChannelDelta): ChannelState {
|
|
211
|
+
const next: ChannelState = {
|
|
212
|
+
name: chan.name,
|
|
213
|
+
nameLower: chan.nameLower,
|
|
214
|
+
modes: { ...chan.modes },
|
|
215
|
+
banMasks: new Set(chan.banMasks),
|
|
216
|
+
members: new Map(chan.members),
|
|
217
|
+
pendingInvites: chan.pendingInvites,
|
|
218
|
+
createdAt: chan.createdAt,
|
|
219
|
+
};
|
|
220
|
+
if (chan.topic !== undefined) next.topic = chan.topic;
|
|
221
|
+
|
|
222
|
+
if (delta.memberships !== undefined) {
|
|
223
|
+
for (const m of delta.memberships) {
|
|
224
|
+
if (m.type === 'add') {
|
|
225
|
+
if (m.nick === undefined) continue;
|
|
226
|
+
next.members.set(m.conn, {
|
|
227
|
+
conn: m.conn,
|
|
228
|
+
nick: m.nick,
|
|
229
|
+
op: m.op ?? false,
|
|
230
|
+
voice: m.voice ?? false,
|
|
231
|
+
});
|
|
232
|
+
} else {
|
|
233
|
+
next.members.delete(m.conn);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (delta.modeChanges !== undefined) {
|
|
239
|
+
for (const change of delta.modeChanges) {
|
|
240
|
+
applyModeChange(next.modes, change);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (delta.banMaskChanges !== undefined) {
|
|
245
|
+
for (const b of delta.banMaskChanges) {
|
|
246
|
+
if (b.type === 'add') {
|
|
247
|
+
next.banMasks.add(b.mask);
|
|
248
|
+
} else {
|
|
249
|
+
next.banMasks.delete(b.mask);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (delta.topic !== undefined) {
|
|
255
|
+
if (delta.topic === null) {
|
|
256
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
257
|
+
delete next.topic;
|
|
258
|
+
} else {
|
|
259
|
+
next.topic = delta.topic;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return next;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function applyModeChange(modes: ChannelModes, change: ModeChange): void {
|
|
267
|
+
switch (change.mode) {
|
|
268
|
+
case 'inviteOnly':
|
|
269
|
+
case 'topicLock':
|
|
270
|
+
case 'noExternal':
|
|
271
|
+
case 'moderated':
|
|
272
|
+
case 'secret':
|
|
273
|
+
case 'private':
|
|
274
|
+
modes[change.mode] = change.set;
|
|
275
|
+
break;
|
|
276
|
+
case 'key':
|
|
277
|
+
if (change.set) {
|
|
278
|
+
if (typeof change.arg === 'string') modes.key = change.arg;
|
|
279
|
+
} else {
|
|
280
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
281
|
+
delete modes.key;
|
|
282
|
+
}
|
|
283
|
+
break;
|
|
284
|
+
case 'limit':
|
|
285
|
+
if (change.set) {
|
|
286
|
+
if (typeof change.arg === 'number') modes.limit = change.arg;
|
|
287
|
+
} else {
|
|
288
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
289
|
+
delete modes.limit;
|
|
290
|
+
}
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection-level state shapes.
|
|
3
|
+
*
|
|
4
|
+
* The Connection entity is the authority for registration (NICK/USER/PASS),
|
|
5
|
+
* per-client modes (away, invisible, oper), and the list of channels the
|
|
6
|
+
* connection has joined. Adapters (CF ConnectionDO, AWS Connections table)
|
|
7
|
+
* persist this shape verbatim.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ChanName } from './channel.js';
|
|
11
|
+
|
|
12
|
+
/** Stable identifier for a transport connection. */
|
|
13
|
+
export type ConnId = string;
|
|
14
|
+
|
|
15
|
+
/** IRC nickname, with its original case preserved. */
|
|
16
|
+
export type Nick = string;
|
|
17
|
+
|
|
18
|
+
/** Lifetime of a connection from handshake through welcome numerics. */
|
|
19
|
+
export type RegistrationState = 'pre-registration' | 'registering' | 'registered';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* User modes applied to a connection. The IRC user-mode letters are
|
|
23
|
+
* `i` (invisible), `o` (oper), `w` (wallops), `s` (server notices).
|
|
24
|
+
*/
|
|
25
|
+
export interface UserModes {
|
|
26
|
+
invisible: boolean;
|
|
27
|
+
oper: boolean;
|
|
28
|
+
wallops: boolean;
|
|
29
|
+
serverNotices: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Mutable, authoritative per-connection state.
|
|
34
|
+
*
|
|
35
|
+
* Adapters persist this verbatim. Reducers receive it as their `state`
|
|
36
|
+
* argument and return a new value (with mutation permitted for performance
|
|
37
|
+
* since the previous value is discarded).
|
|
38
|
+
*/
|
|
39
|
+
export interface ConnectionState {
|
|
40
|
+
id: ConnId;
|
|
41
|
+
/** Reserved nick once `NICK` is accepted; absent until then. */
|
|
42
|
+
nick?: Nick;
|
|
43
|
+
/** Ident/user name set by `USER`. */
|
|
44
|
+
user?: string;
|
|
45
|
+
/** Visible host (post-cloak). */
|
|
46
|
+
host?: string;
|
|
47
|
+
/** Real name set by `USER`. */
|
|
48
|
+
realname?: string;
|
|
49
|
+
/** Stashed `PASS` value, consumed during registration. */
|
|
50
|
+
passAttempt?: string;
|
|
51
|
+
/** SASL-authenticated account name, if any. */
|
|
52
|
+
account?: string;
|
|
53
|
+
registration: RegistrationState;
|
|
54
|
+
/**
|
|
55
|
+
* True once the client has sent `CAP LS` or `CAP REQ` and not yet sent
|
|
56
|
+
* `CAP END`. While true the registration welcome block is deferred until
|
|
57
|
+
* `CAP END` arrives (per IRCv3 capability-negotiation spec).
|
|
58
|
+
*/
|
|
59
|
+
capNegotiating: boolean;
|
|
60
|
+
/** IRCv3 capabilities negotiated via `CAP`. */
|
|
61
|
+
caps: Set<string>;
|
|
62
|
+
/** Away reason; absent when not away. */
|
|
63
|
+
away?: string;
|
|
64
|
+
/** Lowercased channel names this connection has JOINed. */
|
|
65
|
+
joinedChannels: Set<ChanName>;
|
|
66
|
+
userModes: UserModes;
|
|
67
|
+
/** Last-seen epoch ms (drives idle / PING bookkeeping). */
|
|
68
|
+
lastSeen: number;
|
|
69
|
+
/** Connect-time epoch ms (drives WHOIS signon time). */
|
|
70
|
+
connectedSince: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read-only snapshot of a connection used by reducers that need a peer's
|
|
75
|
+
* view (PRIVMSG routing, WHOIS, NAMES) without mutating it. The actor layer
|
|
76
|
+
* builds these by calling {@link toSnapshot} on the authoritative state.
|
|
77
|
+
*/
|
|
78
|
+
export interface ConnSnapshot {
|
|
79
|
+
readonly id: ConnId;
|
|
80
|
+
readonly nick?: Nick;
|
|
81
|
+
readonly user?: string;
|
|
82
|
+
readonly host?: string;
|
|
83
|
+
readonly realname?: string;
|
|
84
|
+
readonly account?: string;
|
|
85
|
+
readonly registration: RegistrationState;
|
|
86
|
+
readonly caps: ReadonlySet<string>;
|
|
87
|
+
readonly away?: string;
|
|
88
|
+
readonly joinedChannels: ReadonlySet<ChanName>;
|
|
89
|
+
readonly userModes: Readonly<UserModes>;
|
|
90
|
+
readonly lastSeen: number;
|
|
91
|
+
readonly connectedSince: number;
|
|
92
|
+
/** Derived `nick!user@host`; absent when nick is missing. */
|
|
93
|
+
readonly hostmask?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CreateConnectionOptions {
|
|
97
|
+
id: ConnId;
|
|
98
|
+
connectedSince: number;
|
|
99
|
+
/** Defaults to {@link connectedSince}. */
|
|
100
|
+
lastSeen?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Constructs a fresh pre-registration connection. */
|
|
104
|
+
export function createConnection(opts: CreateConnectionOptions): ConnectionState {
|
|
105
|
+
return {
|
|
106
|
+
id: opts.id,
|
|
107
|
+
registration: 'pre-registration',
|
|
108
|
+
capNegotiating: false,
|
|
109
|
+
caps: new Set<string>(),
|
|
110
|
+
joinedChannels: new Set<ChanName>(),
|
|
111
|
+
userModes: {
|
|
112
|
+
invisible: false,
|
|
113
|
+
oper: false,
|
|
114
|
+
wallops: false,
|
|
115
|
+
serverNotices: false,
|
|
116
|
+
},
|
|
117
|
+
lastSeen: opts.lastSeen ?? opts.connectedSince,
|
|
118
|
+
connectedSince: opts.connectedSince,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Builds the canonical `nick!user@host` string, omitting absent parts. */
|
|
123
|
+
export function hostmaskOf(conn: ConnectionState): string | undefined {
|
|
124
|
+
if (conn.nick === undefined) return undefined;
|
|
125
|
+
let out = conn.nick;
|
|
126
|
+
if (conn.user !== undefined) out = `${out}!${conn.user}`;
|
|
127
|
+
if (conn.host !== undefined) out = `${out}@${conn.host}`;
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Freezes a mutable connection state into an immutable snapshot view. */
|
|
132
|
+
export function toSnapshot(conn: ConnectionState): ConnSnapshot {
|
|
133
|
+
const hostmask = hostmaskOf(conn);
|
|
134
|
+
// Build as a plain object literal with optional keys omitted entirely
|
|
135
|
+
// (exactOptionalPropertyTypes + readonly fields forbid assign-after-build).
|
|
136
|
+
const snap: ConnSnapshot = {
|
|
137
|
+
id: conn.id,
|
|
138
|
+
registration: conn.registration,
|
|
139
|
+
caps: conn.caps,
|
|
140
|
+
joinedChannels: conn.joinedChannels,
|
|
141
|
+
userModes: conn.userModes,
|
|
142
|
+
lastSeen: conn.lastSeen,
|
|
143
|
+
connectedSince: conn.connectedSince,
|
|
144
|
+
...(conn.nick !== undefined ? { nick: conn.nick } : {}),
|
|
145
|
+
...(conn.user !== undefined ? { user: conn.user } : {}),
|
|
146
|
+
...(conn.host !== undefined ? { host: conn.host } : {}),
|
|
147
|
+
...(conn.realname !== undefined ? { realname: conn.realname } : {}),
|
|
148
|
+
...(conn.account !== undefined ? { account: conn.account } : {}),
|
|
149
|
+
...(conn.away !== undefined ? { away: conn.away } : {}),
|
|
150
|
+
...(hostmask !== undefined ? { hostmask } : {}),
|
|
151
|
+
};
|
|
152
|
+
return snap;
|
|
153
|
+
}
|