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,449 @@
1
+ /**
2
+ * Per-connection actor: bytes → framed messages → reducer → dispatch.
3
+ *
4
+ * PLAN §2.1 + §9 — the actor owns one connection's lifecycle. It receives
5
+ * WebSocket text frames (one IRC message per frame, or N `\r\n`-joined
6
+ * messages per frame), splits them into lines, parses each line, routes by
7
+ * command to the right reducer per the location-of-authority table, and
8
+ * `dispatch`es the resulting `Effect[]` against the bound {@link IrcRuntime}.
9
+ *
10
+ * Invariants (acceptance criteria):
11
+ * - One message per frame → one reducer invocation.
12
+ * - Joined frame with N `\r\n` → N invocations, in order.
13
+ * - Parser failures produce `421 ERR_UNKNOWNCOMMAND` and processing
14
+ * continues (the actor never crashes on a single bad line).
15
+ *
16
+ * Location-of-authority:
17
+ * - Connection commands (PING/NICK/USER/…/QUIT) run against the actor's
18
+ * own {@link ConnectionState}.
19
+ * - Channel commands (JOIN/PART/…) run against the authoritative
20
+ * {@link ChannelState} the runtime exposes via {@link ActorChannelAccess}.
21
+ * - Target-dependent commands (PRIVMSG/NOTICE/MODE) are routed by the
22
+ * first param's shape (`#`/`&` prefix → channel).
23
+ *
24
+ * The actor is transport-agnostic by design: it processes text frames. The
25
+ * CF / AWS adapters wrap it with platform-specific socket glue.
26
+ */
27
+
28
+ import {
29
+ type ChanName,
30
+ type ChannelState,
31
+ type Clock,
32
+ type ConnectionState,
33
+ Effect,
34
+ type Effect as EffectType,
35
+ EmptyMotdProvider,
36
+ type IdFactory,
37
+ type IrcMessage,
38
+ type MotdProvider,
39
+ Numerics,
40
+ type RawLine,
41
+ type ServerConfig,
42
+ buildCtx,
43
+ capReducer,
44
+ channelModeReducer,
45
+ handleJoinZero,
46
+ inviteReducer,
47
+ isChannelTarget,
48
+ joinReducer,
49
+ kickReducer,
50
+ listReducer,
51
+ motdReducer,
52
+ namesReducer,
53
+ nickReducer,
54
+ noticeChannelReducer,
55
+ noticeUserReducer,
56
+ parse,
57
+ partReducer,
58
+ passReducer,
59
+ pingReducer,
60
+ pongReducer,
61
+ privmsgChannelReducer,
62
+ privmsgUserReducer,
63
+ quitReducer,
64
+ topicReducer,
65
+ userModeReducer,
66
+ userReducer,
67
+ whoReducer,
68
+ whoisReducer,
69
+ } from '@serverless-ircd/irc-core';
70
+ import { dispatch } from './dispatch.js';
71
+ import type { IrcRuntime } from './runtime.js';
72
+
73
+ /**
74
+ * Channel-state access for the actor.
75
+ *
76
+ * For the in-memory runtime this is `runtime.getOrCreateChannel`. CF / AWS
77
+ * adapters will not use this actor — they forward channel-authority
78
+ * commands to a ChannelDO / Lambda — so this interface only needs to cover
79
+ * the local-execution case.
80
+ *
81
+ * `refreshChannel` is optional: in-memory runtimes omit it (their state is
82
+ * already authoritative). Distributed runtimes implement it so the actor
83
+ * can pull the current roster / modes / topic from the channel authority
84
+ * before running a reducer that needs to observe them (NAMES, MODE, KICK,
85
+ * cross-connection JOIN checks, …). The actor awaits `refreshChannel`
86
+ * once per parsed message, before routing.
87
+ */
88
+ export interface ActorChannelAccess {
89
+ getOrCreateChannel(name: ChanName): ChannelState;
90
+ refreshChannel?(name: ChanName): Promise<void>;
91
+ }
92
+
93
+ export interface ConnectionActorOptions {
94
+ state: ConnectionState;
95
+ runtime: IrcRuntime;
96
+ channels: ActorChannelAccess;
97
+ serverConfig: ServerConfig;
98
+ clock: Clock;
99
+ ids: IdFactory;
100
+ motd?: MotdProvider;
101
+ }
102
+
103
+ export class ConnectionActor {
104
+ private readonly state: ConnectionState;
105
+ private readonly runtime: IrcRuntime;
106
+ private readonly channels: ActorChannelAccess;
107
+ private readonly serverConfig: ServerConfig;
108
+ private readonly clock: Clock;
109
+ private readonly ids: IdFactory;
110
+ private readonly motd: MotdProvider;
111
+
112
+ constructor(opts: ConnectionActorOptions) {
113
+ this.state = opts.state;
114
+ this.runtime = opts.runtime;
115
+ this.channels = opts.channels;
116
+ this.serverConfig = opts.serverConfig;
117
+ this.clock = opts.clock;
118
+ this.ids = opts.ids;
119
+ this.motd = opts.motd ?? EmptyMotdProvider;
120
+ }
121
+
122
+ /**
123
+ * Processes one WebSocket text frame. Splits on `\r\n` (tolerating bare
124
+ * `\n`) and dispatches each non-empty line in order. A parse failure on
125
+ * one line emits a `421` and processing continues with the next line.
126
+ */
127
+ async receiveTextFrame(text: string): Promise<void> {
128
+ for (const line of splitFrameLines(text)) {
129
+ if (line.length === 0) continue;
130
+ await this.processLine(line);
131
+ }
132
+ }
133
+
134
+ private async processLine(line: string): Promise<void> {
135
+ let msg: IrcMessage;
136
+ let parseFailed = false;
137
+ try {
138
+ msg = parse(line);
139
+ } catch {
140
+ // `parse` only throws `IrcParseError` (every throw inside the parser
141
+ // constructs one); the untyped catch is the strict-mode narrowing
142
+ // artifact for `unknown`. Every parse failure becomes a `421` so the
143
+ // actor never crashes on a single bad line (acceptance criteria).
144
+ parseFailed = true;
145
+ msg = { tags: {}, command: '', params: [] };
146
+ }
147
+ if (parseFailed) {
148
+ const token = firstToken(line);
149
+ await dispatch([this.unknownCommandEffect(token)], this.runtime);
150
+ return;
151
+ }
152
+ // Pull authoritative channel state for any channel the upcoming reducer
153
+ // will read. In-memory runtimes no-op; distributed runtimes fetch from
154
+ // the channel authority (e.g. CF ChannelDO). Done before `route` so the
155
+ // synchronous reducers observe up-to-date rosters / modes / topics.
156
+ await this.prefetchChannelState(msg);
157
+ const effects = await this.route(msg);
158
+ await dispatch(effects, this.runtime);
159
+ }
160
+
161
+ /**
162
+ * Awaits {@link ActorChannelAccess.refreshChannel} for every channel
163
+ * referenced by `msg`, if the bound channel-access implementation opted
164
+ * in. No-op for in-memory runtimes.
165
+ */
166
+ private async prefetchChannelState(msg: IrcMessage): Promise<void> {
167
+ const refresh = this.channels.refreshChannel;
168
+ if (refresh === undefined) return;
169
+ const targets = channelTargetsForMessage(msg);
170
+ if (targets.length === 0) return;
171
+ await Promise.all(targets.map((name) => refresh.call(this.channels, name)));
172
+ }
173
+
174
+ private async route(msg: IrcMessage): Promise<EffectType[]> {
175
+ const ctx = buildCtx({
176
+ serverConfig: this.serverConfig,
177
+ clock: this.clock,
178
+ ids: this.ids,
179
+ motd: this.motd,
180
+ connection: this.state,
181
+ });
182
+
183
+ switch (msg.command) {
184
+ // -------- Connection-authority commands --------
185
+ case 'PING':
186
+ return pingReducer(this.state, msg, ctx).effects;
187
+ case 'PONG':
188
+ return pongReducer(this.state, msg, ctx).effects;
189
+ case 'NICK':
190
+ return nickReducer(this.state, msg, ctx).effects;
191
+ case 'USER':
192
+ return userReducer(this.state, msg, ctx).effects;
193
+ case 'PASS':
194
+ return passReducer(this.state, msg, ctx).effects;
195
+ case 'QUIT':
196
+ return quitReducer(this.state, msg, ctx).effects;
197
+ case 'MOTD':
198
+ return motdReducer(this.state, msg, ctx).effects;
199
+ case 'CAP':
200
+ return capReducer(this.state, msg, ctx).effects;
201
+
202
+ // -------- Channel-authority commands --------
203
+ case 'JOIN':
204
+ return this.routeJoin(msg, ctx);
205
+ case 'PART':
206
+ return this.routeMultiChannel(
207
+ msg,
208
+ (chan, singleParamMsg) => partReducer(chan, singleParamMsg, ctx).effects,
209
+ );
210
+ case 'TOPIC':
211
+ return this.routeSingleChannel(msg, (chan) => topicReducer(chan, msg, ctx).effects);
212
+ case 'NAMES':
213
+ return this.routeSingleChannel(msg, (chan) => namesReducer(chan, msg, ctx).effects);
214
+ case 'KICK':
215
+ return this.routeSingleChannel(msg, (chan) => kickReducer(chan, msg, ctx).effects);
216
+ case 'INVITE':
217
+ return this.routeSingleChannel(msg, (chan) => inviteReducer(chan, msg, ctx).effects);
218
+ case 'LIST':
219
+ return this.routeList(msg, ctx);
220
+ case 'WHO':
221
+ return this.routeWho(msg, ctx);
222
+ case 'WHOIS':
223
+ return this.routeWhois(msg, ctx);
224
+
225
+ // -------- Target-dependent commands --------
226
+ case 'PRIVMSG':
227
+ return this.routeMessageTarget(msg, privmsgChannelReducer, privmsgUserReducer, ctx);
228
+ case 'NOTICE':
229
+ return this.routeMessageTarget(msg, noticeChannelReducer, noticeUserReducer, ctx);
230
+ case 'MODE':
231
+ return isChannelTarget(msg.params[0] ?? '')
232
+ ? this.routeSingleChannel(msg, (chan) => channelModeReducer(chan, msg, ctx).effects)
233
+ : userModeReducer(this.state, msg, ctx).effects;
234
+
235
+ default:
236
+ return [this.unknownCommandEffect(msg.command)];
237
+ }
238
+ }
239
+
240
+ private async routeList(
241
+ msg: IrcMessage,
242
+ ctx: ReturnType<typeof buildCtx>,
243
+ ): Promise<EffectType[]> {
244
+ const snapshots = await this.runtime.listChannels();
245
+ return listReducer(snapshots, msg, ctx).effects;
246
+ }
247
+
248
+ private async routeWho(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): Promise<EffectType[]> {
249
+ const target = msg.params[0] ?? '';
250
+ if (target.length === 0) {
251
+ return whoReducer(this.channels.getOrCreateChannel(''), new Map(), msg, ctx).effects;
252
+ }
253
+ const chan = this.channels.getOrCreateChannel(target);
254
+ const connections = await this.runtime.getChannelConnections(target);
255
+ return whoReducer(chan, connections, msg, ctx).effects;
256
+ }
257
+
258
+ /**
259
+ * Resolves the WHOIS target via the nick registry, fetches its live
260
+ * {@link ConnectionState} from the runtime, and gathers the
261
+ * {@link ChannelState}s the target belongs to so the reducer can build
262
+ * the `319` channel list. Unknown / no-arg cases pass `undefined` to
263
+ * the reducer, which emits `401`+`318` (with a nick) or just `318`.
264
+ */
265
+ private async routeWhois(
266
+ msg: IrcMessage,
267
+ ctx: ReturnType<typeof buildCtx>,
268
+ ): Promise<EffectType[]> {
269
+ const targetNick = msg.params[0] ?? '';
270
+ let target: ConnectionState | undefined;
271
+ if (targetNick.length > 0) {
272
+ const connId = await this.runtime.lookupNick(targetNick);
273
+ if (connId !== null) {
274
+ target = (await this.runtime.getConnection(connId)) ?? undefined;
275
+ }
276
+ }
277
+ const channels =
278
+ target === undefined
279
+ ? []
280
+ : Array.from(target.joinedChannels, (name) => this.channels.getOrCreateChannel(name));
281
+ return whoisReducer(target, channels, msg, ctx).effects;
282
+ }
283
+
284
+ private routeJoin(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): EffectType[] {
285
+ if (msg.params[0] === '0') {
286
+ return handleJoinZero(ctx);
287
+ }
288
+ const effects: EffectType[] = [];
289
+ const names = splitCsv(msg.params[0] ?? '');
290
+ const keys = splitCsv(msg.params[1] ?? '');
291
+ if (names.length === 0) {
292
+ // No channel argument: defer to the reducer so it emits the proper
293
+ // 461 ERR_NEEDMOREPARAMS rather than the actor silently doing nothing.
294
+ const chan = this.channels.getOrCreateChannel('');
295
+ return joinReducer(chan, msg, ctx).effects;
296
+ }
297
+ for (const [i, name] of names.entries()) {
298
+ const chan = this.channels.getOrCreateChannel(name);
299
+ const key = keys[i];
300
+ const singleParamMsg: IrcMessage =
301
+ key === undefined ? { ...msg, params: [name] } : { ...msg, params: [name, key] };
302
+ effects.push(...joinReducer(chan, singleParamMsg, ctx).effects);
303
+ }
304
+ return effects;
305
+ }
306
+
307
+ private routeSingleChannel(
308
+ msg: IrcMessage,
309
+ run: (chan: ChannelState) => EffectType[],
310
+ ): EffectType[] {
311
+ const name = msg.params[0] ?? '';
312
+ if (name.length === 0) {
313
+ return [this.unknownCommandEffect(msg.command)];
314
+ }
315
+ const chan = this.channels.getOrCreateChannel(name);
316
+ return run(chan);
317
+ }
318
+
319
+ private routeMultiChannel(
320
+ msg: IrcMessage,
321
+ run: (chan: ChannelState, singleParamMsg: IrcMessage) => EffectType[],
322
+ ): EffectType[] {
323
+ const effects: EffectType[] = [];
324
+ const rawParam = msg.params[0] ?? '';
325
+ const names = splitCsv(rawParam);
326
+ const tail = msg.params.slice(1);
327
+ // No channel argument: defer to the reducer so it can emit the proper
328
+ // 461 ERR_NEEDMOREPARAMS rather than the actor silently doing nothing.
329
+ if (names.length === 0) {
330
+ // No channel argument: defer to the reducer so it can emit the proper
331
+ // 461 ERR_NEEDMOREPARAMS rather than the actor silently doing nothing.
332
+ return run(this.channels.getOrCreateChannel(''), msg);
333
+ }
334
+ for (const name of names) {
335
+ const chan = this.channels.getOrCreateChannel(name);
336
+ const singleParamMsg: IrcMessage = { ...msg, params: [name, ...tail] };
337
+ effects.push(...run(chan, singleParamMsg));
338
+ }
339
+ return effects;
340
+ }
341
+
342
+ private routeMessageTarget(
343
+ msg: IrcMessage,
344
+ channelReducer: (
345
+ chan: ChannelState,
346
+ msg: IrcMessage,
347
+ ctx: ReturnType<typeof buildCtx>,
348
+ ) => {
349
+ effects: EffectType[];
350
+ },
351
+ userReducer: (
352
+ state: ConnectionState,
353
+ msg: IrcMessage,
354
+ ctx: ReturnType<typeof buildCtx>,
355
+ ) => {
356
+ effects: EffectType[];
357
+ },
358
+ ctx: ReturnType<typeof buildCtx>,
359
+ ): EffectType[] {
360
+ const effects: EffectType[] = [];
361
+ const targets = splitCsv(msg.params[0] ?? '');
362
+ const tail = msg.params.slice(1);
363
+ if (targets.length === 0) {
364
+ // No target argument: defer to the user-target reducer so it emits the
365
+ // proper 411 ERR_NORECIPIENT rather than silently doing nothing.
366
+ return userReducer(this.state, msg, ctx).effects;
367
+ }
368
+ for (const target of targets) {
369
+ const singleParamMsg: IrcMessage = { ...msg, params: [target, ...tail] };
370
+ if (isChannelTarget(target)) {
371
+ const chan = this.channels.getOrCreateChannel(target);
372
+ effects.push(...channelReducer(chan, singleParamMsg, ctx).effects);
373
+ } else {
374
+ effects.push(...userReducer(this.state, singleParamMsg, ctx).effects);
375
+ }
376
+ }
377
+ return effects;
378
+ }
379
+
380
+ private unknownCommandEffect(command: string): EffectType {
381
+ const nick = this.state.nick ?? '*';
382
+ const code = Numerics.ERR_UNKNOWNCOMMAND.toString().padStart(3, '0');
383
+ const line: RawLine = {
384
+ text:
385
+ command.length === 0
386
+ ? `:${this.serverConfig.serverName} ${code} ${nick} :Unknown command`
387
+ : `:${this.serverConfig.serverName} ${code} ${nick} ${command} :Unknown command`,
388
+ };
389
+ return Effect.send(this.state.id, [line]);
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Splits a WebSocket text frame into IRC lines. Tolerates `\r\n`, bare `\n`,
395
+ * and missing trailing terminators. Returns each line bare (no CRLF).
396
+ */
397
+ export function splitFrameLines(text: string): string[] {
398
+ if (text.length === 0) return [];
399
+ return text.split(/\r?\n/u);
400
+ }
401
+
402
+ /** Returns the first whitespace-delimited token of `line`, or `''`. */
403
+ function firstToken(line: string): string {
404
+ const m = line.match(/^\S+/u);
405
+ if (m === null) return '';
406
+ // `m[0]` is `string | undefined` per `noUncheckedIndexedAccess`, but a
407
+ // successful match always has `m[0]` defined (regex contract).
408
+ return m[0] as string;
409
+ }
410
+
411
+ /** Splits a comma-separated IRC parameter list. */
412
+ function splitCsv(param: string): string[] {
413
+ if (param.length === 0) return [];
414
+ return param.split(',').filter((p) => p.length > 0);
415
+ }
416
+
417
+ /**
418
+ * Extracts the channel names a reducer will read for the supplied command.
419
+ * Used by `prefetchChannelState` to refresh only the channels the upcoming
420
+ * route will touch. Returns the bare names as they appear in the message
421
+ * (no lowercasing — `refreshChannel` will lowercase on its own).
422
+ *
423
+ * Commands not listed here either have no channel target (PING, NICK, …)
424
+ * or read channel membership from `ctx.connection.joinedChannels` directly
425
+ * (QUIT), so they don't need a prefetch.
426
+ */
427
+ function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
428
+ const cmd = msg.command;
429
+ switch (cmd) {
430
+ case 'JOIN':
431
+ // `JOIN 0` parts every joined channel; the reducer reads
432
+ // joinedChannels off the connection directly, no prefetch needed.
433
+ if (msg.params[0] === '0') return [];
434
+ return splitCsv(msg.params[0] ?? '').filter(isChannelTarget) as ChanName[];
435
+ case 'PART':
436
+ case 'TOPIC':
437
+ case 'NAMES':
438
+ case 'KICK':
439
+ case 'MODE':
440
+ case 'PRIVMSG':
441
+ case 'NOTICE':
442
+ return splitCsv(msg.params[0] ?? '').filter(isChannelTarget) as ChanName[];
443
+ case 'INVITE':
444
+ // INVITE <nick> <channel> — the channel is the second param.
445
+ return splitCsv(msg.params[1] ?? '').filter(isChannelTarget) as ChanName[];
446
+ default:
447
+ return [];
448
+ }
449
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Effect dispatcher.
3
+ *
4
+ * `dispatch(effects, runtime)` interprets each {@link Effect} against the
5
+ * bound {@link IrcRuntime} port. The pure core emits the effect list; only
6
+ * this function awaits. Invariants:
7
+ *
8
+ * - Effects are processed in array order (the test named "ordering").
9
+ * - Consecutive identical Send effects coalesce into one runtime call
10
+ * (the test named "dedup").
11
+ * - The first runtime rejection propagates and stops processing.
12
+ */
13
+
14
+ import type { BroadcastEffect, Effect, RawLine } from '@serverless-ircd/irc-core';
15
+ import type { IrcRuntime } from './runtime.js';
16
+
17
+ /** Discriminated-union handler: each Effect tag maps to one runtime call. */
18
+ type Handler<E extends Effect> = (effect: E, runtime: IrcRuntime) => Promise<void>;
19
+
20
+ const handlers: { [K in Effect['tag']]: Handler<Extract<Effect, { tag: K }>> } = {
21
+ Send: (e, r) => r.send(e.to, e.lines),
22
+ Broadcast: (e, r) => broadcast(e, r),
23
+ Disconnect: (e, r) => r.disconnect(e.conn, e.reason),
24
+ SendToNick: (e, r) => r.sendToNick(e.nick, e.sender, e.lines, e.notFoundLines),
25
+ ReserveNick: (e, r) => r.reserveNick(e.nick, e.conn).then(noop),
26
+ ChangeNick: (e, r) => r.changeNick(e.conn, e.oldNick, e.newNick).then(noop),
27
+ ReleaseNick: (e, r) => r.releaseNick(e.nick),
28
+ ApplyChannelDelta: (e, r) => r.applyChannelDelta(e.chan, e.delta),
29
+ LookupNick: (e, r) => r.lookupNick(e.nick).then(noop),
30
+ GetConnectionInfo: (e, r) => r.getConnectionInfo(e.conn).then(noop),
31
+ GetChannelSnapshot: (e, r) => r.getChannelSnapshot(e.chan).then(noop),
32
+ };
33
+
34
+ function noop(): void {}
35
+
36
+ /**
37
+ * Returns true iff two effects are interchangeable — used to coalesce
38
+ * consecutive duplicates so a reducer's accidental double-send is a no-op.
39
+ *
40
+ * Only `Send` is deduped today: it's the only effect where exact-equality
41
+ * coalescing is always safe (same bytes to the same target). Broadcasts
42
+ * carry `except?` and channel-delta semantics that make blind coalescing
43
+ * risky; we leave them alone.
44
+ */
45
+ function isSameSend(a: Effect, b: Effect): boolean {
46
+ if (a.tag !== 'Send' || b.tag !== 'Send') return false;
47
+ if (a.to !== b.to) return false;
48
+ return linesEqual(a.lines, b.lines);
49
+ }
50
+
51
+ function linesEqual(a: RawLine[], b: RawLine[]): boolean {
52
+ if (a.length !== b.length) return false;
53
+ for (let i = 0; i < a.length; i++) {
54
+ const x = a[i];
55
+ const y = b[i];
56
+ if (x === undefined || y === undefined || x.text !== y.text) return false;
57
+ }
58
+ return true;
59
+ }
60
+
61
+ /**
62
+ * Interprets `effects` in order against `runtime`. Returns when every
63
+ * effect has been performed; the first rejection propagates.
64
+ */
65
+ export async function dispatch(effects: Effect[], runtime: IrcRuntime): Promise<void> {
66
+ let previous: Effect | undefined;
67
+ for (const effect of effects) {
68
+ if (previous !== undefined && isSameSend(previous, effect)) {
69
+ previous = effect;
70
+ continue;
71
+ }
72
+ await handle(effect, runtime);
73
+ previous = effect;
74
+ }
75
+ }
76
+
77
+ async function handle(effect: Effect, runtime: IrcRuntime): Promise<void> {
78
+ // The dispatch table is keyed by tag; the cast is the standard TS pattern
79
+ // for exhaustive-union dispatch.
80
+ const handler = handlers[effect.tag] as Handler<Effect>;
81
+ await handler(effect, runtime);
82
+ }
83
+
84
+ /**
85
+ * Cap-aware Broadcast fanout.
86
+ *
87
+ * When `cap` is unset the reducer has no per-recipient preference, so we
88
+ * take the runtime's `broadcast` fast path. When `cap` is set dispatch
89
+ * routes per-recipient instead, consulting each member's advertised caps:
90
+ *
91
+ * - `cap` + `capLines` (split mode): cap-enabled members receive
92
+ * `capLines`, everyone else receives `lines`.
93
+ * - `cap` without `capLines` (cap-only mode): `lines` go ONLY to
94
+ * cap-enabled members; the rest receive nothing.
95
+ *
96
+ * `except` is always honored. A member whose connection info is unavailable
97
+ * (the connection has gone) is treated as lacking the cap.
98
+ */
99
+ async function broadcast(e: BroadcastEffect, r: IrcRuntime): Promise<void> {
100
+ if (e.cap === undefined) {
101
+ await r.broadcast(e.chan, e.lines, e.except);
102
+ return;
103
+ }
104
+
105
+ const snapshot = await r.getChannelSnapshot(e.chan);
106
+ if (snapshot === null) return;
107
+
108
+ for (const connId of snapshot.members.keys()) {
109
+ if (connId === e.except) continue;
110
+
111
+ const info = await r.getConnectionInfo(connId);
112
+ const hasCap = info?.caps.has(e.cap) ?? false;
113
+
114
+ if (e.capLines !== undefined) {
115
+ await r.send(connId, hasCap ? e.capLines : e.lines);
116
+ } else if (hasCap) {
117
+ await r.send(connId, e.lines);
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @serverless-ircd/irc-server
3
+ *
4
+ * Orchestration layer: defines the {@link IrcRuntime} port, the {@link dispatch}
5
+ * interpreter that runs an `Effect[]` against it, and the {@link ConnectionActor}
6
+ * that turns a WebSocket text frame into reducer invocations + dispatch.
7
+ */
8
+ export type { IrcRuntime } from './runtime.js';
9
+ export { dispatch } from './dispatch.js';
10
+ export { ConnectionActor, splitFrameLines } from './actor.js';
11
+ export type { ActorChannelAccess, ConnectionActorOptions } from './actor.js';
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The {@link IrcRuntime} port.
3
+ *
4
+ * Every adapter (in-memory, CF, AWS) implements this interface. The pure
5
+ * core's `Effect[]` is interpreted against it by {@link dispatch}.
6
+ *
7
+ * Method-for-method compatibility with the {@link Effect} union is enforced
8
+ * by `dispatch.ts`'s handler table.
9
+ */
10
+
11
+ import type {
12
+ ChanName,
13
+ ChanSnapshot,
14
+ ChannelDelta,
15
+ ConnId,
16
+ ConnSnapshot,
17
+ ConnectionState,
18
+ Nick,
19
+ RawLine,
20
+ } from '@serverless-ircd/irc-core';
21
+
22
+ export interface IrcRuntime {
23
+ // transport
24
+ send(to: ConnId, lines: RawLine[]): Promise<void>;
25
+ broadcast(chan: ChanName, lines: RawLine[], except?: ConnId): Promise<void>;
26
+ disconnect(conn: ConnId, reason?: string): Promise<void>;
27
+ /**
28
+ * Routes `lines` to whichever connection currently owns `nick`. If the
29
+ * nick is offline and `notFoundLines` is supplied, those lines are sent
30
+ * to `sender` (PRIVMSG's `401`; NOTICE omits the fallback entirely).
31
+ */
32
+ sendToNick(
33
+ nick: Nick,
34
+ sender: ConnId,
35
+ lines: RawLine[],
36
+ notFoundLines?: RawLine[],
37
+ ): Promise<void>;
38
+
39
+ // nickname registry
40
+ reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }>;
41
+ changeNick(conn: ConnId, oldNick: Nick, newNick: Nick): Promise<boolean>;
42
+ releaseNick(nick: Nick): Promise<void>;
43
+
44
+ // channel ownership
45
+ applyChannelDelta(chan: ChanName, delta: ChannelDelta): Promise<void>;
46
+
47
+ // lookups (NAMES, WHOIS, routing)
48
+ lookupNick(nick: Nick): Promise<ConnId | null>;
49
+ getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null>;
50
+ /**
51
+ * Returns the live {@link ConnectionState} for `conn`, or `null` if the
52
+ * connection is unknown / offline. Used by reducers that need a peer's
53
+ * full state (e.g. WHOIS) — the returned object is the authority-owned
54
+ * reference for in-memory runtimes and a materialized view for
55
+ * distributed runtimes.
56
+ */
57
+ getConnection(conn: ConnId): Promise<ConnectionState | null>;
58
+ getChannelSnapshot(chan: ChanName): Promise<ChanSnapshot | null>;
59
+ getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>>;
60
+ listChannels(): Promise<ChanSnapshot[]>;
61
+ }