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,82 @@
1
+ /**
2
+ * Pure reducer for the IRC `AWAY` command + IRCv3 `away-notify` capability.
3
+ *
4
+ * PLAN §2.1 location-of-authority: AWAY runs in the Connection entity. The
5
+ * reducer's `state` argument is the connection's authoritative
6
+ * {@link ConnectionState}; the away reason is stored as `state.away`.
7
+ *
8
+ * Wire format:
9
+ * - `AWAY [:<reason>]` — sets away when `<reason>` is non-empty; clears
10
+ * it when absent or empty.
11
+ * - `:server 305 <nick> :You are no longer marked as being away` (RPL_UNAWAY)
12
+ * - `:server 306 <nick> :You have been marked as being away` (RPL_NOWAWAY)
13
+ *
14
+ * IRCv3 away-notify: connections with the `away-notify` cap negotiated who
15
+ * share a channel with the away user receive an `AWAY` message broadcast:
16
+ * - `:<hostmask> AWAY :<reason>` on set
17
+ * - `:<hostmask> AWAY` on unset (no trailing param)
18
+ *
19
+ * The broadcast is cap-only (non-cap peers receive nothing) so legacy
20
+ * clients are unaffected.
21
+ */
22
+
23
+ import { Effect } from '../effects.js';
24
+ import type { Effect as EffectType, RawLine } from '../effects.js';
25
+ import { Numerics } from '../protocol/numerics.js';
26
+ import type { ChanName } from '../state/channel.js';
27
+ import { hostmaskOf } from '../state/connection.js';
28
+ import type { ConnectionState } from '../state/connection.js';
29
+ import type { Reducer } from '../types.js';
30
+
31
+ /**
32
+ * Handles `AWAY [:<reason>]`.
33
+ *
34
+ * Validation order: missing/empty param → unset (305); otherwise set (306).
35
+ * away-notify cap peers in shared channels receive a cap-only broadcast.
36
+ */
37
+ export const awayReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
38
+ state.lastSeen = ctx.clock.now();
39
+
40
+ const effects: EffectType[] = [];
41
+
42
+ const reason = msg.params[0];
43
+ const isSettingAway = reason !== undefined && reason.length > 0;
44
+
45
+ const nick = state.nick ?? '*';
46
+
47
+ if (isSettingAway) {
48
+ state.away = reason;
49
+ effects.push(
50
+ Effect.send(ctx.connId, [
51
+ {
52
+ text: `:${ctx.serverName} ${Numerics.RPL_NOWAWAY} ${nick} :You have been marked as being away`,
53
+ },
54
+ ]),
55
+ );
56
+ } else {
57
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
58
+ delete state.away;
59
+ effects.push(
60
+ Effect.send(ctx.connId, [
61
+ {
62
+ text: `:${ctx.serverName} ${Numerics.RPL_UNAWAY} ${nick} :You are no longer marked as being away`,
63
+ },
64
+ ]),
65
+ );
66
+ }
67
+
68
+ // away-notify fanout: emit a cap-only broadcast to each shared channel.
69
+ // The broadcast has no `lines` for non-cap peers (cap-only mode); cap
70
+ // peers receive the AWAY line.
71
+ if (state.joinedChannels.size > 0) {
72
+ const hostmask = hostmaskOf(state) ?? state.nick ?? '?';
73
+ const line: RawLine = isSettingAway
74
+ ? { text: `:${hostmask} AWAY :${reason}` }
75
+ : { text: `:${hostmask} AWAY` };
76
+ for (const chan of state.joinedChannels) {
77
+ effects.push(Effect.broadcast(chan as ChanName, [line], undefined, 'away-notify'));
78
+ }
79
+ }
80
+
81
+ return { state, effects };
82
+ };
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Pure reducer for the IRCv3 `CAP` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: `CAP` runs in the Connection entity.
5
+ * The reducer consults the capability table in `caps/capabilities.ts` and
6
+ * maintains two pieces of state on {@link ConnectionState}:
7
+ *
8
+ * - `caps` — the set of negotiated cap names (sticky, consulted by every
9
+ * reducer that needs to know whether a peer opted into an IRCv3
10
+ * extension).
11
+ * - `capNegotiating` — true between `CAP LS`/`CAP REQ` and `CAP END`.
12
+ * While true the registration welcome block is deferred (IRCv3 spec
13
+ * requirement) so the client can complete capability negotiation
14
+ * before being considered registered.
15
+ *
16
+ * Wire format (IRCv3 Capability Negotiation):
17
+ *
18
+ * Client -> Server:
19
+ * `CAP LS [<version>]`
20
+ * `CAP REQ :<cap>{ <cap>}` (each cap may be `-`-prefixed to disable)
21
+ * `CAP END`
22
+ * `CAP LIST`
23
+ *
24
+ * Server -> Client:
25
+ * `:<server> CAP <nick> LS [*] :<caps>` (multiline with the `*` marker)
26
+ * `:<server> CAP <nick> ACK :<caps>`
27
+ * `:<server> CAP <nick> NAK :<caps>`
28
+ * `:<server> CAP <nick> LIST [*] :<caps>`
29
+ * `:<server> 410 <nick> <cmd> :Invalid CAP command`
30
+ *
31
+ * `CAP NEW` and `CAP DEL` are server-to-client only in IRCv3.2; when sent
32
+ * by a client they are rejected with `410 ERR_INVALIDCAPCMD`.
33
+ */
34
+
35
+ import {
36
+ SUPPORTED_CAPABILITIES,
37
+ capToLsString,
38
+ isSupportedCap,
39
+ splitCapsForWire,
40
+ } from '../caps/capabilities.js';
41
+ import { Effect } from '../effects.js';
42
+ import type { RawLine } from '../effects.js';
43
+ import { Numerics } from '../protocol/numerics.js';
44
+ import type { ConnectionState } from '../state/connection.js';
45
+ import type { Ctx, Reducer, ReducerResult } from '../types.js';
46
+ import { emitWelcomeIfReady } from './registration.js';
47
+
48
+ /** RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing CR-LF. */
49
+ const MAX_LINE_BYTES = 512;
50
+
51
+ /** Length of the trailing CR-LF that the wire layer appends to each line. */
52
+ const TRAILING_CRLF_LEN = 2;
53
+
54
+ /**
55
+ * Handles `CAP <subcommand> [<args>]`. Dispatches to a per-subcommand
56
+ * handler; unknown subcommands (including server-to-client-only ones
57
+ * like `NEW`/`DEL`) fall through to `410 ERR_INVALIDCAPCMD`.
58
+ */
59
+ export const capReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
60
+ state.lastSeen = ctx.clock.now();
61
+
62
+ const rawSub = msg.params[0];
63
+ const sub = (rawSub ?? '').toUpperCase();
64
+
65
+ switch (sub) {
66
+ case 'LS':
67
+ return handleLs(state, ctx);
68
+ case 'LIST':
69
+ return handleList(state, ctx);
70
+ case 'REQ':
71
+ return handleReq(state, msg, ctx);
72
+ case 'END':
73
+ return handleEnd(state, ctx);
74
+ default:
75
+ return { state, effects: [Effect.send(ctx.connId, [invalidCapCmd(ctx, rawSub)])] };
76
+ }
77
+ };
78
+
79
+ /**
80
+ * `CAP LS` — advertise the supported capability set.
81
+ *
82
+ * Marks the connection as `capNegotiating` so the welcome block is
83
+ * deferred until `CAP END`. Splits the payload across multiple lines
84
+ * when it would exceed the 512-byte wire limit, using the `*` multiline
85
+ * marker per the IRCv3 spec.
86
+ */
87
+ function handleLs(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionState> {
88
+ state.capNegotiating = true;
89
+
90
+ const target = state.nick ?? '*';
91
+ const tokens = SUPPORTED_CAPABILITIES.map(capToLsString);
92
+ const maxLen = maxCapsContentLen(ctx, target);
93
+ const chunks = splitCapsForWire(tokens, maxLen);
94
+
95
+ const lines: RawLine[] = chunks.map((chunk, i) => {
96
+ const isLast = i === chunks.length - 1;
97
+ if (isLast) {
98
+ return { text: `:${ctx.serverName} CAP ${target} LS :${chunk}` };
99
+ }
100
+ return { text: `:${ctx.serverName} CAP ${target} LS * :${chunk}` };
101
+ });
102
+
103
+ return { state, effects: [Effect.send(ctx.connId, lines)] };
104
+ }
105
+
106
+ /**
107
+ * `CAP LIST` — report the caps currently negotiated on this connection.
108
+ *
109
+ * Read-only: does not mutate `caps` and does not start CAP negotiation.
110
+ */
111
+ function handleList(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionState> {
112
+ const target = state.nick ?? '*';
113
+ const tokens = [...state.caps].sort();
114
+ const maxLen = maxCapsContentLen(ctx, target);
115
+ const chunks = splitCapsForWire(tokens, maxLen);
116
+
117
+ const lines: RawLine[] = chunks.map((chunk, i) => {
118
+ const isLast = i === chunks.length - 1;
119
+ if (isLast) {
120
+ return { text: `:${ctx.serverName} CAP ${target} LIST :${chunk}` };
121
+ }
122
+ return { text: `:${ctx.serverName} CAP ${target} LIST * :${chunk}` };
123
+ });
124
+
125
+ return { state, effects: [Effect.send(ctx.connId, lines)] };
126
+ }
127
+
128
+ /**
129
+ * `CAP REQ :<caps>` — atomically enable or disable caps.
130
+ *
131
+ * Per IRCv3 the request is all-or-nothing: if every requested cap is
132
+ * supported (and the disable-marker form is well-formed), the server
133
+ * ACKs the request verbatim and updates `state.caps`. If any cap is
134
+ * unknown the entire request is NAKed and `state.caps` is untouched.
135
+ *
136
+ * Marks the connection as `capNegotiating` so the welcome stays deferred.
137
+ */
138
+ function handleReq(
139
+ state: ConnectionState,
140
+ msg: { params: string[] },
141
+ ctx: Ctx,
142
+ ): ReducerResult<ConnectionState> {
143
+ const rawCaps = msg.params[1];
144
+ if (rawCaps === undefined || rawCaps === '') {
145
+ return {
146
+ state,
147
+ effects: [
148
+ Effect.send(ctx.connId, [
149
+ numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'CAP'),
150
+ ]),
151
+ ],
152
+ };
153
+ }
154
+
155
+ state.capNegotiating = true;
156
+
157
+ const requested = rawCaps.split(' ').filter((c) => c.length > 0);
158
+ const target = state.nick ?? '*';
159
+
160
+ const allSupported = requested.every((tok) => {
161
+ const name = tok.startsWith('-') ? tok.slice(1) : tok;
162
+ return isSupportedCap(name);
163
+ });
164
+
165
+ if (!allSupported) {
166
+ return {
167
+ state,
168
+ effects: [
169
+ Effect.send(ctx.connId, [
170
+ L(`:${ctx.serverName} CAP ${target} NAK :${requested.join(' ')}`),
171
+ ]),
172
+ ],
173
+ };
174
+ }
175
+
176
+ for (const tok of requested) {
177
+ if (tok.startsWith('-')) {
178
+ state.caps.delete(tok.slice(1));
179
+ } else {
180
+ state.caps.add(tok);
181
+ }
182
+ }
183
+
184
+ return {
185
+ state,
186
+ effects: [
187
+ Effect.send(ctx.connId, [L(`:${ctx.serverName} CAP ${target} ACK :${requested.join(' ')}`)]),
188
+ ],
189
+ };
190
+ }
191
+
192
+ /**
193
+ * `CAP END` — client signals end of capability negotiation.
194
+ *
195
+ * Clears `capNegotiating` and attempts to finalize registration. If
196
+ * `NICK` and `USER` have already been received, the welcome block is
197
+ * emitted here; otherwise the connection stays pre-registered until the
198
+ * basic handshake catches up.
199
+ */
200
+ function handleEnd(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionState> {
201
+ state.capNegotiating = false;
202
+ const welcome = emitWelcomeIfReady(state, ctx);
203
+ return { state, effects: welcome !== null ? [welcome] : [] };
204
+ }
205
+
206
+ /**
207
+ * Builds the `410 ERR_INVALIDCAPCMD` reply. `rawSub` is echoed verbatim
208
+ * (may be undefined when the client sent a bare `CAP`).
209
+ */
210
+ function invalidCapCmd(ctx: Ctx, rawSub: string | undefined): RawLine {
211
+ const nick = ctx.connection.nick ?? '*';
212
+ const codeStr = Numerics.ERR_INVALIDCAPCMD.toString().padStart(3, '0');
213
+ if (rawSub !== undefined && rawSub !== '') {
214
+ return { text: `:${ctx.serverName} ${codeStr} ${nick} ${rawSub} :Invalid CAP command` };
215
+ }
216
+ return { text: `:${ctx.serverName} ${codeStr} ${nick} :Invalid CAP command` };
217
+ }
218
+
219
+ /** Formats a numeric error line addressed to the connection's nick (or `*`). */
220
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
221
+ const nick = ctx.connection.nick ?? '*';
222
+ const codeStr = code.toString().padStart(3, '0');
223
+ return { text: [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`].join(' ') };
224
+ }
225
+
226
+ /** Constructs a {@link RawLine} from raw text (terse alias for the hot path). */
227
+ function L(text: string): RawLine {
228
+ return { text };
229
+ }
230
+
231
+ /**
232
+ * Returns the maximum content-byte length available for the cap list in a
233
+ * single `CAP ... <SUB> [*] :<caps>` line.
234
+ *
235
+ * Wire layout:
236
+ * `:<server> CAP <target> <SUB> [*] :<caps>\r\n`
237
+ *
238
+ * We budget for the multiline form (`*` marker present) on every line so
239
+ * the final greedy-packing decision never has to be re-evaluated for the
240
+ * non-final vs final distinction.
241
+ */
242
+ function maxCapsContentLen(ctx: Ctx, target: string): number {
243
+ // 1 (':') + server + ' CAP ' + target + ' XXX ' (subcommand + spaces) + '* ' + ':' + CRLF
244
+ // ` CAP ` = 5, ` LS ` / ` LIST ` = 4 (` LS `) up to 6 (` LIST `); use the larger.
245
+ const subOverhead = 6;
246
+ const multilineMarker = 2;
247
+ const overhead =
248
+ TRAILING_CRLF_LEN +
249
+ 1 +
250
+ ctx.serverName.length +
251
+ 5 +
252
+ target.length +
253
+ subOverhead +
254
+ multilineMarker +
255
+ 1;
256
+ return Math.max(0, MAX_LINE_BYTES - overhead);
257
+ }
@@ -0,0 +1,30 @@
1
+ import { Effect } from '../effects.js';
2
+ import type { Effect as EffectType, RawLine } from '../effects.js';
3
+ import type { ChanName } from '../state/channel.js';
4
+ import type { ConnectionState } from '../state/connection.js';
5
+
6
+ export interface ChgHostInput {
7
+ conn: ConnectionState;
8
+ oldUser: string;
9
+ oldHost: string;
10
+ newUser: string;
11
+ newHost: string;
12
+ }
13
+
14
+ export function emitChghost(input: ChgHostInput): EffectType[] {
15
+ const { conn, oldUser, oldHost, newUser, newHost } = input;
16
+
17
+ if (conn.registration !== 'registered') return [];
18
+ if (oldUser === newUser && oldHost === newHost) return [];
19
+
20
+ const nick = conn.nick ?? '?';
21
+ const line: RawLine = {
22
+ text: `:${nick}!${oldUser}@${oldHost} CHGHOST ${newUser} :${newHost}`,
23
+ };
24
+
25
+ const effects: EffectType[] = [];
26
+ for (const chan of conn.joinedChannels) {
27
+ effects.push(Effect.broadcast(chan as ChanName, [], undefined, 'chghost', [line]));
28
+ }
29
+ return effects;
30
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Command reducers (pure). One file per command family.
3
+ *
4
+ * Each reducer follows the signature
5
+ * `(state, msg, ctx) => { state, effects }`
6
+ * from {@link Reducer}. Side effects are values, not performed here.
7
+ */
8
+ export { pingReducer, pongReducer } from './ping.js';
9
+ export {
10
+ buildWelcomeLines,
11
+ emitWelcomeIfReady,
12
+ isValidNick,
13
+ nickReducer,
14
+ passReducer,
15
+ userReducer,
16
+ } from './registration.js';
17
+ export { quitReducer } from './quit.js';
18
+ export { handleJoinZero, isValidChannelName, joinReducer } from './join.js';
19
+ export { partReducer } from './part.js';
20
+ export { motdReducer } from './motd.js';
21
+ export {
22
+ isChannelTarget,
23
+ noticeChannelReducer,
24
+ noticeUserReducer,
25
+ privmsgChannelReducer,
26
+ privmsgUserReducer,
27
+ } from './privmsg.js';
28
+ export { kickReducer } from './kick.js';
29
+ export { topicReducer } from './topic.js';
30
+ export { namesReducer } from './names.js';
31
+ export { listReducer } from './list.js';
32
+ export { channelModeReducer, userModeReducer } from './mode.js';
33
+ export { inviteReducer } from './invite.js';
34
+ export { whoReducer } from './who.js';
35
+ export { whoisReducer } from './whois.js';
36
+ export { generateIsupport } from './isupport.js';
37
+ export { capReducer } from './cap.js';
38
+ export { emitChghost } from './chghost.js';
39
+ export type { ChgHostInput } from './chghost.js';
40
+ export { awayReducer } from './away.js';
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Pure reducer for the IRC `INVITE` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: INVITE runs in the Channel entity. The
5
+ * reducer's `state` argument is the channel's authoritative state; the
6
+ * inviter's identity is read from `ctx.connection`. The pending-invite
7
+ * record (which lets the invitee bypass `+i` on a later JOIN) lives on
8
+ * the channel via {@link ChannelState.pendingInvites}.
9
+ *
10
+ * The actual routing of the INVITE notice to the invitee's connection is
11
+ * expressed as a `SendToNick` effect — the runtime resolves the nick.
12
+ *
13
+ * Wire format (RFC 2812 §3.2.7):
14
+ * - `:<server> 341 <inviter> <chan> <invitee>` — RPL_INVITING
15
+ * - `:<inviter-hostmask> INVITE <invitee> <chan>` — sent to the invitee
16
+ * - `443 ERR_USERONCHANNEL <inviter> <invitee> <chan> :is already on channel`
17
+ *
18
+ * +i (invite-only) requires the inviter to be a channel operator; non-+i
19
+ * channels allow any member to invite. The invitee's nick is resolved
20
+ * case-insensitively against the roster (443 if already a member).
21
+ */
22
+
23
+ import { Effect } from '../effects.js';
24
+ import type { Effect as EffectType, RawLine } from '../effects.js';
25
+ import { Numerics } from '../protocol/numerics.js';
26
+ import type { ChannelState } from '../state/channel.js';
27
+ import { hostmaskOf } from '../state/connection.js';
28
+ import type { ConnectionState } from '../state/connection.js';
29
+ import type { Ctx, Reducer } from '../types.js';
30
+
31
+ const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
32
+
33
+ function isValidChannelName(name: string, maxLen: number): boolean {
34
+ if (name.length === 0 || name.length > maxLen) return false;
35
+ return CHANNEL_NAME_RE.test(name);
36
+ }
37
+
38
+ /** Returns the caller's hostmask, falling back to nick then `?`. */
39
+ function callerHostmask(conn: ConnectionState): string {
40
+ return hostmaskOf(conn) ?? conn.nick ?? '?';
41
+ }
42
+
43
+ /** Formats a numeric error/reply line addressed to the connection's nick. */
44
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
45
+ const nick = ctx.connection.nick ?? '*';
46
+ const codeStr = code.toString().padStart(3, '0');
47
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
48
+ if (middle !== undefined) parts.push(middle);
49
+ parts.push(`:${trailing}`);
50
+ return { text: parts.join(' ') };
51
+ }
52
+
53
+ function lowerNick(nick: string): string {
54
+ return nick.toLowerCase();
55
+ }
56
+
57
+ /** Finds a roster entry's connId by nick (case-insensitive). */
58
+ function findConnIdByNick(state: ChannelState, nick: string): string | null {
59
+ const target = lowerNick(nick);
60
+ for (const entry of state.members.values()) {
61
+ if (lowerNick(entry.nick) === target) return entry.conn;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Handles `INVITE <nick> <chan>`.
68
+ *
69
+ * Validation order: missing-params → invalid-name → not-on-channel →
70
+ * +i-requires-op → invitee-already-on-channel → success.
71
+ */
72
+ export const inviteReducer: Reducer<ChannelState> = (state, msg, ctx) => {
73
+ ctx.connection.lastSeen = ctx.clock.now();
74
+
75
+ const effects: EffectType[] = [];
76
+
77
+ const targetNick = msg.params[0];
78
+ const rawName = msg.params[1];
79
+
80
+ if (targetNick === undefined || rawName === undefined) {
81
+ effects.push(
82
+ Effect.send(ctx.connId, [
83
+ numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'INVITE'),
84
+ ]),
85
+ );
86
+ return { state, effects };
87
+ }
88
+
89
+ if (!isValidChannelName(rawName, ctx.serverConfig.channelLen)) {
90
+ effects.push(
91
+ Effect.send(ctx.connId, [
92
+ numericErr(ctx, Numerics.ERR_NOSUCHCHANNEL, 'No such channel', rawName),
93
+ ]),
94
+ );
95
+ return { state, effects };
96
+ }
97
+
98
+ // Inviter must be on the channel.
99
+ const callerEntry = state.members.get(ctx.connId);
100
+ if (callerEntry === undefined) {
101
+ effects.push(
102
+ Effect.send(ctx.connId, [
103
+ numericErr(ctx, Numerics.ERR_NOTONCHANNEL, "You're not on that channel", state.name),
104
+ ]),
105
+ );
106
+ return { state, effects };
107
+ }
108
+
109
+ // +i (invite-only) requires op to invite.
110
+ if (state.modes.inviteOnly && !callerEntry.op) {
111
+ effects.push(
112
+ Effect.send(ctx.connId, [
113
+ numericErr(ctx, Numerics.ERR_CHANOPRIVSNEEDED, "You're not channel operator", state.name),
114
+ ]),
115
+ );
116
+ return { state, effects };
117
+ }
118
+
119
+ // Invitee already on the channel?
120
+ const targetConnId = findConnIdByNick(state, targetNick);
121
+ if (targetConnId !== null) {
122
+ effects.push(
123
+ Effect.send(ctx.connId, [
124
+ numericErr(
125
+ ctx,
126
+ Numerics.ERR_USERONCHANNEL,
127
+ 'is already on channel',
128
+ `${targetNick} ${state.name}`,
129
+ ),
130
+ ]),
131
+ );
132
+ return { state, effects };
133
+ }
134
+
135
+ // Success: record the pending invite (by lowercased nick) so a later
136
+ // JOIN bypasses +i, emit 341, and route the INVITE notice to the invitee.
137
+ state.pendingInvites.add(targetNick.toLowerCase());
138
+
139
+ const inviterNick = ctx.connection.nick ?? '*';
140
+ effects.push(
141
+ Effect.send(ctx.connId, [
142
+ { text: `:${ctx.serverName} 341 ${inviterNick} ${state.name} ${targetNick}` },
143
+ ]),
144
+ );
145
+
146
+ const hostmask = callerHostmask(ctx.connection);
147
+ const inviteLine: RawLine = { text: `:${hostmask} INVITE ${targetNick} ${state.name}` };
148
+ effects.push(Effect.sendToNick(targetNick, ctx.connId, [inviteLine]));
149
+
150
+ // IRCv3 invite-notify: channel members with the `invite-notify` cap
151
+ // receive a copy of the INVITE. Cap-only broadcast so legacy members see
152
+ // nothing; the invitee is reached via SendToNick above (exactly once).
153
+ effects.push(Effect.broadcast(state.name, [inviteLine], undefined, 'invite-notify'));
154
+
155
+ return { state, effects };
156
+ };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Pure generator for the IRC `005 RPL_ISUPPORT` reply.
3
+ *
4
+ * PLAN §3 / RFC 2812 §3.1.1 / <https://modern.ircdocs.horse/#rplisupport-005>:
5
+ *
6
+ * `<prefix> 005 <nick> <token>{ <token>} :are supported by this server`
7
+ *
8
+ * The full token set rarely fits in a single 510-byte wire line, so daemons
9
+ * split it across multiple `005` lines; only the final line carries the
10
+ * trailing `:are supported by this server` parameter. This module computes
11
+ * the token list from `serverConfig`, then packs tokens into batches that
12
+ * keep every emitted line within the 510-byte ceiling.
13
+ *
14
+ * Tokens are derived from config, not hard-coded, so adapters can tune limits
15
+ * per-deployment (PLAN §10 capacity planning).
16
+ */
17
+
18
+ import type { RawLine } from '../effects.js';
19
+ import type { ServerConfig } from '../types.js';
20
+
21
+ /** Max bytes per IRC wire line, including trailing `\r\n`. */
22
+ const LINE_MAX_BYTES = 510;
23
+
24
+ /** Canonical channel mode classes, in CHANMODES order. */
25
+ const CHANMODES = {
26
+ /** Class A — list modes (always take a parameter when set/unset). */
27
+ list: 'b',
28
+ /** Class B — parameter modes (param when set, no param when unset). */
29
+ paramSet: 'k',
30
+ /** Class C — parameter modes (param when set AND unset). */
31
+ paramAlways: 'l',
32
+ /** Class D — boolean modes (no parameter). */
33
+ boolean: 'imnpst',
34
+ } as const;
35
+
36
+ /** Channel-user prefix sigils, in PREFIX order (highest rank first). */
37
+ const PREFIX = '(ov)@+';
38
+
39
+ /** Channel name sigils this server recognises. */
40
+ const CHANTYPES = '#';
41
+
42
+ /** Case-mapping algorithm — v1 uses RFC 1459 (matches `lower.ts`). */
43
+ const CASEMAPPING = 'rfc1459';
44
+
45
+ /** Default max mode changes per single MODE command (PLAN §3). */
46
+ const MODES_PER_COMMAND = 4;
47
+
48
+ /** Trailing parameter appended to the final 005 line. */
49
+ const TRAILER = 'are supported by this server';
50
+
51
+ /**
52
+ * Builds the token list for one client based on its capabilities and the
53
+ * server's configuration.
54
+ *
55
+ * Order matters: RFC-style daemons emit tokens in a stable order so clients
56
+ * can diff between servers. Tokens are space-separated, never empty.
57
+ */
58
+ function buildTokens(cfg: ServerConfig): string[] {
59
+ return [
60
+ `CHANTYPES=${CHANTYPES}`,
61
+ `CHANMODES=${CHANMODES.list},${CHANMODES.paramSet},${CHANMODES.paramAlways},${CHANMODES.boolean}`,
62
+ `PREFIX=${PREFIX}`,
63
+ `MODES=${MODES_PER_COMMAND}`,
64
+ `NICKLEN=${cfg.nickLen}`,
65
+ `CHANNELLEN=${cfg.channelLen}`,
66
+ `TOPICLEN=${cfg.topicLen}`,
67
+ `CASEMAPPING=${CASEMAPPING}`,
68
+ `NETWORK=${cfg.networkName}`,
69
+ `MAXCHANNELS=${cfg.maxChannelsPerUser}`,
70
+ `MAXTARGETS=${cfg.maxTargetsPerCommand}`,
71
+ // SAFELIST is a boolean ISUPPORT token advertising that LIST will not
72
+ // get the connection killed (the matching IRCv3 `safelist` cap is the
73
+ // per-connection opt-in). LIST is always non-destructive here so the
74
+ // token is unconditional.
75
+ 'SAFELIST',
76
+ ];
77
+ }
78
+
79
+ /**
80
+ * Greedy packer: accumulates tokens into a single space-joined prefix until
81
+ * adding the next token would exceed the 510-byte ceiling, then starts a new
82
+ * batch. The caller appends the trailer separately.
83
+ *
84
+ * Each batch returns just the joined token string (no `005`, no trailer).
85
+ */
86
+ function packTokens(tokens: ReadonlyArray<string>, baseLen: number): string[] {
87
+ const batches: string[] = [];
88
+ let current = '';
89
+ for (const tok of tokens) {
90
+ const candidate = current === '' ? tok : `${current} ${tok}`;
91
+ // +1 for the trailing space before the `:`-prefixed trailer.
92
+ if (baseLen + candidate.length + 1 > LINE_MAX_BYTES && current !== '') {
93
+ batches.push(current);
94
+ current = tok;
95
+ } else {
96
+ current = candidate;
97
+ }
98
+ }
99
+ if (current !== '') batches.push(current);
100
+ return batches;
101
+ }
102
+
103
+ /**
104
+ * Generates the `005 RPL_ISUPPORT` reply for a connection.
105
+ *
106
+ * @param cfg Server configuration (size limits, network name).
107
+ * @param _connCaps IRCv3 capabilities negotiated by the connection
108
+ * (reserved for `BOT=` and similar v3 tokens; currently
109
+ * unused — kept in the signature per the ticket spec).
110
+ * @param nick Optional nick for the reply target; defaults to `*`.
111
+ * @returns One or more {@link RawLine}s, each within the 510-byte ceiling.
112
+ * Only the final line carries the `:are supported by this server`
113
+ * trailer.
114
+ */
115
+ export function generateIsupport(
116
+ cfg: ServerConfig,
117
+ _connCaps: ReadonlySet<string>,
118
+ nick = '*',
119
+ ): RawLine[] {
120
+ const tokens = buildTokens(cfg);
121
+ const prefix = `:${cfg.serverName} 005 ${nick} `;
122
+ const baseLen = prefix.length;
123
+
124
+ const batches = packTokens(tokens, baseLen);
125
+
126
+ const lines: RawLine[] = batches.map((batch, idx) => {
127
+ const isLast = idx === batches.length - 1;
128
+ const trailer = isLast ? ` :${TRAILER}` : '';
129
+ return { text: `${prefix}${batch}${trailer}` };
130
+ });
131
+
132
+ return lines;
133
+ }