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.
Files changed (165) hide show
  1. package/.github/workflows/ci.yml +47 -0
  2. package/.github/workflows/deploy-cf.yml +97 -0
  3. package/LICENSE +29 -0
  4. package/README.md +323 -0
  5. package/apps/cf-worker/package.json +37 -0
  6. package/apps/cf-worker/scripts/smoke.mjs +175 -0
  7. package/apps/cf-worker/src/worker.ts +59 -0
  8. package/apps/cf-worker/tests/smoke.test.ts +150 -0
  9. package/apps/cf-worker/tsconfig.build.json +17 -0
  10. package/apps/cf-worker/tsconfig.test.json +18 -0
  11. package/apps/cf-worker/vitest.config.ts +31 -0
  12. package/apps/cf-worker/wrangler.test.toml +33 -0
  13. package/apps/cf-worker/wrangler.toml +98 -0
  14. package/apps/local-cli/package.json +35 -0
  15. package/apps/local-cli/src/line-scanner.ts +60 -0
  16. package/apps/local-cli/src/main.ts +145 -0
  17. package/apps/local-cli/src/motd-file.ts +45 -0
  18. package/apps/local-cli/src/server.ts +409 -0
  19. package/apps/local-cli/tests/e2e.test.ts +346 -0
  20. package/apps/local-cli/tests/id-factory.test.ts +25 -0
  21. package/apps/local-cli/tests/line-scanner.test.ts +81 -0
  22. package/apps/local-cli/tests/motd-file.test.ts +71 -0
  23. package/apps/local-cli/tests/tcp.test.ts +358 -0
  24. package/apps/local-cli/tsconfig.build.json +17 -0
  25. package/apps/local-cli/tsconfig.test.json +15 -0
  26. package/apps/local-cli/vitest.config.ts +24 -0
  27. package/biome.json +52 -0
  28. package/package.json +35 -0
  29. package/packages/cf-adapter/package.json +38 -0
  30. package/packages/cf-adapter/src/cf-runtime.ts +308 -0
  31. package/packages/cf-adapter/src/channel-do.ts +374 -0
  32. package/packages/cf-adapter/src/connection-do.ts +542 -0
  33. package/packages/cf-adapter/src/env.ts +67 -0
  34. package/packages/cf-adapter/src/index.ts +38 -0
  35. package/packages/cf-adapter/src/registry-do.ts +130 -0
  36. package/packages/cf-adapter/src/serialize.ts +142 -0
  37. package/packages/cf-adapter/src/sharding.ts +101 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +264 -0
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
  41. package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
  42. package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
  43. package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
  44. package/packages/cf-adapter/tests/integration/harness.ts +102 -0
  45. package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
  46. package/packages/cf-adapter/tests/integration/index.ts +17 -0
  47. package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
  48. package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
  49. package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
  50. package/packages/cf-adapter/tests/sharding.test.ts +100 -0
  51. package/packages/cf-adapter/tests/worker/main.ts +33 -0
  52. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
  53. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
  54. package/packages/cf-adapter/tsconfig.build.json +16 -0
  55. package/packages/cf-adapter/tsconfig.test.json +18 -0
  56. package/packages/cf-adapter/vitest.config.ts +32 -0
  57. package/packages/cf-adapter/wrangler.test.toml +50 -0
  58. package/packages/in-memory-runtime/package.json +29 -0
  59. package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
  60. package/packages/in-memory-runtime/src/index.ts +9 -0
  61. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
  62. package/packages/in-memory-runtime/tsconfig.build.json +16 -0
  63. package/packages/in-memory-runtime/tsconfig.test.json +14 -0
  64. package/packages/in-memory-runtime/vitest.config.ts +21 -0
  65. package/packages/irc-core/package.json +29 -0
  66. package/packages/irc-core/src/caps/capabilities.ts +96 -0
  67. package/packages/irc-core/src/caps/index.ts +1 -0
  68. package/packages/irc-core/src/commands/away.ts +82 -0
  69. package/packages/irc-core/src/commands/cap.ts +257 -0
  70. package/packages/irc-core/src/commands/chghost.ts +30 -0
  71. package/packages/irc-core/src/commands/index.ts +40 -0
  72. package/packages/irc-core/src/commands/invite.ts +156 -0
  73. package/packages/irc-core/src/commands/isupport.ts +133 -0
  74. package/packages/irc-core/src/commands/join.ts +309 -0
  75. package/packages/irc-core/src/commands/kick.ts +162 -0
  76. package/packages/irc-core/src/commands/list.ts +126 -0
  77. package/packages/irc-core/src/commands/mode.ts +655 -0
  78. package/packages/irc-core/src/commands/motd.ts +146 -0
  79. package/packages/irc-core/src/commands/names.ts +164 -0
  80. package/packages/irc-core/src/commands/part.ts +118 -0
  81. package/packages/irc-core/src/commands/ping.ts +47 -0
  82. package/packages/irc-core/src/commands/privmsg.ts +256 -0
  83. package/packages/irc-core/src/commands/quit.ts +70 -0
  84. package/packages/irc-core/src/commands/registration.ts +251 -0
  85. package/packages/irc-core/src/commands/topic.ts +171 -0
  86. package/packages/irc-core/src/commands/who.ts +169 -0
  87. package/packages/irc-core/src/commands/whois.ts +165 -0
  88. package/packages/irc-core/src/effects.ts +184 -0
  89. package/packages/irc-core/src/index.ts +20 -0
  90. package/packages/irc-core/src/ports.ts +153 -0
  91. package/packages/irc-core/src/protocol/batch.ts +85 -0
  92. package/packages/irc-core/src/protocol/index.ts +18 -0
  93. package/packages/irc-core/src/protocol/messages.ts +36 -0
  94. package/packages/irc-core/src/protocol/numerics.ts +155 -0
  95. package/packages/irc-core/src/protocol/outbound.ts +145 -0
  96. package/packages/irc-core/src/protocol/parser.ts +175 -0
  97. package/packages/irc-core/src/protocol/serializer.ts +71 -0
  98. package/packages/irc-core/src/state/channel.ts +293 -0
  99. package/packages/irc-core/src/state/connection.ts +153 -0
  100. package/packages/irc-core/src/state/index.ts +25 -0
  101. package/packages/irc-core/src/state/registry.ts +22 -0
  102. package/packages/irc-core/src/types.ts +83 -0
  103. package/packages/irc-core/tests/batch.test.ts +79 -0
  104. package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
  105. package/packages/irc-core/tests/commands/away.test.ts +233 -0
  106. package/packages/irc-core/tests/commands/batch.test.ts +178 -0
  107. package/packages/irc-core/tests/commands/cap.test.ts +499 -0
  108. package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
  109. package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
  110. package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
  111. package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
  112. package/packages/irc-core/tests/commands/invite.test.ts +321 -0
  113. package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
  114. package/packages/irc-core/tests/commands/join.test.ts +687 -0
  115. package/packages/irc-core/tests/commands/kick.test.ts +316 -0
  116. package/packages/irc-core/tests/commands/list.test.ts +452 -0
  117. package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
  118. package/packages/irc-core/tests/commands/motd.test.ts +322 -0
  119. package/packages/irc-core/tests/commands/names.test.ts +342 -0
  120. package/packages/irc-core/tests/commands/part.test.ts +265 -0
  121. package/packages/irc-core/tests/commands/ping.test.ts +144 -0
  122. package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
  123. package/packages/irc-core/tests/commands/quit.test.ts +220 -0
  124. package/packages/irc-core/tests/commands/registration.test.ts +599 -0
  125. package/packages/irc-core/tests/commands/topic.test.ts +337 -0
  126. package/packages/irc-core/tests/commands/who.test.ts +441 -0
  127. package/packages/irc-core/tests/commands/whois.test.ts +422 -0
  128. package/packages/irc-core/tests/effects.test.ts +147 -0
  129. package/packages/irc-core/tests/message-tags.test.ts +182 -0
  130. package/packages/irc-core/tests/numerics.test.ts +98 -0
  131. package/packages/irc-core/tests/outbound.test.ts +232 -0
  132. package/packages/irc-core/tests/parser.test.ts +162 -0
  133. package/packages/irc-core/tests/ports.test.ts +101 -0
  134. package/packages/irc-core/tests/serializer.test.ts +164 -0
  135. package/packages/irc-core/tests/state/channel.test.ts +351 -0
  136. package/packages/irc-core/tests/state/connection.test.ts +122 -0
  137. package/packages/irc-core/tests/state/registry.test.ts +20 -0
  138. package/packages/irc-core/tests/types.test.ts +127 -0
  139. package/packages/irc-core/tsconfig.build.json +12 -0
  140. package/packages/irc-core/tsconfig.test.json +10 -0
  141. package/packages/irc-core/vitest.config.ts +21 -0
  142. package/packages/irc-server/package.json +28 -0
  143. package/packages/irc-server/src/actor.ts +449 -0
  144. package/packages/irc-server/src/dispatch.ts +120 -0
  145. package/packages/irc-server/src/index.ts +11 -0
  146. package/packages/irc-server/src/runtime.ts +61 -0
  147. package/packages/irc-server/tests/actor.test.ts +816 -0
  148. package/packages/irc-server/tests/dispatch.test.ts +512 -0
  149. package/packages/irc-server/tests/runtime.test.ts +52 -0
  150. package/packages/irc-server/tsconfig.build.json +13 -0
  151. package/packages/irc-server/tsconfig.test.json +11 -0
  152. package/packages/irc-server/vitest.config.ts +21 -0
  153. package/pnpm-workspace.yaml +4 -0
  154. package/tools/tcp-ws-forwarder/package.json +32 -0
  155. package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
  156. package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
  157. package/tools/tcp-ws-forwarder/src/main.ts +114 -0
  158. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
  159. package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
  160. package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
  161. package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
  162. package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
  163. package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
  164. package/tsconfig.base.json +25 -0
  165. 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
+ }