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,171 @@
1
+ /**
2
+ * Pure reducer for the IRC `TOPIC` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: TOPIC runs in the Channel entity. The
5
+ * reducer's `state` argument is the channel's authoritative state; the
6
+ * caller's identity is `ctx.connection`.
7
+ *
8
+ * Read form (`TOPIC <chan>` with no trailing topic):
9
+ * - `331 RPL_NOTOPIC` when no topic is set.
10
+ * - `332 RPL_TOPIC` + `333 RPL_TOPICWHOTIME` when a topic is set.
11
+ *
12
+ * Write form (`TOPIC <chan> :<topic>`):
13
+ * - Sets `state.topic` (clearing it for an empty trailing param).
14
+ * - Honors `+t` (topic lock): only ops may change the topic; non-op
15
+ * members get `482 ERR_CHANOPRIVSNEEDED`.
16
+ * - Truncates the topic to `serverConfig.topicLen` to keep wire lines
17
+ * within the 512-byte budget.
18
+ * - Broadcasts `:hostmask TOPIC <chan> :<topic>` to the channel.
19
+ */
20
+
21
+ import { Effect } from '../effects.js';
22
+ import type { Effect as EffectType, RawLine } from '../effects.js';
23
+ import { Numerics } from '../protocol/numerics.js';
24
+ import type { ChannelState, ChannelTopic } from '../state/channel.js';
25
+ import { hostmaskOf } from '../state/connection.js';
26
+ import type { ConnectionState } from '../state/connection.js';
27
+ import type { Ctx, Reducer } from '../types.js';
28
+
29
+ /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
30
+ const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
31
+
32
+ function isValidChannelName(name: string, maxLen: number): boolean {
33
+ if (name.length === 0 || name.length > maxLen) return false;
34
+ return CHANNEL_NAME_RE.test(name);
35
+ }
36
+
37
+ /** Formats a numeric error/reply line addressed to the connection's nick. */
38
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
39
+ const nick = ctx.connection.nick ?? '*';
40
+ const codeStr = code.toString().padStart(3, '0');
41
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
42
+ if (middle !== undefined) parts.push(middle);
43
+ parts.push(`:${trailing}`);
44
+ return { text: parts.join(' ') };
45
+ }
46
+
47
+ /** Returns the caller's hostmask, falling back to nick then `?`. */
48
+ function callerHostmask(conn: ConnectionState): string {
49
+ return hostmaskOf(conn) ?? conn.nick ?? '?';
50
+ }
51
+
52
+ /**
53
+ * Handles `TOPIC <chan> [:<topic>]`.
54
+ *
55
+ * Validation order (deliberate): missing-param → invalid-name →
56
+ * not-on-channel → write-authorization (+t) → success.
57
+ */
58
+ export const topicReducer: Reducer<ChannelState> = (state, msg, ctx) => {
59
+ ctx.connection.lastSeen = ctx.clock.now();
60
+
61
+ const effects: EffectType[] = [];
62
+
63
+ const rawName = msg.params[0];
64
+ if (rawName === undefined) {
65
+ effects.push(
66
+ Effect.send(ctx.connId, [
67
+ numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'TOPIC'),
68
+ ]),
69
+ );
70
+ return { state, effects };
71
+ }
72
+
73
+ if (!isValidChannelName(rawName, ctx.serverConfig.channelLen)) {
74
+ effects.push(
75
+ Effect.send(ctx.connId, [
76
+ numericErr(ctx, Numerics.ERR_NOSUCHCHANNEL, 'No such channel', rawName),
77
+ ]),
78
+ );
79
+ return { state, effects };
80
+ }
81
+
82
+ // Must be a member to read or write the topic.
83
+ const entry = state.members.get(ctx.connId);
84
+ if (entry === undefined) {
85
+ effects.push(
86
+ Effect.send(ctx.connId, [
87
+ numericErr(ctx, Numerics.ERR_NOTONCHANNEL, "You're not on that channel", state.name),
88
+ ]),
89
+ );
90
+ return { state, effects };
91
+ }
92
+
93
+ // Read form: no topic param supplied. We check `params[1]` directly
94
+ // (rather than `params.length`) because it cleanly handles both
95
+ // `TOPIC #foo` and any defensive "params is shorter than expected" case.
96
+ const newTopic = msg.params[1];
97
+ if (newTopic === undefined) {
98
+ return readTopic(state, ctx, effects);
99
+ }
100
+
101
+ // Write form. +t (topic lock) requires op.
102
+ if (state.modes.topicLock && !entry.op) {
103
+ effects.push(
104
+ Effect.send(ctx.connId, [
105
+ numericErr(ctx, Numerics.ERR_CHANOPRIVSNEEDED, "You're not channel operator", state.name),
106
+ ]),
107
+ );
108
+ return { state, effects };
109
+ }
110
+
111
+ const truncated =
112
+ newTopic.length > ctx.serverConfig.topicLen
113
+ ? newTopic.slice(0, ctx.serverConfig.topicLen)
114
+ : newTopic;
115
+
116
+ // Empty topic clears; non-empty sets.
117
+ if (truncated.length === 0) {
118
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `=undefined`.
119
+ delete state.topic;
120
+ // Mirror the topic clear to the runtime's channel authority. In the
121
+ // in-memory runtime the actor's `state` IS authoritative so this is
122
+ // redundant; in distributed runtimes (CF ChannelDO) this is the only
123
+ // path that persists the change, so subsequent reads by other
124
+ // connections observe the cleared topic.
125
+ effects.push(Effect.applyChannelDelta(state.name, { topic: null }));
126
+ } else {
127
+ const topic: ChannelTopic = {
128
+ text: truncated,
129
+ setter: callerHostmask(ctx.connection),
130
+ setAt: ctx.clock.now(),
131
+ };
132
+ state.topic = topic;
133
+ // Mirror the topic set to the channel authority (see comment above).
134
+ effects.push(Effect.applyChannelDelta(state.name, { topic }));
135
+ }
136
+
137
+ const hostmask = callerHostmask(ctx.connection);
138
+ const line: RawLine = { text: `:${hostmask} TOPIC ${state.name} :${truncated}` };
139
+ effects.push(Effect.broadcast(state.name, [line]));
140
+
141
+ return { state, effects };
142
+ };
143
+
144
+ /** Emits the read-form numeric block: `331` (no topic) or `332` + `333`. */
145
+ function readTopic(
146
+ state: ChannelState,
147
+ ctx: Ctx,
148
+ effects: EffectType[],
149
+ ): { state: ChannelState; effects: EffectType[] } {
150
+ if (state.topic === undefined) {
151
+ effects.push(
152
+ Effect.send(ctx.connId, [
153
+ numericErr(ctx, Numerics.RPL_NOTOPIC, 'No topic is set', state.name),
154
+ ]),
155
+ );
156
+ return { state, effects };
157
+ }
158
+
159
+ const nick = ctx.connection.nick ?? '*';
160
+ const codeStr332 = Numerics.RPL_TOPIC.toString().padStart(3, '0');
161
+ const codeStr333 = Numerics.RPL_TOPICWHOTIME.toString().padStart(3, '0');
162
+ effects.push(
163
+ Effect.send(ctx.connId, [
164
+ { text: `:${ctx.serverName} ${codeStr332} ${nick} ${state.name} :${state.topic.text}` },
165
+ {
166
+ text: `:${ctx.serverName} ${codeStr333} ${nick} ${state.name} ${state.topic.setter} ${state.topic.setAt}`,
167
+ },
168
+ ]),
169
+ );
170
+ return { state, effects };
171
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Pure reducer for the IRC `WHO` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: WHO reads channel + connection state but
5
+ * does not mutate either, so the reducer takes the channel snapshot (state
6
+ * arg) and a separate `connections` map keyed by ConnId. The requester is
7
+ * `ctx.connection`.
8
+ *
9
+ * Wire format (RFC 1459/2812 §3.6.1):
10
+ * - `352 RPL_WHOREPLY`:
11
+ * `:<server> 352 <nick> <chan> <user> <host> <server> <nick> <flags> :<hops> <real>`
12
+ * - `<flags>` starts with `H` (here) or `G` (gone/away), optionally
13
+ * followed by `*` (oper), then channel-prefix chars (`@`, `+`).
14
+ * - `315 RPL_ENDOFWHO`: `:<server> 315 <nick> <mask> :End of /WHO list`
15
+ *
16
+ * Visibility rules:
17
+ * - `+s` (secret) channels: members only. Non-members get an empty list
18
+ * (no 403 — modern IRC daemons treat WHO of a +s channel as "no match"
19
+ * for non-members to avoid leaking existence).
20
+ * - `+i` (invisible user mode): only shown when the requester shares a
21
+ * channel with the invisible user. Within a single WHO #chan call the
22
+ * requester is by construction on `#chan`, so invisible members of that
23
+ * channel are listed.
24
+ *
25
+ * The actor layer routes WHO by target type:
26
+ * - Channel target (`#foo`): handled here, against the channel snapshot.
27
+ * - Nick target / wildcards: handled by the actor layer against the nick
28
+ * registry and connection snapshots; this reducer is channel-scoped.
29
+ *
30
+ * For v1 the `<server>` field in 352 is the configured local server name
31
+ * (PLAN §1: single-server, no S2S linking).
32
+ */
33
+
34
+ import { Effect } from '../effects.js';
35
+ import type { Effect as EffectType, RawLine } from '../effects.js';
36
+ import { Numerics } from '../protocol/numerics.js';
37
+ import type { ChannelState, RosterEntry } from '../state/channel.js';
38
+ import type { ConnectionState } from '../state/connection.js';
39
+ import type { Ctx } from '../types.js';
40
+
41
+ /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
42
+ const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
43
+
44
+ function isValidChannelName(name: string, maxLen: number): boolean {
45
+ if (name.length === 0 || name.length > maxLen) return false;
46
+ return CHANNEL_NAME_RE.test(name);
47
+ }
48
+
49
+ /** Formats a numeric error/reply line addressed to the connection's nick. */
50
+ function numericLine(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
51
+ const nick = ctx.connection.nick ?? '*';
52
+ const codeStr = code.toString().padStart(3, '0');
53
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
54
+ if (middle !== '') parts.push(middle);
55
+ parts.push(`:${trailing}`);
56
+ return { text: parts.join(' ') };
57
+ }
58
+
59
+ /**
60
+ * Builds the `<flags>` field for one 352 line.
61
+ *
62
+ * Channel-prefix chars follow the away/oper markers. With `multi-prefix`
63
+ * every applicable prefix is emitted (`@+` for an op+voice user); without
64
+ * the cap only the highest-priority prefix is emitted (`@`), matching the
65
+ * NAMES reducer's behavior and IRCv3 `multi-prefix` spec.
66
+ */
67
+ function whoFlags(conn: ConnectionState, entry: RosterEntry, ctx: Ctx): string {
68
+ let flags = conn.away !== undefined ? 'G' : 'H';
69
+ if (conn.userModes.oper) flags += '*';
70
+ if (ctx.connection.caps.has('multi-prefix')) {
71
+ if (entry.op) flags += '@';
72
+ if (entry.voice) flags += '+';
73
+ } else if (entry.op) {
74
+ flags += '@';
75
+ } else if (entry.voice) {
76
+ flags += '+';
77
+ }
78
+ return flags;
79
+ }
80
+
81
+ /**
82
+ * Builds one 352 line for a single member.
83
+ *
84
+ * `hops` is hard-coded to 0 (single-server v1, no S2S hops).
85
+ */
86
+ function whoLine(chanName: string, conn: ConnectionState, entry: RosterEntry, ctx: Ctx): RawLine {
87
+ const nick = ctx.connection.nick ?? '*';
88
+ const user = conn.user;
89
+ const host = conn.host;
90
+ const server = ctx.serverName;
91
+ const memberNick = conn.nick;
92
+ const flags = whoFlags(conn, entry, ctx);
93
+ const hops = 0;
94
+ const real = conn.realname;
95
+ const codeStr = Numerics.RPL_WHOREPLY.toString().padStart(3, '0');
96
+ return {
97
+ text: `:${ctx.serverName} ${codeStr} ${nick} ${chanName} ${user} ${host} ${server} ${memberNick} ${flags} :${hops} ${real}`,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Handles `WHO <chan> [o]`.
103
+ *
104
+ * Returns the empty-list reply when visibility checks fail; otherwise emits
105
+ * one 352 per visible member then a 315. The `o` filter restricts output to
106
+ * IRC operators (server operators, not channel ops).
107
+ */
108
+ export function whoReducer(
109
+ state: ChannelState,
110
+ connections: ReadonlyMap<string, ConnectionState>,
111
+ msg: { command: string; params: string[] },
112
+ ctx: Ctx,
113
+ ): { state: ChannelState; effects: EffectType[] } {
114
+ ctx.connection.lastSeen = ctx.clock.now();
115
+
116
+ const effects: EffectType[] = [];
117
+
118
+ const rawMask = msg.params[0];
119
+ if (rawMask === undefined) {
120
+ // No mask: modern daemons reply with end-of-WHO for the empty mask.
121
+ // We mirror that and emit no 461 (matches test spec).
122
+ effects.push(
123
+ Effect.send(ctx.connId, [numericLine(ctx, Numerics.RPL_ENDOFWHO, 'End of /WHO list', '')]),
124
+ );
125
+ return { state, effects };
126
+ }
127
+
128
+ if (!isValidChannelName(rawMask, ctx.serverConfig.channelLen)) {
129
+ effects.push(
130
+ Effect.send(ctx.connId, [
131
+ numericLine(ctx, Numerics.ERR_NOSUCHCHANNEL, 'No such channel', rawMask),
132
+ ]),
133
+ );
134
+ return { state, effects };
135
+ }
136
+
137
+ // +s channel: non-members get an empty reply (no 403 — avoid leaking).
138
+ const isMember = state.members.has(ctx.connId);
139
+ if ((state.modes.secret || state.modes.private) && !isMember) {
140
+ effects.push(
141
+ Effect.send(ctx.connId, [
142
+ numericLine(ctx, Numerics.RPL_ENDOFWHO, 'End of /WHO list', state.name),
143
+ ]),
144
+ );
145
+ return { state, effects };
146
+ }
147
+
148
+ // `o` filter: operators only.
149
+ const opsOnly = msg.params[1] === 'o';
150
+
151
+ const lines: RawLine[] = [];
152
+ for (const entry of state.members.values()) {
153
+ const conn = connections.get(entry.conn);
154
+ if (conn === undefined) continue;
155
+ if (opsOnly && !conn.userModes.oper) continue;
156
+ lines.push(whoLine(state.name, conn, entry, ctx));
157
+ }
158
+
159
+ if (lines.length > 0) {
160
+ effects.push(Effect.send(ctx.connId, lines));
161
+ }
162
+ effects.push(
163
+ Effect.send(ctx.connId, [
164
+ numericLine(ctx, Numerics.RPL_ENDOFWHO, 'End of /WHO list', state.name),
165
+ ]),
166
+ );
167
+
168
+ return { state, effects };
169
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Pure reducer for the IRC `WHOIS` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: WHOIS reads connection + channel state but
5
+ * does not mutate the target, so the reducer takes the target ConnectionState
6
+ * (resolved by the actor layer via the nick registry) and the channels the
7
+ * target is a member of. The requester is `ctx.connection`.
8
+ *
9
+ * Wire format (RFC 1459/2812 §3.6.2):
10
+ * - `311 RPL_WHOISUSER`:
11
+ * `:<server> 311 <nick> <target> <user> <host> * :<real>`
12
+ * - `319 RPL_WHOISCHANNELS`:
13
+ * `:<server> 319 <nick> <target> :<channels>` (space-separated, prefixed)
14
+ * - `312 RPL_WHOISSERVER`:
15
+ * `:<server> 312 <nick> <target> <server> :<network>`
16
+ * - `317 RPL_WHOISIDLE`:
17
+ * `:<server> 317 <nick> <target> <idle-secs> <signon-secs> :seconds idle, signon time`
18
+ * - `330 RPL_WHOISACCOUNT` (IRCv3):
19
+ * `:<server> 330 <nick> <target> <account> :is logged in as`
20
+ * - `313 RPL_WHOISOPERATOR`:
21
+ * `:<server> 313 <nick> <target> :is an IRC operator`
22
+ * - `318 RPL_ENDOFWHOIS`:
23
+ * `:<server> 318 <nick> <target> :End of /WHOIS list`
24
+ * - `401 ERR_NOSUCHNICK`:
25
+ * `:<server> 401 <nick> <target> :No such nick/channel`
26
+ *
27
+ * Channel visibility:
28
+ * - Secret (+s) and private (+p) channels are hidden unless the requester
29
+ * is also a member.
30
+ *
31
+ * Idle and signon math:
32
+ * - `idle` = `floor((ctx.clock.now() - target.lastSeen) / 1000)`
33
+ * - `signon` = `floor(target.connectedSince / 1000)`
34
+ */
35
+
36
+ import { Effect } from '../effects.js';
37
+ import type { Effect as EffectType, RawLine } from '../effects.js';
38
+ import { Numerics } from '../protocol/numerics.js';
39
+ import type { ChannelState, RosterEntry } from '../state/channel.js';
40
+ import type { ConnectionState } from '../state/connection.js';
41
+ import type { Ctx } from '../types.js';
42
+
43
+ /** Formats a numeric line addressed to the requester. */
44
+ function numericLine(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 !== '') parts.push(middle);
49
+ parts.push(`:${trailing}`);
50
+ return { text: parts.join(' ') };
51
+ }
52
+
53
+ /** Returns the roster sigil (`@`, `+`, or '') for the target on a channel. */
54
+ function prefixOf(entry: RosterEntry): string {
55
+ if (entry.op) return '@';
56
+ if (entry.voice) return '+';
57
+ return '';
58
+ }
59
+
60
+ /**
61
+ * Builds the 319 channel list, applying +s/+p visibility filtering.
62
+ *
63
+ * Channels are sorted alphabetically (by lowercase name) for deterministic
64
+ * output. Each entry is prefixed with the target's mode sigil on that channel.
65
+ */
66
+ function visibleChannels(
67
+ target: ConnectionState,
68
+ channels: ReadonlyArray<ChannelState>,
69
+ ctx: Ctx,
70
+ ): string {
71
+ const visible: Array<{ name: string; sort: string; prefix: string }> = [];
72
+ for (const chan of channels) {
73
+ const entry = chan.members.get(target.id);
74
+ if (entry === undefined) continue;
75
+ const hidden = chan.modes.secret || chan.modes.private;
76
+ if (hidden && !chan.members.has(ctx.connId)) continue;
77
+ visible.push({ name: chan.name, sort: chan.nameLower, prefix: prefixOf(entry) });
78
+ }
79
+ visible.sort((a, b) => a.sort.localeCompare(b.sort));
80
+ return visible.map((c) => `${c.prefix}${c.name}`).join(' ');
81
+ }
82
+
83
+ /**
84
+ * Handles `WHOIS <nick>`.
85
+ *
86
+ * The actor layer resolves `<nick>` to a target ConnectionState via the nick
87
+ * registry, collects the channels the target is a member of, and passes both
88
+ * here. If `target` is `undefined`, the reducer emits 401 then 318.
89
+ */
90
+ export function whoisReducer(
91
+ target: ConnectionState | undefined,
92
+ channels: ReadonlyArray<ChannelState>,
93
+ msg: { command: string; params: string[] },
94
+ ctx: Ctx,
95
+ ): { effects: EffectType[] } {
96
+ ctx.connection.lastSeen = ctx.clock.now();
97
+
98
+ const effects: EffectType[] = [];
99
+ const targetNick = msg.params[0] ?? '';
100
+
101
+ if (target === undefined) {
102
+ if (targetNick !== '') {
103
+ effects.push(
104
+ Effect.send(ctx.connId, [
105
+ numericLine(ctx, Numerics.ERR_NOSUCHNICK, 'No such nick/channel', targetNick),
106
+ ]),
107
+ );
108
+ }
109
+ effects.push(
110
+ Effect.send(ctx.connId, [
111
+ numericLine(ctx, Numerics.RPL_ENDOFWHOIS, 'End of /WHOIS list', targetNick),
112
+ ]),
113
+ );
114
+ return { effects };
115
+ }
116
+
117
+ const lines: RawLine[] = [];
118
+ const requesterNick = ctx.connection.nick ?? '*';
119
+ const tNick = target.nick ?? '*';
120
+ const tUser = target.user ?? '?';
121
+ const tHost = target.host ?? '?';
122
+ const tReal = target.realname ?? tNick;
123
+ const idleSecs = Math.floor((ctx.clock.now() - target.lastSeen) / 1000);
124
+ const signonSecs = Math.floor(target.connectedSince / 1000);
125
+
126
+ lines.push({
127
+ text: `:${ctx.serverName} ${Numerics.RPL_WHOISUSER.toString().padStart(3, '0')} ${requesterNick} ${tNick} ${tUser} ${tHost} * :${tReal}`,
128
+ });
129
+ lines.push({
130
+ text: `:${ctx.serverName} ${Numerics.RPL_WHOISSERVER.toString().padStart(3, '0')} ${requesterNick} ${tNick} ${ctx.serverName} :${ctx.networkName}`,
131
+ });
132
+ if (target.userModes.oper) {
133
+ lines.push({
134
+ text: `:${ctx.serverName} ${Numerics.RPL_WHOISOPERATOR.toString().padStart(3, '0')} ${requesterNick} ${tNick} :is an IRC operator`,
135
+ });
136
+ }
137
+ if (target.account !== undefined) {
138
+ lines.push({
139
+ text: `:${ctx.serverName} ${Numerics.RPL_WHOISACCOUNT.toString().padStart(3, '0')} ${requesterNick} ${tNick} ${target.account} :is logged in as`,
140
+ });
141
+ }
142
+ lines.push({
143
+ text: `:${ctx.serverName} ${Numerics.RPL_WHOISIDLE.toString().padStart(3, '0')} ${requesterNick} ${tNick} ${idleSecs} ${signonSecs} :seconds idle, signon time`,
144
+ });
145
+ effects.push(Effect.send(ctx.connId, lines));
146
+
147
+ const channelList = visibleChannels(target, channels, ctx);
148
+ if (channelList !== '') {
149
+ effects.push(
150
+ Effect.send(ctx.connId, [
151
+ {
152
+ text: `:${ctx.serverName} ${Numerics.RPL_WHOISCHANNELS.toString().padStart(3, '0')} ${requesterNick} ${tNick} :${channelList}`,
153
+ },
154
+ ]),
155
+ );
156
+ }
157
+
158
+ effects.push(
159
+ Effect.send(ctx.connId, [
160
+ numericLine(ctx, Numerics.RPL_ENDOFWHOIS, 'End of /WHOIS list', tNick),
161
+ ]),
162
+ );
163
+
164
+ return { effects };
165
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The `Effect` discriminated union.
3
+ *
4
+ * Reducers are pure: they emit a description of every side effect they
5
+ * need (transport sends, nick registry mutations, channel deltas, lookups)
6
+ * without performing them. The actor layer's `dispatch(effects, runtime)`
7
+ * interprets each effect against the bound {@link IrcRuntime}.
8
+ *
9
+ * Every effect tag corresponds one-to-one with an `IrcRuntime` method, so
10
+ * the dispatch table is mechanical.
11
+ *
12
+ * Coverage of side effects referenced in PLAN §2.1:
13
+ * - transport: {@link SendEffect}, {@link BroadcastEffect}, {@link DisconnectEffect}
14
+ * - nick registry: {@link ReserveNickEffect}, {@link ChangeNickEffect},
15
+ * {@link ReleaseNickEffect}
16
+ * - channel ownership: {@link ApplyChannelDeltaEffect}
17
+ * - lookups: {@link LookupNickEffect}, {@link GetConnectionInfoEffect},
18
+ * {@link GetChannelSnapshotEffect}
19
+ */
20
+
21
+ import type { ChanName, ChannelDelta } from './state/channel.js';
22
+ import type { ConnId, Nick } from './state/connection.js';
23
+
24
+ /** A single wire-format IRC line, ready to ship to a transport. */
25
+ export interface RawLine {
26
+ text: string;
27
+ }
28
+
29
+ export interface SendEffect {
30
+ tag: 'Send';
31
+ to: ConnId;
32
+ lines: RawLine[];
33
+ }
34
+
35
+ export interface BroadcastEffect {
36
+ tag: 'Broadcast';
37
+ chan: ChanName;
38
+ lines: RawLine[];
39
+ except?: ConnId;
40
+ cap?: string;
41
+ capLines?: RawLine[];
42
+ }
43
+
44
+ export interface DisconnectEffect {
45
+ tag: 'Disconnect';
46
+ conn: ConnId;
47
+ reason?: string;
48
+ }
49
+
50
+ export interface ReserveNickEffect {
51
+ tag: 'ReserveNick';
52
+ nick: Nick;
53
+ conn: ConnId;
54
+ }
55
+
56
+ export interface ChangeNickEffect {
57
+ tag: 'ChangeNick';
58
+ conn: ConnId;
59
+ oldNick: Nick;
60
+ newNick: Nick;
61
+ }
62
+
63
+ export interface ReleaseNickEffect {
64
+ tag: 'ReleaseNick';
65
+ nick: Nick;
66
+ }
67
+
68
+ export interface ApplyChannelDeltaEffect {
69
+ tag: 'ApplyChannelDelta';
70
+ chan: ChanName;
71
+ delta: ChannelDelta;
72
+ }
73
+
74
+ export interface LookupNickEffect {
75
+ tag: 'LookupNick';
76
+ nick: Nick;
77
+ }
78
+
79
+ export interface GetConnectionInfoEffect {
80
+ tag: 'GetConnectionInfo';
81
+ conn: ConnId;
82
+ }
83
+
84
+ export interface GetChannelSnapshotEffect {
85
+ tag: 'GetChannelSnapshot';
86
+ chan: ChanName;
87
+ }
88
+
89
+ /**
90
+ * Routes `lines` to whichever connection currently owns `nick`. If the nick
91
+ * is offline, `notFoundLines` (when supplied) is sent back to `sender` —
92
+ * this is how PRIVMSG surfaces `401 ERR_NOSUCHNICK`; NOTICE omits the
93
+ * fallback entirely (RFC: NOTICE MUST NEVER generate numerics).
94
+ */
95
+ export interface SendToNickEffect {
96
+ tag: 'SendToNick';
97
+ nick: Nick;
98
+ sender: ConnId;
99
+ lines: RawLine[];
100
+ notFoundLines?: RawLine[];
101
+ }
102
+
103
+ /** Discriminated union of every effect the dispatch interpreter knows. */
104
+ export type Effect =
105
+ | SendEffect
106
+ | BroadcastEffect
107
+ | DisconnectEffect
108
+ | ReserveNickEffect
109
+ | ChangeNickEffect
110
+ | ReleaseNickEffect
111
+ | ApplyChannelDeltaEffect
112
+ | LookupNickEffect
113
+ | GetConnectionInfoEffect
114
+ | GetChannelSnapshotEffect
115
+ | SendToNickEffect;
116
+
117
+ /** Literal string tag of every {@link Effect} variant. */
118
+ export type EffectTag = Effect['tag'];
119
+
120
+ /**
121
+ * Per-effect success indicator returned by `dispatch` for effects whose
122
+ * runtime method yields a meaningful result. Lookup-shaped effects surface
123
+ * `null` when nothing matched; nick-reservation surfaces `{ ok: false }` on
124
+ * collision; everything else reports `{ ok: true }`.
125
+ */
126
+ export type EffectDispatchResult =
127
+ | { ok: true }
128
+ | { ok: false }
129
+ | { ok: true; value: unknown } // optional payload (lookup result)
130
+ | null;
131
+
132
+ /**
133
+ * Tagged-union constructors. Keeps reducer code terse and gives the test
134
+ * suite a single import surface to assert against.
135
+ */
136
+ export const Effect = {
137
+ send(to: ConnId, lines: RawLine[]): Effect {
138
+ return { tag: 'Send', to, lines };
139
+ },
140
+ broadcast(
141
+ chan: ChanName,
142
+ lines: RawLine[],
143
+ except?: ConnId,
144
+ cap?: string,
145
+ capLines?: RawLine[],
146
+ ): Effect {
147
+ const e: BroadcastEffect = { tag: 'Broadcast', chan, lines };
148
+ if (except !== undefined) e.except = except;
149
+ if (cap !== undefined) e.cap = cap;
150
+ if (capLines !== undefined) e.capLines = capLines;
151
+ return e;
152
+ },
153
+ disconnect(conn: ConnId, reason?: string): Effect {
154
+ const e: DisconnectEffect = { tag: 'Disconnect', conn };
155
+ if (reason !== undefined) e.reason = reason;
156
+ return e;
157
+ },
158
+ reserveNick(nick: Nick, conn: ConnId): Effect {
159
+ return { tag: 'ReserveNick', nick, conn };
160
+ },
161
+ changeNick(conn: ConnId, oldNick: Nick, newNick: Nick): Effect {
162
+ return { tag: 'ChangeNick', conn, oldNick, newNick };
163
+ },
164
+ releaseNick(nick: Nick): Effect {
165
+ return { tag: 'ReleaseNick', nick };
166
+ },
167
+ applyChannelDelta(chan: ChanName, delta: ChannelDelta): Effect {
168
+ return { tag: 'ApplyChannelDelta', chan, delta };
169
+ },
170
+ lookupNick(nick: Nick): Effect {
171
+ return { tag: 'LookupNick', nick };
172
+ },
173
+ getConnectionInfo(conn: ConnId): Effect {
174
+ return { tag: 'GetConnectionInfo', conn };
175
+ },
176
+ getChannelSnapshot(chan: ChanName): Effect {
177
+ return { tag: 'GetChannelSnapshot', chan };
178
+ },
179
+ sendToNick(nick: Nick, sender: ConnId, lines: RawLine[], notFoundLines?: RawLine[]): Effect {
180
+ const e: SendToNickEffect = { tag: 'SendToNick', nick, sender, lines };
181
+ if (notFoundLines !== undefined) e.notFoundLines = notFoundLines;
182
+ return e;
183
+ },
184
+ } as const;