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,322 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { motdReducer } from '../../src/commands/motd';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { EmptyMotdProvider, type MotdProvider, StaticMotdProvider } from '../../src/ports';
6
+ import { FakeClock, SequentialIdFactory } from '../../src/ports';
7
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
8
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
9
+
10
+ const serverConfig: ServerConfig = {
11
+ serverName: 'irc.example.com',
12
+ networkName: 'ExampleNet',
13
+ maxChannelsPerUser: 30,
14
+ maxTargetsPerCommand: 10,
15
+ maxListEntries: 50,
16
+ nickLen: 30,
17
+ channelLen: 50,
18
+ topicLen: 390,
19
+ quitMessage: 'Client Quit',
20
+ };
21
+
22
+ function makeCtx(
23
+ state: ConnectionState,
24
+ motd: MotdProvider = new StaticMotdProvider([]),
25
+ clock = new FakeClock(1_000),
26
+ ): Ctx {
27
+ return buildCtx({
28
+ serverConfig,
29
+ clock,
30
+ ids: new SequentialIdFactory(),
31
+ connection: state,
32
+ motd,
33
+ });
34
+ }
35
+
36
+ function makeState(): ConnectionState {
37
+ const s = createConnection({ id: 'c1', connectedSince: 0 });
38
+ s.nick = 'alice';
39
+ s.user = 'alice';
40
+ s.host = 'example.com';
41
+ s.realname = 'Alice';
42
+ s.registration = 'registered';
43
+ return s;
44
+ }
45
+
46
+ const L = (text: string): RawLine => ({ text });
47
+
48
+ // ============================================================================
49
+ // motdReducer — success path
50
+ // ============================================================================
51
+
52
+ describe('motdReducer — success', () => {
53
+ it('emits 375, one 372 per MOTD line, then 376 for a 3-line MOTD', () => {
54
+ const state = makeState();
55
+ const ctx = makeCtx(state, new StaticMotdProvider(['line one', 'line two', 'line three']));
56
+ const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
57
+
58
+ expect(out.effects).toEqual<EffectType[]>([
59
+ Effect.send('c1', [
60
+ L(':irc.example.com 375 alice :- irc.example.com Message of the day -'),
61
+ L(':irc.example.com 372 alice :- line one'),
62
+ L(':irc.example.com 372 alice :- line two'),
63
+ L(':irc.example.com 372 alice :- line three'),
64
+ L(':irc.example.com 376 alice :End of MOTD command'),
65
+ ]),
66
+ ]);
67
+ });
68
+
69
+ it('emits exactly one 372 for a single-line MOTD', () => {
70
+ const state = makeState();
71
+ const ctx = makeCtx(state, new StaticMotdProvider(['only line']));
72
+ const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
73
+
74
+ const send = out.effects[0];
75
+ expect(send).toBeDefined();
76
+ expect(send?.tag).toBe('Send');
77
+ if (send?.tag === 'Send') {
78
+ const codes = send.lines.map((l) => l.text.split(' ')[1]);
79
+ expect(codes).toEqual(['375', '372', '376']);
80
+ }
81
+ });
82
+
83
+ it('produces exactly one Send effect to the requesting connection', () => {
84
+ const state = makeState();
85
+ const ctx = makeCtx(state, new StaticMotdProvider(['a', 'b']));
86
+ const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
87
+ expect(out.effects).toHaveLength(1);
88
+ expect(out.effects[0]?.tag).toBe('Send');
89
+ if (out.effects[0]?.tag === 'Send') {
90
+ expect(out.effects[0].to).toBe('c1');
91
+ }
92
+ });
93
+
94
+ it('reads MOTD lines fresh from the provider on each invocation', () => {
95
+ const state = makeState();
96
+ const provider = new StaticMotdProvider(['first']);
97
+ const ctx = makeCtx(state, provider);
98
+ const out1 = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
99
+ provider.setLines(['first', 'second']);
100
+ const out2 = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
101
+
102
+ const count372 = (e: EffectType | undefined): number => {
103
+ if (e?.tag !== 'Send') return 0;
104
+ return e.lines.filter((l) => l.text.split(' ')[1] === '372').length;
105
+ };
106
+ expect(count372(out1.effects[0])).toBe(1);
107
+ expect(count372(out2.effects[0])).toBe(2);
108
+ });
109
+ });
110
+
111
+ // ============================================================================
112
+ // motdReducer — empty MOTD → 422
113
+ // ============================================================================
114
+
115
+ describe('motdReducer — empty MOTD', () => {
116
+ it('emits a single 422 ERR_NOMOTD when the provider has no lines', () => {
117
+ const state = makeState();
118
+ const ctx = makeCtx(state, new StaticMotdProvider([]));
119
+ const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
120
+
121
+ expect(out.effects).toEqual<EffectType[]>([
122
+ Effect.send('c1', [L(':irc.example.com 422 alice :MOTD File is missing')]),
123
+ ]);
124
+ });
125
+
126
+ it('emits 422 when using the EmptyMotdProvider constant', () => {
127
+ const state = makeState();
128
+ const ctx = makeCtx(state, EmptyMotdProvider);
129
+ const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
130
+ expect(out.effects).toEqual<EffectType[]>([
131
+ Effect.send('c1', [L(':irc.example.com 422 alice :MOTD File is missing')]),
132
+ ]);
133
+ });
134
+ });
135
+
136
+ // ============================================================================
137
+ // motdReducer — unregistered connection
138
+ // ============================================================================
139
+
140
+ describe('motdReducer — unregistered', () => {
141
+ it('addresses the reply to * when the connection has no nick', () => {
142
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
143
+ const ctx = makeCtx(state, new StaticMotdProvider(['hi']));
144
+ const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
145
+ expect(out.effects).toEqual<EffectType[]>([
146
+ Effect.send('c1', [
147
+ L(':irc.example.com 375 * :- irc.example.com Message of the day -'),
148
+ L(':irc.example.com 372 * :- hi'),
149
+ L(':irc.example.com 376 * :End of MOTD command'),
150
+ ]),
151
+ ]);
152
+ });
153
+ });
154
+
155
+ // ============================================================================
156
+ // motdReducer — long-line splitting (512-byte limit incl. tags / CR-LF)
157
+ // ============================================================================
158
+
159
+ describe('motdReducer — line splitting', () => {
160
+ /**
161
+ * Per RFC 1459 §2.3 the maximum IRC line length is 512 bytes including the
162
+ * trailing CR-LF. The 372 wire format is:
163
+ *
164
+ * ":<server> 372 <nick> :- <chunk>\r\n"
165
+ *
166
+ * The reducer must split over-long MOTD content lines so every emitted 372
167
+ * fits within the 512-byte budget.
168
+ */
169
+ it('splits a content line longer than the 372 budget into multiple 372 replies', () => {
170
+ const state = makeState();
171
+ // Overhead = ":" + "irc.example.com" + " 372 " + "alice" + " :- " + "\r\n"
172
+ // = 1 + 15 + 5 + 5 + 4 + 2 = 32 bytes.
173
+ // So the per-372 content budget is 512 - 32 = 480 chars.
174
+ // 500 chars of content → 2 chunks (480 + 20).
175
+ const long = 'A'.repeat(500);
176
+ const ctx = makeCtx(state, new StaticMotdProvider([long]));
177
+ const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
178
+
179
+ const send = out.effects[0];
180
+ expect(send?.tag).toBe('Send');
181
+ if (send?.tag === 'Send') {
182
+ const rpl372 = send.lines.filter((l) => l.text.split(' ')[1] === '372');
183
+ expect(rpl372).toHaveLength(2);
184
+ // First chunk: 480 chars of A.
185
+ expect(rpl372[0]?.text).toBe(`:irc.example.com 372 alice :- ${'A'.repeat(480)}`);
186
+ // Second chunk: remaining 20 chars.
187
+ expect(rpl372[1]?.text).toBe(`:irc.example.com 372 alice :- ${'A'.repeat(20)}`);
188
+ }
189
+ });
190
+
191
+ it('never emits a wire line longer than 510 chars (512 minus CR-LF)', () => {
192
+ const state = makeState();
193
+ const long = 'Z'.repeat(2_000);
194
+ const ctx = makeCtx(state, new StaticMotdProvider([long]));
195
+ const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
196
+
197
+ const send = out.effects[0];
198
+ if (send?.tag === 'Send') {
199
+ for (const line of send.lines) {
200
+ expect(line.text.length).toBeLessThanOrEqual(510);
201
+ }
202
+ }
203
+ });
204
+
205
+ it('still emits the 375 and 376 framing lines around split 372 chunks', () => {
206
+ const state = makeState();
207
+ const long = 'B'.repeat(1_000);
208
+ const ctx = makeCtx(state, new StaticMotdProvider([long]));
209
+ const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
210
+
211
+ const send = out.effects[0];
212
+ expect(send?.tag).toBe('Send');
213
+ if (send?.tag === 'Send') {
214
+ const codes = send.lines.map((l) => l.text.split(' ')[1]);
215
+ expect(codes[0]).toBe('375');
216
+ expect(codes[codes.length - 1]).toBe('376');
217
+ // Every middle code is a 372.
218
+ for (let i = 1; i < codes.length - 1; i++) {
219
+ expect(codes[i]).toBe('372');
220
+ }
221
+ }
222
+ });
223
+
224
+ it('emits a single empty 372 chunk for an empty MOTD line', () => {
225
+ const state = makeState();
226
+ const ctx = makeCtx(state, new StaticMotdProvider(['']));
227
+ const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
228
+
229
+ const send = out.effects[0];
230
+ if (send?.tag === 'Send') {
231
+ // Still exactly one 372 line for the empty input line.
232
+ const rpl372 = send.lines.filter((l) => l.text.split(' ')[1] === '372');
233
+ expect(rpl372).toHaveLength(1);
234
+ expect(rpl372[0]?.text).toBe(':irc.example.com 372 alice :- ');
235
+ }
236
+ });
237
+
238
+ it('still emits one 372 per line when server+nick exhaust the 372 budget', () => {
239
+ // Drive `maxMotdContentLen` to <= 0 by giving the connection a nick so long
240
+ // that the 372 line prefix exceeds 510 bytes. splitForMotd then returns
241
+ // [''] per input line — the reducer still emits one 372 per MOTD line,
242
+ // never silently dropping content.
243
+ const state = makeState();
244
+ state.nick = 'a'.repeat(600);
245
+ const ctx = makeCtx(state, new StaticMotdProvider(['hello']));
246
+ const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
247
+
248
+ const send = out.effects[0];
249
+ if (send?.tag === 'Send') {
250
+ const rpl372 = send.lines.filter((l) => l.text.split(' ')[1] === '372');
251
+ expect(rpl372).toHaveLength(1);
252
+ // Content is empty (the whole input got dropped because it cannot fit
253
+ // at all) — but the empty-chunk path was still taken.
254
+ expect(rpl372[0]?.text.endsWith(':- ')).toBe(true);
255
+ }
256
+ });
257
+ });
258
+
259
+ // ============================================================================
260
+ // motdReducer — state side-effects
261
+ // ============================================================================
262
+
263
+ describe('motdReducer — state', () => {
264
+ it('updates connection lastSeen to ctx.clock.now()', () => {
265
+ const state = makeState();
266
+ expect(state.lastSeen).toBe(0);
267
+ const clock = new FakeClock(7_777);
268
+ const ctx = makeCtx(state, new StaticMotdProvider(['hi']), clock);
269
+ const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
270
+ expect(out.state.lastSeen).toBe(7_777);
271
+ });
272
+
273
+ it('does not mutate any connection field other than lastSeen', () => {
274
+ const state = makeState();
275
+ state.caps.add('server-time');
276
+ state.joinedChannels.add('#foo');
277
+ state.userModes.invisible = true;
278
+
279
+ const ctx = makeCtx(state, new StaticMotdProvider(['hi']));
280
+ const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
281
+
282
+ expect(out.state.id).toBe('c1');
283
+ expect(out.state.nick).toBe('alice');
284
+ expect(out.state.user).toBe('alice');
285
+ expect(out.state.host).toBe('example.com');
286
+ expect(out.state.realname).toBe('Alice');
287
+ expect(out.state.caps).toEqual(new Set(['server-time']));
288
+ expect(out.state.joinedChannels).toEqual(new Set(['#foo']));
289
+ expect(out.state.userModes.invisible).toBe(true);
290
+ expect(out.state.registration).toBe('registered');
291
+ expect(out.state.connectedSince).toBe(0);
292
+ });
293
+
294
+ it('returns the same state reference (mutation permitted, no copy)', () => {
295
+ const state = makeState();
296
+ const ctx = makeCtx(state, new StaticMotdProvider(['hi']));
297
+ const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);
298
+ expect(out.state).toBe(state);
299
+ });
300
+ });
301
+
302
+ // ============================================================================
303
+ // motdReducer — case-insensitivity
304
+ // ============================================================================
305
+
306
+ describe('motdReducer — case-insensitivity', () => {
307
+ it('accepts a lower-case command token', () => {
308
+ const state = makeState();
309
+ const ctx = makeCtx(state, new StaticMotdProvider(['hi']));
310
+ const out = motdReducer(state, { command: 'motd', params: [], tags: {} }, ctx);
311
+ expect(out.effects).toHaveLength(1);
312
+ expect(out.effects[0]?.tag).toBe('Send');
313
+ });
314
+
315
+ it('accepts a mixed-case command token', () => {
316
+ const state = makeState();
317
+ const ctx = makeCtx(state, new StaticMotdProvider(['hi']));
318
+ const out = motdReducer(state, { command: 'MoTd', params: [], tags: {} }, ctx);
319
+ expect(out.effects).toHaveLength(1);
320
+ expect(out.effects[0]?.tag).toBe('Send');
321
+ });
322
+ });
@@ -0,0 +1,342 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { namesReducer } from '../../src/commands/names';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
6
+ import { type ChannelState, createChannel } from '../../src/state/channel';
7
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
8
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
9
+
10
+ const serverConfig: ServerConfig = {
11
+ serverName: 'irc.example.com',
12
+ networkName: 'ExampleNet',
13
+ maxChannelsPerUser: 30,
14
+ maxTargetsPerCommand: 10,
15
+ maxListEntries: 50,
16
+ nickLen: 30,
17
+ channelLen: 50,
18
+ topicLen: 390,
19
+ quitMessage: 'Client Quit',
20
+ };
21
+
22
+ function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {
23
+ return buildCtx({
24
+ serverConfig,
25
+ clock,
26
+ ids: new SequentialIdFactory(),
27
+ motd: EmptyMotdProvider,
28
+ connection: conn,
29
+ });
30
+ }
31
+
32
+ function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
33
+ const s = createConnection({ id, connectedSince: 0 });
34
+ s.nick = nick;
35
+ s.user = nick;
36
+ s.host = 'example.com';
37
+ s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);
38
+ s.registration = 'registered';
39
+ return s;
40
+ }
41
+
42
+ function makeChan(name = '#foo'): ChannelState {
43
+ return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });
44
+ }
45
+
46
+ function addMember(
47
+ chan: ChannelState,
48
+ connId: string,
49
+ nick: string,
50
+ op = false,
51
+ voice = false,
52
+ ): void {
53
+ chan.members.set(connId, { conn: connId, nick, op, voice });
54
+ }
55
+
56
+ const L = (text: string): RawLine => ({ text });
57
+
58
+ // ============================================================================
59
+ // namesReducer — success
60
+ // ============================================================================
61
+
62
+ describe('namesReducer — success', () => {
63
+ it('emits 353 RPL_NAMREPLY with the member list and 366 RPL_ENDOFNAMES', () => {
64
+ const chan = makeChan('#foo');
65
+ addMember(chan, 'c1', 'alice', true);
66
+ addMember(chan, 'c2', 'bob');
67
+ const conn = makeConn();
68
+ const ctx = makeCtx(conn);
69
+
70
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
71
+
72
+ expect(out.effects).toEqual<EffectType[]>([
73
+ Effect.send('c1', [
74
+ L(':irc.example.com 353 alice = #foo :@alice bob'),
75
+ L(':irc.example.com 366 alice #foo :End of /NAMES list.'),
76
+ ]),
77
+ ]);
78
+ });
79
+
80
+ it('emits a 353 with an empty names list when the channel has no members', () => {
81
+ const chan = makeChan('#foo');
82
+ // alice is a member so the visibility check passes, but no other members.
83
+ addMember(chan, 'c1', 'alice');
84
+ const conn = makeConn();
85
+ const ctx = makeCtx(conn);
86
+
87
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
88
+
89
+ expect(out.effects).toEqual<EffectType[]>([
90
+ Effect.send('c1', [
91
+ L(':irc.example.com 353 alice = #foo :alice'),
92
+ L(':irc.example.com 366 alice #foo :End of /NAMES list.'),
93
+ ]),
94
+ ]);
95
+ });
96
+
97
+ it('uses * sigil in 353 when the channel is +s (secret)', () => {
98
+ const chan = makeChan('#foo');
99
+ chan.modes.secret = true;
100
+ addMember(chan, 'c1', 'alice');
101
+ const conn = makeConn();
102
+ const ctx = makeCtx(conn);
103
+
104
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
105
+
106
+ const send = out.effects[0];
107
+ expect(send?.tag).toBe('Send');
108
+ if (send?.tag === 'Send') {
109
+ expect(send.lines[0]?.text).toBe(':irc.example.com 353 alice * #foo :alice');
110
+ }
111
+ });
112
+
113
+ it('uses @ sigil in 353 when the channel is +p (private)', () => {
114
+ const chan = makeChan('#foo');
115
+ chan.modes.private = true;
116
+ addMember(chan, 'c1', 'alice');
117
+ const conn = makeConn();
118
+ const ctx = makeCtx(conn);
119
+
120
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
121
+
122
+ const send = out.effects[0];
123
+ if (send?.tag === 'Send') {
124
+ expect(send.lines[0]?.text).toBe(':irc.example.com 353 alice @ #foo :alice');
125
+ }
126
+ });
127
+
128
+ it('shows only the highest-priority prefix when multi-prefix is not negotiated', () => {
129
+ const chan = makeChan('#foo');
130
+ addMember(chan, 'c1', 'alice');
131
+ addMember(chan, 'c2', 'bob', true, true); // op AND voice
132
+ addMember(chan, 'c3', 'carol', false, true); // voiced but NOT op
133
+ const conn = makeConn();
134
+ const ctx = makeCtx(conn);
135
+ // No multi-prefix cap.
136
+
137
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
138
+
139
+ const send = out.effects[0];
140
+ expect(send?.tag).toBe('Send');
141
+ if (send?.tag === 'Send') {
142
+ // bob has both @ and + but only @ is shown without multi-prefix.
143
+ // carol has only +; that is shown as her highest prefix.
144
+ // Order matches roster iteration (insertion order); assert substrings.
145
+ const namesLine = send.lines[0]?.text ?? '';
146
+ expect(namesLine).toContain('@bob');
147
+ expect(namesLine).not.toContain('+bob');
148
+ expect(namesLine).toContain('+carol');
149
+ expect(namesLine).toContain('alice');
150
+ }
151
+ });
152
+
153
+ it('shows all applicable prefixes when multi-prefix is negotiated', () => {
154
+ const chan = makeChan('#foo');
155
+ addMember(chan, 'c1', 'alice');
156
+ addMember(chan, 'c2', 'bob', true, true); // op AND voice
157
+ const conn = makeConn();
158
+ conn.caps.add('multi-prefix');
159
+ const ctx = makeCtx(conn);
160
+
161
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
162
+
163
+ const send = out.effects[0];
164
+ if (send?.tag === 'Send') {
165
+ // bob has both @ and +; both shown with multi-prefix.
166
+ const namesLine = send.lines[0]?.text ?? '';
167
+ expect(namesLine).toContain('@+bob');
168
+ expect(namesLine).toContain('alice');
169
+ }
170
+ });
171
+
172
+ it('updates the connection lastSeen to ctx.clock.now()', () => {
173
+ const chan = makeChan('#foo');
174
+ addMember(chan, 'c1', 'alice');
175
+ const conn = makeConn();
176
+ const clock = new FakeClock(4_200);
177
+ const ctx = makeCtx(conn, clock);
178
+
179
+ namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
180
+
181
+ expect(conn.lastSeen).toBe(4_200);
182
+ });
183
+
184
+ it('returns the same state reference (read-only reducer)', () => {
185
+ const chan = makeChan('#foo');
186
+ addMember(chan, 'c1', 'alice');
187
+ const conn = makeConn();
188
+ const ctx = makeCtx(conn);
189
+
190
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
191
+
192
+ expect(out.state).toBe(chan);
193
+ });
194
+ });
195
+
196
+ // ============================================================================
197
+ // namesReducer — rejections
198
+ // ============================================================================
199
+
200
+ describe('namesReducer — rejections', () => {
201
+ it('emits 461 ERR_NEEDMOREPARAMS only when no channel is supplied AND the reducer is invoked directly', () => {
202
+ // The reducer is invoked per-channel by the actor layer; if it is invoked
203
+ // with no params at all that is a programming error in the actor layer,
204
+ // but we still emit 461 for safety.
205
+ const chan = makeChan('#foo');
206
+ addMember(chan, 'c1', 'alice');
207
+ const conn = makeConn();
208
+ const ctx = makeCtx(conn);
209
+
210
+ const out = namesReducer(chan, { command: 'NAMES', params: [], tags: {} }, ctx);
211
+
212
+ expect(out.effects).toEqual<EffectType[]>([
213
+ Effect.send('c1', [L(':irc.example.com 461 alice NAMES :Not enough parameters')]),
214
+ ]);
215
+ });
216
+
217
+ it('emits 403 ERR_NOSUCHCHANNEL for a channel name without a valid prefix', () => {
218
+ const chan = makeChan('foo');
219
+ const conn = makeConn();
220
+ const ctx = makeCtx(conn);
221
+
222
+ const out = namesReducer(chan, { command: 'NAMES', params: ['foo'], tags: {} }, ctx);
223
+
224
+ expect(out.effects).toEqual<EffectType[]>([
225
+ Effect.send('c1', [L(':irc.example.com 403 alice foo :No such channel')]),
226
+ ]);
227
+ });
228
+
229
+ it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing a comma', () => {
230
+ const chan = makeChan('#foo');
231
+ const conn = makeConn();
232
+ const ctx = makeCtx(conn);
233
+
234
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo,bar'], tags: {} }, ctx);
235
+
236
+ expect(out.effects).toEqual<EffectType[]>([
237
+ Effect.send('c1', [L(':irc.example.com 403 alice #foo,bar :No such channel')]),
238
+ ]);
239
+ });
240
+
241
+ it('emits 403 ERR_NOSUCHCHANNEL for an empty channel name', () => {
242
+ const chan = makeChan('#foo');
243
+ const conn = makeConn();
244
+ const ctx = makeCtx(conn);
245
+
246
+ const out = namesReducer(chan, { command: 'NAMES', params: [''], tags: {} }, ctx);
247
+
248
+ expect(out.effects).toEqual<EffectType[]>([
249
+ Effect.send('c1', [L(':irc.example.com 403 alice :No such channel')]),
250
+ ]);
251
+ });
252
+
253
+ it('emits 442 ERR_NOTONCHANNEL when listing a +s channel the connection has not joined', () => {
254
+ const chan = makeChan('#secret');
255
+ chan.modes.secret = true;
256
+ addMember(chan, 'c2', 'bob');
257
+ const conn = makeConn();
258
+ const ctx = makeCtx(conn);
259
+
260
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#secret'], tags: {} }, ctx);
261
+
262
+ expect(out.effects).toEqual<EffectType[]>([
263
+ Effect.send('c1', [L(":irc.example.com 442 alice #secret :You're not on that channel")]),
264
+ ]);
265
+ });
266
+
267
+ it('emits 442 ERR_NOTONCHANNEL when listing a +p channel the connection has not joined', () => {
268
+ const chan = makeChan('#priv');
269
+ chan.modes.private = true;
270
+ addMember(chan, 'c2', 'bob');
271
+ const conn = makeConn();
272
+ const ctx = makeCtx(conn);
273
+
274
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#priv'], tags: {} }, ctx);
275
+
276
+ expect(out.effects).toEqual<EffectType[]>([
277
+ Effect.send('c1', [L(":irc.example.com 442 alice #priv :You're not on that channel")]),
278
+ ]);
279
+ });
280
+
281
+ it('allows a member to list a +s channel', () => {
282
+ const chan = makeChan('#secret');
283
+ chan.modes.secret = true;
284
+ addMember(chan, 'c1', 'alice');
285
+ addMember(chan, 'c2', 'bob');
286
+ const conn = makeConn();
287
+ const ctx = makeCtx(conn);
288
+
289
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#secret'], tags: {} }, ctx);
290
+
291
+ const send = out.effects[0];
292
+ expect(send?.tag).toBe('Send');
293
+ if (send?.tag === 'Send') {
294
+ expect(send.lines[0]?.text).toBe(':irc.example.com 353 alice * #secret :alice bob');
295
+ }
296
+ });
297
+
298
+ it('allows a non-member to list a regular (non-+s, non-+p) channel', () => {
299
+ const chan = makeChan('#foo');
300
+ addMember(chan, 'c2', 'bob');
301
+ const conn = makeConn();
302
+ const ctx = makeCtx(conn);
303
+
304
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
305
+
306
+ const send = out.effects[0];
307
+ expect(send?.tag).toBe('Send');
308
+ if (send?.tag === 'Send') {
309
+ expect(send.lines[0]?.text).toBe(':irc.example.com 353 alice = #foo :bob');
310
+ }
311
+ });
312
+
313
+ it('uses * in numeric replies when the connection has no nick (defensive)', () => {
314
+ const chan = makeChan('#foo');
315
+ addMember(chan, 'c1', 'alice');
316
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
317
+ const ctx = makeCtx(conn);
318
+
319
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
320
+
321
+ expect(out.effects).toEqual<EffectType[]>([
322
+ Effect.send('c1', [
323
+ L(':irc.example.com 353 * = #foo :alice'),
324
+ L(':irc.example.com 366 * #foo :End of /NAMES list.'),
325
+ ]),
326
+ ]);
327
+ });
328
+
329
+ it('uses * in error numeric replies when the connection has no nick (defensive)', () => {
330
+ const chan = makeChan('#secret');
331
+ chan.modes.secret = true;
332
+ addMember(chan, 'c2', 'bob');
333
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
334
+ const ctx = makeCtx(conn);
335
+
336
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#secret'], tags: {} }, ctx);
337
+
338
+ expect(out.effects).toEqual<EffectType[]>([
339
+ Effect.send('c1', [L(":irc.example.com 442 * #secret :You're not on that channel")]),
340
+ ]);
341
+ });
342
+ });