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,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @serverless-ircd/irc-core
|
|
3
|
+
*
|
|
4
|
+
* Platform-agnostic IRC protocol core: parser, serializer, numeric registry,
|
|
5
|
+
* state shapes, Effect taxonomy, reducer signature, and injected ports.
|
|
6
|
+
*/
|
|
7
|
+
export * from './protocol/index.js';
|
|
8
|
+
export * from './state/index.js';
|
|
9
|
+
export * from './caps/index.js';
|
|
10
|
+
export * from './commands/index.js';
|
|
11
|
+
export * from './effects.js';
|
|
12
|
+
export * from './ports.js';
|
|
13
|
+
export type {
|
|
14
|
+
BuildCtxOptions,
|
|
15
|
+
Ctx,
|
|
16
|
+
Reducer,
|
|
17
|
+
ReducerResult,
|
|
18
|
+
ServerConfig,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
export { buildCtx } from './types.js';
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injected ports for deterministic time and IDs.
|
|
3
|
+
*
|
|
4
|
+
* Reducers MUST NOT call `Date.now()`, `Math.random()`, or `crypto` directly:
|
|
5
|
+
* they take a {@link Clock} and {@link IdFactory} via {@link Ctx} so tests
|
|
6
|
+
* can pin the timeline and identity space (PLAN §8 determinism rules).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Read-only wall clock, epoch milliseconds. */
|
|
10
|
+
export interface Clock {
|
|
11
|
+
now(): number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Real-system clock. Used in production; never in unit tests. */
|
|
15
|
+
export const SystemClock: Clock = {
|
|
16
|
+
now(): number {
|
|
17
|
+
return Date.now();
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fake clock for tests. Deterministic, manually advanced.
|
|
23
|
+
*
|
|
24
|
+
* const clock = new FakeClock(1000);
|
|
25
|
+
* clock.advance(50);
|
|
26
|
+
* clock.now() === 1050;
|
|
27
|
+
*/
|
|
28
|
+
export class FakeClock implements Clock {
|
|
29
|
+
private current: number;
|
|
30
|
+
constructor(start = 0) {
|
|
31
|
+
this.current = start;
|
|
32
|
+
}
|
|
33
|
+
now(): number {
|
|
34
|
+
return this.current;
|
|
35
|
+
}
|
|
36
|
+
advance(ms: number): void {
|
|
37
|
+
this.current += ms;
|
|
38
|
+
}
|
|
39
|
+
set(ms: number): void {
|
|
40
|
+
this.current = ms;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Factory for the various opaque identifiers reducers and dispatch emit. */
|
|
45
|
+
export interface IdFactory {
|
|
46
|
+
/** `BATCH` reference id (IRCv3 batch). */
|
|
47
|
+
batchId(): string;
|
|
48
|
+
/** Per-message nonce / tag id (e.g. msgid). */
|
|
49
|
+
nonce(): string;
|
|
50
|
+
/** SASL / session-scoped id. */
|
|
51
|
+
sessionId(): string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Production id factory. Generates UUIDv4-shaped strings without relying
|
|
56
|
+
* on `crypto` so the same code runs on Node 20, Cloudflare Workers, and
|
|
57
|
+
* Lambda without per-platform type wrangling. Uniqueness is statistical;
|
|
58
|
+
* for cryptographic IDs, adapters can supply their own {@link IdFactory}.
|
|
59
|
+
*/
|
|
60
|
+
export class UuidIdFactory implements IdFactory {
|
|
61
|
+
batchId(): string {
|
|
62
|
+
return uuidV4();
|
|
63
|
+
}
|
|
64
|
+
nonce(): string {
|
|
65
|
+
return uuidV4();
|
|
66
|
+
}
|
|
67
|
+
sessionId(): string {
|
|
68
|
+
return uuidV4();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// RFC 4122 v4 shape (random). Six fixed bits identify the version/variant;
|
|
73
|
+
// the remaining 122 bits come from Math.random. Good enough for non-security
|
|
74
|
+
// identifiers like BATCH ref ids and msgid tags.
|
|
75
|
+
const HEX = '0123456789abcdef';
|
|
76
|
+
function uuidV4(): string {
|
|
77
|
+
let out = '';
|
|
78
|
+
for (let i = 0; i < 36; i++) {
|
|
79
|
+
if (i === 8 || i === 13 || i === 18 || i === 23) {
|
|
80
|
+
out += '-';
|
|
81
|
+
} else if (i === 14) {
|
|
82
|
+
out += '4';
|
|
83
|
+
} else if (i === 19) {
|
|
84
|
+
out += HEX[(Math.random() * 4) | 0 | 0x8];
|
|
85
|
+
} else {
|
|
86
|
+
out += HEX[(Math.random() * 16) | 0];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Deterministic id factory for tests. A single counter is shared across
|
|
94
|
+
* methods so the output is fully predictable regardless of which method
|
|
95
|
+
* is called first.
|
|
96
|
+
*/
|
|
97
|
+
export class SequentialIdFactory implements IdFactory {
|
|
98
|
+
private counter = 0;
|
|
99
|
+
batchId(): string {
|
|
100
|
+
return `batch-${this.counter++}`;
|
|
101
|
+
}
|
|
102
|
+
nonce(): string {
|
|
103
|
+
return `nonce-${this.counter++}`;
|
|
104
|
+
}
|
|
105
|
+
sessionId(): string {
|
|
106
|
+
return `session-${this.counter++}`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Port supplying the server's Message-of-the-Day text.
|
|
112
|
+
*
|
|
113
|
+
* The MOTD reducer is pure and synchronous; it cannot read
|
|
114
|
+
* KV / D1 / SecretsManager itself. Adapters pre-load the MOTD at startup
|
|
115
|
+
* (e.g. when a `ConnectionDO` wakes or a Lambda cold-starts) and inject a
|
|
116
|
+
* synchronously-readable {@link MotdProvider} into {@link Ctx}. This keeps
|
|
117
|
+
* the reducer free of cloud deps and deterministic for tests.
|
|
118
|
+
*
|
|
119
|
+
* Each call to {@link lines} must return a fresh snapshot of the MOTD as
|
|
120
|
+
* the reducer sees it: an empty array signals "no MOTD configured" and
|
|
121
|
+
* produces `422 ERR_NOMOTD`. Each returned string is one logical MOTD
|
|
122
|
+
* line; over-long lines are hard-wrapped by the reducer to fit the
|
|
123
|
+
* 512-byte wire limit.
|
|
124
|
+
*/
|
|
125
|
+
export interface MotdProvider {
|
|
126
|
+
/** Returns the MOTD lines (empty array = no MOTD configured). */
|
|
127
|
+
lines(): string[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Reference in-memory {@link MotdProvider} backed by a mutable array.
|
|
132
|
+
*
|
|
133
|
+
* Used by adapters that pre-load the MOTD into memory (the local CLI, CF
|
|
134
|
+
* Worker with KV-cached MOTD, AWS Lambda with env/SSM-cached MOTD) and by
|
|
135
|
+
* tests. The backing array is mutable so a deployment can hot-reload the
|
|
136
|
+
* MOTD without rebuilding the provider.
|
|
137
|
+
*/
|
|
138
|
+
export class StaticMotdProvider implements MotdProvider {
|
|
139
|
+
private motdLines: string[];
|
|
140
|
+
constructor(lines: string[] = []) {
|
|
141
|
+
this.motdLines = [...lines];
|
|
142
|
+
}
|
|
143
|
+
lines(): string[] {
|
|
144
|
+
return this.motdLines;
|
|
145
|
+
}
|
|
146
|
+
/** Replaces the backing MOTD lines (used for hot-reload + tests). */
|
|
147
|
+
setLines(lines: string[]): void {
|
|
148
|
+
this.motdLines = [...lines];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Convenience: a {@link MotdProvider} that always reports "no MOTD". */
|
|
153
|
+
export const EmptyMotdProvider: MotdProvider = new StaticMotdProvider([]);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IRCv3 `batch` framing helper.
|
|
3
|
+
*
|
|
4
|
+
* Spec: https://ircv3.net/specs/extensions/batch-3.3
|
|
5
|
+
*
|
|
6
|
+
* `wrapBatch` is a pure transformation: given an ordered list of inner
|
|
7
|
+
* {@link RawLine}s and a batch reference id, it returns a new line list
|
|
8
|
+
* bounded by `BATCH +id type [args]` (start) and `BATCH -id` (end). The
|
|
9
|
+
* caller is responsible for allocating the id (via {@link IdFactory}) so
|
|
10
|
+
* the function remains deterministic and free of side effects.
|
|
11
|
+
*
|
|
12
|
+
* Nested batches are simply an outer `wrapBatch` call whose `lines`
|
|
13
|
+
* argument is the result of an inner `wrapBatch` call: the framing is
|
|
14
|
+
* composable by design. The caller MUST allocate distinct ids per batch
|
|
15
|
+
* (the spec forbids reuse of a ref id while the batch is open).
|
|
16
|
+
*
|
|
17
|
+
* Without the `batch` cap negotiated, the dispatch layer omits the
|
|
18
|
+
* wrapping entirely and the recipient observes the inner lines verbatim.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { RawLine } from '../effects.js';
|
|
22
|
+
|
|
23
|
+
/** Output of {@link wrapBatch}: the framing plus the inner body as one list. */
|
|
24
|
+
export interface BatchFrame extends Array<RawLine> {
|
|
25
|
+
/** The reference id used in the `+id`/`-id` markers. */
|
|
26
|
+
readonly id: string;
|
|
27
|
+
/** The batch type token (e.g. `names`, `join`, `netjoin`). */
|
|
28
|
+
readonly type: string;
|
|
29
|
+
/** The `BATCH +id type [args]` start marker. */
|
|
30
|
+
readonly start: RawLine;
|
|
31
|
+
/** The original inner lines, unmodified. */
|
|
32
|
+
readonly body: RawLine[];
|
|
33
|
+
/** The `BATCH -id` end marker. */
|
|
34
|
+
readonly end: RawLine;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wraps `lines` in IRCv3 `BATCH` framing.
|
|
39
|
+
*
|
|
40
|
+
* @param lines Inner batch contents (in send order). Empty input returns an
|
|
41
|
+
* empty array unchanged — callers should not emit empty batches.
|
|
42
|
+
* @param id Batch reference id (allocate via `IdFactory.batchId()`).
|
|
43
|
+
* @param type Batch type token (opaque to clients; known types like
|
|
44
|
+
* `netjoin`, `netsplit`, `chghost`, `topic` are honoured by
|
|
45
|
+
* clients that understand them).
|
|
46
|
+
* @param args Optional trailing parameters appended after the type token
|
|
47
|
+
* (e.g. the channel name for a `netjoin #foo` batch). Omitted
|
|
48
|
+
* entirely when `undefined`.
|
|
49
|
+
* @returns A new {@link BatchFrame} (array) suitable to be sent in order.
|
|
50
|
+
*/
|
|
51
|
+
export function wrapBatch(
|
|
52
|
+
lines: readonly RawLine[],
|
|
53
|
+
id: string,
|
|
54
|
+
type: string,
|
|
55
|
+
args?: string,
|
|
56
|
+
): BatchFrame {
|
|
57
|
+
if (lines.length === 0) {
|
|
58
|
+
// Return an empty BatchFrame so the caller's array shape is stable.
|
|
59
|
+
// Empty batches are forbidden by the spec; we silently elide them.
|
|
60
|
+
const empty = [] as unknown as BatchFrame;
|
|
61
|
+
Object.defineProperties(empty, {
|
|
62
|
+
id: { value: id },
|
|
63
|
+
type: { value: type },
|
|
64
|
+
start: { value: { text: '' } },
|
|
65
|
+
body: { value: [] },
|
|
66
|
+
end: { value: { text: '' } },
|
|
67
|
+
});
|
|
68
|
+
return empty;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const startText = args !== undefined ? `BATCH +${id} ${type} ${args}` : `BATCH +${id} ${type}`;
|
|
72
|
+
const start: RawLine = { text: startText };
|
|
73
|
+
const end: RawLine = { text: `BATCH -${id}` };
|
|
74
|
+
const body = [...lines];
|
|
75
|
+
|
|
76
|
+
const out = [start, ...body, end] as unknown as BatchFrame;
|
|
77
|
+
Object.defineProperties(out, {
|
|
78
|
+
id: { value: id },
|
|
79
|
+
type: { value: type },
|
|
80
|
+
start: { value: start },
|
|
81
|
+
body: { value: body },
|
|
82
|
+
end: { value: end },
|
|
83
|
+
});
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type { IrcMessage, IrcSource } from './messages.js';
|
|
2
|
+
export { IrcParseError, parse, unescapeTagValue } from './parser.js';
|
|
3
|
+
export { escapeTagValue, serialize, serializeFrame } from './serializer.js';
|
|
4
|
+
export {
|
|
5
|
+
MAX_LINE_BYTES,
|
|
6
|
+
applyServerTime,
|
|
7
|
+
enforceLineLimit,
|
|
8
|
+
filterClientTags,
|
|
9
|
+
formatServerTime,
|
|
10
|
+
} from './outbound.js';
|
|
11
|
+
export { wrapBatch, type BatchFrame } from './batch.js';
|
|
12
|
+
export {
|
|
13
|
+
formatNumeric,
|
|
14
|
+
isNumericCommand,
|
|
15
|
+
type NumericCode,
|
|
16
|
+
numericToName,
|
|
17
|
+
Numerics,
|
|
18
|
+
} from './numerics.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured shapes for a parsed IRC message.
|
|
3
|
+
*
|
|
4
|
+
* These types are wire-format concepts only. Higher layers (command handlers,
|
|
5
|
+
* runtime effects) build on top of {@link IrcMessage} but add their own
|
|
6
|
+
* semantics.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Message source ("prefix"). For a user it is `nick!user@host`; for a server
|
|
11
|
+
* it is just `name`. When `user`/`host` are absent the source is server-like.
|
|
12
|
+
*/
|
|
13
|
+
export interface IrcSource {
|
|
14
|
+
/** Nickname (for a user) or server name. */
|
|
15
|
+
name: string;
|
|
16
|
+
/** Ident/user name. Present only for user sources. */
|
|
17
|
+
user?: string;
|
|
18
|
+
/** Hostname. Present for user sources and some server sources. */
|
|
19
|
+
host?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A single IRC protocol message, parsed from one wire line.
|
|
24
|
+
*
|
|
25
|
+
* - `tags` is always present (empty record when none were on the wire). A
|
|
26
|
+
* valueless tag is represented as the empty string.
|
|
27
|
+
* - `command` is normalized: letters are upper-cased; numeric commands keep
|
|
28
|
+
* their three-digit string form.
|
|
29
|
+
* - `params` includes the trailing parameter (if any) as its last element.
|
|
30
|
+
*/
|
|
31
|
+
export interface IrcMessage {
|
|
32
|
+
tags: Readonly<Record<string, string>>;
|
|
33
|
+
source?: IrcSource;
|
|
34
|
+
command: string;
|
|
35
|
+
params: string[];
|
|
36
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IRC numeric reply/error codes (RFC 1459/2812 + common modern extensions).
|
|
3
|
+
*
|
|
4
|
+
* One canonical name per code (no aliases) so the reverse map is unambiguous.
|
|
5
|
+
* @see PLAN.md §3 for the supported subset.
|
|
6
|
+
*/
|
|
7
|
+
export const Numerics = {
|
|
8
|
+
// Welcome / registration
|
|
9
|
+
RPL_WELCOME: 1,
|
|
10
|
+
RPL_YOURHOST: 2,
|
|
11
|
+
RPL_CREATED: 3,
|
|
12
|
+
RPL_MYINFO: 4,
|
|
13
|
+
RPL_ISUPPORT: 5,
|
|
14
|
+
RPL_BOUNCE: 10,
|
|
15
|
+
|
|
16
|
+
// Capability / stats admin
|
|
17
|
+
RPL_ADMINME: 256,
|
|
18
|
+
|
|
19
|
+
// Away / userhost / ison
|
|
20
|
+
RPL_AWAY: 301,
|
|
21
|
+
RPL_USERHOST: 302,
|
|
22
|
+
RPL_ISON: 303,
|
|
23
|
+
RPL_UNAWAY: 305,
|
|
24
|
+
RPL_NOWAWAY: 306,
|
|
25
|
+
|
|
26
|
+
// WHOIS
|
|
27
|
+
RPL_WHOISUSER: 311,
|
|
28
|
+
RPL_WHOISSERVER: 312,
|
|
29
|
+
RPL_WHOISOPERATOR: 313,
|
|
30
|
+
RPL_WHOISIDLE: 317,
|
|
31
|
+
RPL_ENDOFWHOIS: 318,
|
|
32
|
+
RPL_WHOISCHANNELS: 319,
|
|
33
|
+
RPL_WHOISACCOUNT: 330,
|
|
34
|
+
|
|
35
|
+
// LIST
|
|
36
|
+
RPL_LISTSTART: 321,
|
|
37
|
+
RPL_LIST: 322,
|
|
38
|
+
RPL_LISTEND: 323,
|
|
39
|
+
|
|
40
|
+
// Channel mode / metadata
|
|
41
|
+
RPL_CHANNELMODEIS: 324,
|
|
42
|
+
RPL_CREATIONTIME: 329,
|
|
43
|
+
RPL_NOTOPIC: 331,
|
|
44
|
+
RPL_TOPIC: 332,
|
|
45
|
+
RPL_TOPICWHOTIME: 333,
|
|
46
|
+
|
|
47
|
+
// Invite / except lists
|
|
48
|
+
RPL_INVITING: 341,
|
|
49
|
+
RPL_INVITELIST: 346,
|
|
50
|
+
RPL_ENDOFINVITELIST: 347,
|
|
51
|
+
RPL_EXCEPTLIST: 348,
|
|
52
|
+
RPL_ENDOFEXCEPTLIST: 349,
|
|
53
|
+
|
|
54
|
+
// WHO / NAMES
|
|
55
|
+
RPL_WHOREPLY: 352,
|
|
56
|
+
RPL_ENDOFWHO: 315,
|
|
57
|
+
RPL_NAMREPLY: 353,
|
|
58
|
+
RPL_ENDOFNAMES: 366,
|
|
59
|
+
|
|
60
|
+
// Links / ban / whowas
|
|
61
|
+
RPL_LINKS: 364,
|
|
62
|
+
RPL_ENDOFLINKS: 365,
|
|
63
|
+
RPL_BANLIST: 367,
|
|
64
|
+
RPL_ENDOFBANLIST: 368,
|
|
65
|
+
RPL_ENDOFWHOWAS: 369,
|
|
66
|
+
|
|
67
|
+
// Info / MOTD
|
|
68
|
+
RPL_INFO: 371,
|
|
69
|
+
RPL_ENDOFINFO: 374,
|
|
70
|
+
RPL_MOTD: 372,
|
|
71
|
+
RPL_MOTDSTART: 375,
|
|
72
|
+
RPL_ENDOFMOTD: 376,
|
|
73
|
+
|
|
74
|
+
// Oper
|
|
75
|
+
RPL_YOUREOPER: 381,
|
|
76
|
+
RPL_REHASHING: 382,
|
|
77
|
+
RPL_YOURESERVICE: 383,
|
|
78
|
+
RPL_TIME: 391,
|
|
79
|
+
|
|
80
|
+
// Errors
|
|
81
|
+
ERR_NOSUCHNICK: 401,
|
|
82
|
+
ERR_NOSUCHSERVER: 402,
|
|
83
|
+
ERR_NOSUCHCHANNEL: 403,
|
|
84
|
+
ERR_CANNOTSENDTOCHAN: 404,
|
|
85
|
+
ERR_TOOMANYCHANNELS: 405,
|
|
86
|
+
ERR_WASNOSUCHNICK: 406,
|
|
87
|
+
ERR_TOOMANYTARGETS: 407,
|
|
88
|
+
ERR_NOSUCHSERVICE: 408,
|
|
89
|
+
ERR_INVALIDCAPCMD: 410,
|
|
90
|
+
ERR_NORECIPIENT: 411,
|
|
91
|
+
ERR_NOTEXTTOSEND: 412,
|
|
92
|
+
ERR_NOTOPLEVEL: 413,
|
|
93
|
+
ERR_WILDTOPLEVEL: 414,
|
|
94
|
+
ERR_UNKNOWNCOMMAND: 421,
|
|
95
|
+
ERR_NOMOTD: 422,
|
|
96
|
+
ERR_NOADMININFO: 423,
|
|
97
|
+
ERR_FILEERROR: 424,
|
|
98
|
+
ERR_NONICKNAMEGIVEN: 431,
|
|
99
|
+
ERR_ERRONEUSNICKNAME: 432,
|
|
100
|
+
ERR_NICKNAMEINUSE: 433,
|
|
101
|
+
ERR_NICKCOLLISION: 436,
|
|
102
|
+
ERR_UNAVAILRESOURCE: 437,
|
|
103
|
+
ERR_USERNOTINCHANNEL: 441,
|
|
104
|
+
ERR_NOTONCHANNEL: 442,
|
|
105
|
+
ERR_USERONCHANNEL: 443,
|
|
106
|
+
ERR_NOLOGIN: 444,
|
|
107
|
+
ERR_SUMMONDISABLED: 445,
|
|
108
|
+
ERR_USERSDISABLED: 446,
|
|
109
|
+
ERR_NOTREGISTERED: 451,
|
|
110
|
+
ERR_NEEDMOREPARAMS: 461,
|
|
111
|
+
ERR_ALREADYREGISTRED: 462,
|
|
112
|
+
ERR_NOPERMFORHOST: 463,
|
|
113
|
+
ERR_PASSWDMISMATCH: 464,
|
|
114
|
+
ERR_YOUREBANNEDCREEP: 465,
|
|
115
|
+
ERR_KEYSET: 467,
|
|
116
|
+
ERR_CHANNELISFULL: 471,
|
|
117
|
+
ERR_UNKNOWNMODE: 472,
|
|
118
|
+
ERR_INVITEONLYCHAN: 473,
|
|
119
|
+
ERR_BANNEDFROMCHAN: 474,
|
|
120
|
+
ERR_BADCHANNELKEY: 475,
|
|
121
|
+
ERR_BADCHANMASK: 476,
|
|
122
|
+
ERR_NOCHANMODES: 477,
|
|
123
|
+
ERR_BANLISTFULL: 478,
|
|
124
|
+
ERR_NOPRIVILEGES: 481,
|
|
125
|
+
ERR_CHANOPRIVSNEEDED: 482,
|
|
126
|
+
ERR_CANTKILLSERVER: 483,
|
|
127
|
+
ERR_NOOPERHOST: 491,
|
|
128
|
+
ERR_UMODEUNKNOWNFLAG: 501,
|
|
129
|
+
ERR_USERSDONTMATCH: 502,
|
|
130
|
+
} as const;
|
|
131
|
+
|
|
132
|
+
export type NumericCode = keyof typeof Numerics;
|
|
133
|
+
|
|
134
|
+
/** Reverse lookup: numeric code -> canonical name. */
|
|
135
|
+
export const numericToName: ReadonlyMap<number, NumericCode> = new Map(
|
|
136
|
+
(Object.entries(Numerics) as ReadonlyArray<[NumericCode, number]>).map(([name, code]) => [
|
|
137
|
+
code,
|
|
138
|
+
name,
|
|
139
|
+
]),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const NUMERIC_COMMAND_RE = /^[0-9]{3}$/;
|
|
143
|
+
|
|
144
|
+
/** A command token is a numeric iff it is exactly three digits. */
|
|
145
|
+
export function isNumericCommand(command: string): boolean {
|
|
146
|
+
return NUMERIC_COMMAND_RE.test(command);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Formats a numeric by name as its three-digit wire representation.
|
|
151
|
+
* Zero-pads codes below 100.
|
|
152
|
+
*/
|
|
153
|
+
export function formatNumeric(name: NumericCode): string {
|
|
154
|
+
return Numerics[name].toString().padStart(3, '0');
|
|
155
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-recipient outbound transformations for IRCv3 capabilities.
|
|
3
|
+
*
|
|
4
|
+
* Reducers emit {@link RawLine} strings already formatted; they cannot know
|
|
5
|
+
* which caps each downstream recipient has negotiated. These pure helpers
|
|
6
|
+
* are called by the dispatch / runtime layer when delivering lines to a
|
|
7
|
+
* specific connection, applying (or omitting) the IRCv3 `server-time` and
|
|
8
|
+
* `message-tags` decorations according to that recipient's cap set.
|
|
9
|
+
*
|
|
10
|
+
* Spec references:
|
|
11
|
+
* - IRCv3 Server-Time: the time tag is `time` and uses ISO-8601 with
|
|
12
|
+
* millisecond precision (`yyyy-MM-ddTHH:mm:ss.sssZ`).
|
|
13
|
+
* - IRCv3 Message Tags: the 512-byte IRC line limit INCLUDES the tag
|
|
14
|
+
* section for clients that negotiated `message-tags`. Legacy clients
|
|
15
|
+
* never see tags so the budget applies to the body only.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { RawLine } from '../effects.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing
|
|
22
|
+
* CR-LF. Adapters append `\r\n` after the lines produced here, so the
|
|
23
|
+
* budget for the {@link RawLine} text itself is 510 bytes. We retain the
|
|
24
|
+
* 512 symbolic constant because every other place that enforces the limit
|
|
25
|
+
* (CAP LS splitting, MOTD wrapping) does the same subtraction locally.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_LINE_BYTES = 512;
|
|
28
|
+
|
|
29
|
+
/** Length of the trailing CR-LF the wire layer appends to each line. */
|
|
30
|
+
const TRAILING_CRLF_LEN = 2;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Formats epoch milliseconds as the IRCv3 Server-Time tag value:
|
|
34
|
+
* `yyyy-MM-ddTHH:mm:ss.sssZ` (ISO-8601, UTC, millisecond precision).
|
|
35
|
+
*
|
|
36
|
+
* Implemented via `Date#toISOString`, which is the canonical form required
|
|
37
|
+
* by the spec and already zero-pads every field.
|
|
38
|
+
*/
|
|
39
|
+
export function formatServerTime(now: number): string {
|
|
40
|
+
return new Date(now).toISOString();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Prepends the `@time=...` server-time tag to `line.text` iff the recipient
|
|
45
|
+
* negotiated the `server-time` cap. Returns the original line object
|
|
46
|
+
* unchanged otherwise.
|
|
47
|
+
*
|
|
48
|
+
* The returned {@link RawLine} is always a fresh object so callers never
|
|
49
|
+
* observe accidental mutation of the input.
|
|
50
|
+
*
|
|
51
|
+
* Note: when `line.text` already begins with a tag section (`@...`), the
|
|
52
|
+
* `time` tag is merged into the existing section so we never emit two `@`
|
|
53
|
+
* prefixes. This matches how the dispatch layer composes per-recipient
|
|
54
|
+
* decorations from multiple cap helpers.
|
|
55
|
+
*/
|
|
56
|
+
export function applyServerTime(line: RawLine, caps: ReadonlySet<string>, now: number): RawLine {
|
|
57
|
+
if (!caps.has('server-time')) return line;
|
|
58
|
+
const stamp = formatServerTime(now);
|
|
59
|
+
if (line.text.startsWith('@')) {
|
|
60
|
+
// Insert `time=<stamp>;` immediately after the leading `@`.
|
|
61
|
+
const merged = `@time=${stamp};${line.text.slice(1)}`;
|
|
62
|
+
return { text: merged };
|
|
63
|
+
}
|
|
64
|
+
return { text: `@time=${stamp} ${line.text}` };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Enforces the IRC 512-byte line limit on `line.text`.
|
|
69
|
+
*
|
|
70
|
+
* For recipients that negotiated `message-tags`, the tag section (the
|
|
71
|
+
* leading `@... ` prefix) is part of the wire budget and is preserved
|
|
72
|
+
* intact; truncation happens at the end of the trailing parameter only.
|
|
73
|
+
* For legacy recipients the entire `line.text` is the body and may be
|
|
74
|
+
* truncated from the right.
|
|
75
|
+
*
|
|
76
|
+
* The returned {@link RawLine} is the original object when no truncation
|
|
77
|
+
* was needed, otherwise a fresh object with the truncated text.
|
|
78
|
+
*/
|
|
79
|
+
export function enforceLineLimit(line: RawLine, caps: ReadonlySet<string>): RawLine {
|
|
80
|
+
const budget = MAX_LINE_BYTES - TRAILING_CRLF_LEN;
|
|
81
|
+
if (line.text.length <= budget) return line;
|
|
82
|
+
|
|
83
|
+
const hasTags = caps.has('message-tags') && line.text.startsWith('@');
|
|
84
|
+
if (!hasTags) {
|
|
85
|
+
return { text: line.text.slice(0, budget) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Preserve the tag section verbatim; truncate only the post-tag body.
|
|
89
|
+
const spaceIdx = line.text.indexOf(' ');
|
|
90
|
+
if (spaceIdx === -1) {
|
|
91
|
+
// Malformed: a `@`-prefixed line with no space. Truncate from the right.
|
|
92
|
+
return { text: line.text.slice(0, budget) };
|
|
93
|
+
}
|
|
94
|
+
const tagSection = line.text.slice(0, spaceIdx + 1); // include trailing space
|
|
95
|
+
const body = line.text.slice(spaceIdx + 1);
|
|
96
|
+
const bodyBudget = budget - tagSection.length;
|
|
97
|
+
if (bodyBudget < 0) {
|
|
98
|
+
// Pathological: the tag section alone exceeds the budget. Drop the body
|
|
99
|
+
// entirely and truncate the tag section itself so the wire contract
|
|
100
|
+
// (≤ budget bytes) still holds.
|
|
101
|
+
return { text: line.text.slice(0, budget) };
|
|
102
|
+
}
|
|
103
|
+
return { text: `${tagSection}${body.slice(0, bodyBudget)}` };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Strips IRCv3 message-tags client-only tags (keys prefixed with `+`) from
|
|
108
|
+
* the line's tag section when the recipient did NOT negotiate the
|
|
109
|
+
* `message-tags` capability. Recipients that negotiated `message-tags`
|
|
110
|
+
* receive the line verbatim.
|
|
111
|
+
*
|
|
112
|
+
* Per the IRCv3 Message Tags spec, `+`-prefixed tags are client-only and
|
|
113
|
+
* MUST NOT be delivered to clients that lack the `message-tags` cap.
|
|
114
|
+
* Server-defined tags (no `+` prefix) are left in place — each is gated by
|
|
115
|
+
* its own capability (server-time, account-tag, …) applied elsewhere.
|
|
116
|
+
*
|
|
117
|
+
* When filtering removes every tag, the leading `@... ` section is dropped
|
|
118
|
+
* entirely so legacy clients never observe a bare tag prefix.
|
|
119
|
+
*
|
|
120
|
+
* The input {@link RawLine} is never mutated; the original object is
|
|
121
|
+
* returned unchanged when no client tag needs removing.
|
|
122
|
+
*/
|
|
123
|
+
export function filterClientTags(line: RawLine, caps: ReadonlySet<string>): RawLine {
|
|
124
|
+
if (caps.has('message-tags')) return line;
|
|
125
|
+
if (!line.text.startsWith('@')) return line;
|
|
126
|
+
const spaceIdx = line.text.indexOf(' ');
|
|
127
|
+
if (spaceIdx === -1) return line;
|
|
128
|
+
|
|
129
|
+
const tagSection = line.text.slice(1, spaceIdx);
|
|
130
|
+
const rest = line.text.slice(spaceIdx + 1);
|
|
131
|
+
const entries = tagSection.split(';');
|
|
132
|
+
|
|
133
|
+
const hasClientTag = entries.some((entry) => clientTagKey(entry).startsWith('+'));
|
|
134
|
+
if (!hasClientTag) return line;
|
|
135
|
+
|
|
136
|
+
const kept = entries.filter((entry) => !clientTagKey(entry).startsWith('+'));
|
|
137
|
+
if (kept.length === 0) return { text: rest };
|
|
138
|
+
return { text: `@${kept.join(';')} ${rest}` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Returns the key portion of a raw `key` or `key=value` tag entry. */
|
|
142
|
+
function clientTagKey(entry: string): string {
|
|
143
|
+
const eq = entry.indexOf('=');
|
|
144
|
+
return eq === -1 ? entry : entry.slice(0, eq);
|
|
145
|
+
}
|