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,502 @@
1
+ import {
2
+ type ChannelDelta,
3
+ type ConnectionState,
4
+ FakeClock,
5
+ type RawLine,
6
+ createConnection,
7
+ } from '@serverless-ircd/irc-core';
8
+ import { describe, expect, it } from 'vitest';
9
+ import { InMemoryRuntime } from '../src/in-memory-runtime';
10
+
11
+ const L = (text: string): RawLine => ({ text });
12
+
13
+ function makeConn(id: string, nick?: string): ConnectionState {
14
+ const s = createConnection({ id, connectedSince: 0 });
15
+ if (nick !== undefined) {
16
+ s.nick = nick;
17
+ s.user = nick.toLowerCase();
18
+ s.host = 'example.com';
19
+ s.realname = nick;
20
+ s.registration = 'registered';
21
+ }
22
+ return s;
23
+ }
24
+
25
+ describe('InMemoryRuntime — reserveNick', () => {
26
+ it('returns ok:true the first time a nick is reserved', async () => {
27
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
28
+ rt.registerConnection(makeConn('c1'), { send: () => {}, disconnect: () => {} });
29
+
30
+ const result = await rt.reserveNick('alice', 'c1');
31
+ expect(result).toEqual({ ok: true });
32
+ });
33
+
34
+ it('returns ok:false when the nick is already taken by another connection', async () => {
35
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
36
+ rt.registerConnection(makeConn('c1'), { send: () => {}, disconnect: () => {} });
37
+ rt.registerConnection(makeConn('c2'), { send: () => {}, disconnect: () => {} });
38
+
39
+ await rt.reserveNick('alice', 'c1');
40
+ const second = await rt.reserveNick('alice', 'c2');
41
+ expect(second).toEqual({ ok: false });
42
+ });
43
+
44
+ it('treats nick names case-insensitively (Alice vs alice collide)', async () => {
45
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
46
+ rt.registerConnection(makeConn('c1'), { send: () => {}, disconnect: () => {} });
47
+ rt.registerConnection(makeConn('c2'), { send: () => {}, disconnect: () => {} });
48
+
49
+ await rt.reserveNick('Alice', 'c1');
50
+ const second = await rt.reserveNick('alice', 'c2');
51
+ expect(second).toEqual({ ok: false });
52
+ });
53
+
54
+ it('allows the same connection to re-reserve its own nick (idempotent)', async () => {
55
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
56
+ rt.registerConnection(makeConn('c1'), { send: () => {}, disconnect: () => {} });
57
+
58
+ await rt.reserveNick('alice', 'c1');
59
+ const second = await rt.reserveNick('alice', 'c1');
60
+ expect(second).toEqual({ ok: true });
61
+ });
62
+
63
+ it('is race-free under interleaved concurrent reservations for the same nick', async () => {
64
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
65
+ rt.registerConnection(makeConn('c1'), { send: () => {}, disconnect: () => {} });
66
+ rt.registerConnection(makeConn('c2'), { send: () => {}, disconnect: () => {} });
67
+
68
+ // Fire both reservations without awaiting between them.
69
+ const [r1, r2] = await Promise.all([
70
+ rt.reserveNick('alice', 'c1'),
71
+ rt.reserveNick('alice', 'c2'),
72
+ ]);
73
+
74
+ // Exactly one wins.
75
+ const oks = [r1, r2].filter((r) => r.ok).length;
76
+ expect(oks).toBe(1);
77
+ });
78
+
79
+ it('stress test: 1000 concurrent reservations for one nick yield exactly one winner', async () => {
80
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
81
+ const N = 1000;
82
+ for (let i = 0; i < N; i++) {
83
+ const id = `c${i}`;
84
+ rt.registerConnection(makeConn(id), { send: () => {}, disconnect: () => {} });
85
+ }
86
+
87
+ const results = await Promise.all(
88
+ Array.from({ length: N }, (_, i) => rt.reserveNick('alice', `c${i}`)),
89
+ );
90
+ const winners = results.filter((r) => r.ok).length;
91
+ expect(winners).toBe(1);
92
+
93
+ // The single winner is the owner of record in the registry.
94
+ const owner = await rt.lookupNick('alice');
95
+ expect(owner).not.toBeNull();
96
+ expect(owner).toBe(`c${results.findIndex((r) => r.ok)}`);
97
+ });
98
+ });
99
+
100
+ describe('InMemoryRuntime — changeNick', () => {
101
+ it('returns true and updates the registry when the new nick is free', async () => {
102
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
103
+ rt.registerConnection(makeConn('c1'), { send: () => {}, disconnect: () => {} });
104
+
105
+ await rt.reserveNick('alice', 'c1');
106
+ const ok = await rt.changeNick('c1', 'alice', 'bob');
107
+ expect(ok).toBe(true);
108
+ expect(await rt.lookupNick('alice')).toBeNull();
109
+ expect(await rt.lookupNick('bob')).toBe('c1');
110
+ });
111
+
112
+ it('returns false and leaves the old mapping when the new nick is taken', async () => {
113
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
114
+ rt.registerConnection(makeConn('c1'), { send: () => {}, disconnect: () => {} });
115
+ rt.registerConnection(makeConn('c2'), { send: () => {}, disconnect: () => {} });
116
+
117
+ await rt.reserveNick('alice', 'c1');
118
+ await rt.reserveNick('bob', 'c2');
119
+
120
+ const ok = await rt.changeNick('c1', 'alice', 'bob');
121
+ expect(ok).toBe(false);
122
+ expect(await rt.lookupNick('alice')).toBe('c1');
123
+ expect(await rt.lookupNick('bob')).toBe('c2');
124
+ });
125
+ });
126
+
127
+ describe('InMemoryRuntime — releaseNick', () => {
128
+ it('removes the nick from the registry so it can be re-reserved', async () => {
129
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
130
+ rt.registerConnection(makeConn('c1'), { send: () => {}, disconnect: () => {} });
131
+ rt.registerConnection(makeConn('c2'), { send: () => {}, disconnect: () => {} });
132
+
133
+ await rt.reserveNick('alice', 'c1');
134
+ await rt.releaseNick('alice');
135
+ const second = await rt.reserveNick('alice', 'c2');
136
+ expect(second).toEqual({ ok: true });
137
+ });
138
+
139
+ it('is a no-op for a nick that was never reserved', async () => {
140
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
141
+ await expect(rt.releaseNick('ghost')).resolves.toBeUndefined();
142
+ });
143
+ });
144
+
145
+ describe('InMemoryRuntime — lookupNick', () => {
146
+ it('returns the connection id owning the nick', async () => {
147
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
148
+ rt.registerConnection(makeConn('c1'), { send: () => {}, disconnect: () => {} });
149
+ await rt.reserveNick('alice', 'c1');
150
+
151
+ expect(await rt.lookupNick('alice')).toBe('c1');
152
+ });
153
+
154
+ it('is case-insensitive', async () => {
155
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
156
+ rt.registerConnection(makeConn('c1'), { send: () => {}, disconnect: () => {} });
157
+ await rt.reserveNick('Alice', 'c1');
158
+
159
+ expect(await rt.lookupNick('alice')).toBe('c1');
160
+ expect(await rt.lookupNick('ALICE')).toBe('c1');
161
+ });
162
+
163
+ it('returns null when no connection owns the nick', async () => {
164
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
165
+ expect(await rt.lookupNick('nobody')).toBeNull();
166
+ });
167
+ });
168
+
169
+ describe('InMemoryRuntime — getConnectionInfo', () => {
170
+ it('returns a snapshot of a registered connection', async () => {
171
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
172
+ const conn = makeConn('c1', 'alice');
173
+ rt.registerConnection(conn, { send: () => {}, disconnect: () => {} });
174
+
175
+ const snap = await rt.getConnectionInfo('c1');
176
+ expect(snap).not.toBeNull();
177
+ expect(snap?.id).toBe('c1');
178
+ expect(snap?.nick).toBe('alice');
179
+ expect(snap?.hostmask).toBe('alice!alice@example.com');
180
+ });
181
+
182
+ it('reflects subsequent mutations to the live state object', async () => {
183
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
184
+ const conn = makeConn('c1');
185
+ rt.registerConnection(conn, { send: () => {}, disconnect: () => {} });
186
+
187
+ let snap = await rt.getConnectionInfo('c1');
188
+ expect(snap?.nick).toBeUndefined();
189
+
190
+ conn.nick = 'alice';
191
+ snap = await rt.getConnectionInfo('c1');
192
+ expect(snap?.nick).toBe('alice');
193
+ });
194
+
195
+ it('returns null for an unknown connection', async () => {
196
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
197
+ expect(await rt.getConnectionInfo('ghost')).toBeNull();
198
+ });
199
+ });
200
+
201
+ describe('InMemoryRuntime — applyChannelDelta / getChannelSnapshot', () => {
202
+ it('creates a channel on demand when a delta arrives for it', async () => {
203
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
204
+ const delta: ChannelDelta = {
205
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
206
+ };
207
+ await rt.applyChannelDelta('#foo', delta);
208
+
209
+ const snap = await rt.getChannelSnapshot('#foo');
210
+ expect(snap).not.toBeNull();
211
+ expect(snap?.name).toBe('#foo');
212
+ expect(snap?.nameLower).toBe('#foo');
213
+ expect(snap?.members.size).toBe(1);
214
+ expect(snap?.members.get('c1')?.nick).toBe('alice');
215
+ });
216
+
217
+ it('is case-insensitive on the channel name (lookup and dedup)', async () => {
218
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
219
+ await rt.applyChannelDelta('#Foo', {
220
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
221
+ });
222
+ const snap = await rt.getChannelSnapshot('#foo');
223
+ expect(snap).not.toBeNull();
224
+ expect(snap?.name).toBe('#Foo'); // original case preserved
225
+ expect(snap?.members.has('c1')).toBe(true);
226
+ });
227
+
228
+ it('returns null from getChannelSnapshot for an unknown channel', async () => {
229
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
230
+ expect(await rt.getChannelSnapshot('#nope')).toBeNull();
231
+ });
232
+
233
+ it('applies mode and topic deltas alongside memberships', async () => {
234
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
235
+ await rt.applyChannelDelta('#foo', {
236
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice', op: true }],
237
+ modeChanges: [{ mode: 'topicLock', set: true }],
238
+ topic: { text: 'hi', setter: 'alice', setAt: 100 },
239
+ });
240
+
241
+ const snap = await rt.getChannelSnapshot('#foo');
242
+ expect(snap?.modes.topicLock).toBe(true);
243
+ expect(snap?.topic).toEqual({ text: 'hi', setter: 'alice', setAt: 100 });
244
+ expect(snap?.members.get('c1')?.op).toBe(true);
245
+ });
246
+ });
247
+
248
+ describe('InMemoryRuntime — send', () => {
249
+ it('delivers lines to the target connection send handler', async () => {
250
+ const sent: RawLine[][] = [];
251
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
252
+ rt.registerConnection(makeConn('c1'), {
253
+ send: (lines) => {
254
+ sent.push(lines);
255
+ },
256
+ disconnect: () => {},
257
+ });
258
+
259
+ await rt.send('c1', [L('PONG :tok')]);
260
+ expect(sent).toEqual([[{ text: 'PONG :tok' }]]);
261
+ });
262
+
263
+ it('silently drops sends to unknown connections', async () => {
264
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
265
+ await expect(rt.send('ghost', [L('hi')])).resolves.toBeUndefined();
266
+ });
267
+ });
268
+
269
+ describe('InMemoryRuntime — broadcast', () => {
270
+ it('delivers lines to every member of the channel', async () => {
271
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
272
+ const received: Record<string, RawLine[][]> = { c1: [], c2: [], c3: [] };
273
+ const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
274
+ for (const id of ['c1', 'c2', 'c3']) {
275
+ rt.registerConnection(makeConn(id, id === 'c1' ? 'alice' : `nick_${id}`), {
276
+ send: push(id),
277
+ disconnect: () => {},
278
+ });
279
+ }
280
+ await rt.applyChannelDelta('#foo', {
281
+ memberships: [
282
+ { type: 'add', conn: 'c1', nick: 'alice' },
283
+ { type: 'add', conn: 'c2', nick: 'nick_c2' },
284
+ { type: 'add', conn: 'c3', nick: 'nick_c3' },
285
+ ],
286
+ });
287
+
288
+ await rt.broadcast('#foo', [L(':alice PRIVMSG #foo :hi')]);
289
+ expect(received.c1).toHaveLength(1);
290
+ expect(received.c2).toHaveLength(1);
291
+ expect(received.c3).toHaveLength(1);
292
+ });
293
+
294
+ it('skips the except connection when supplied', async () => {
295
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
296
+ const received: Record<string, RawLine[][]> = { c1: [], c2: [], c3: [] };
297
+ const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
298
+ for (const id of ['c1', 'c2', 'c3']) {
299
+ rt.registerConnection(makeConn(id, `nick_${id}`), {
300
+ send: push(id),
301
+ disconnect: () => {},
302
+ });
303
+ }
304
+ await rt.applyChannelDelta('#foo', {
305
+ memberships: [
306
+ { type: 'add', conn: 'c1', nick: 'nick_c1' },
307
+ { type: 'add', conn: 'c2', nick: 'nick_c2' },
308
+ { type: 'add', conn: 'c3', nick: 'nick_c3' },
309
+ ],
310
+ });
311
+
312
+ await rt.broadcast('#foo', [L('hi')], 'c2');
313
+ expect(received.c1).toHaveLength(1);
314
+ expect(received.c2).toHaveLength(0);
315
+ expect(received.c3).toHaveLength(1);
316
+ });
317
+
318
+ it('broadcasts to all members when except is omitted', async () => {
319
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
320
+ const received: Record<string, number> = { c1: 0, c2: 0 };
321
+ const bump = (id: string) => () => {
322
+ received[id] = (received[id] ?? 0) + 1;
323
+ };
324
+ for (const id of ['c1', 'c2']) {
325
+ rt.registerConnection(makeConn(id, `nick_${id}`), {
326
+ send: bump(id),
327
+ disconnect: () => {},
328
+ });
329
+ }
330
+ await rt.applyChannelDelta('#foo', {
331
+ memberships: [
332
+ { type: 'add', conn: 'c1', nick: 'nick_c1' },
333
+ { type: 'add', conn: 'c2', nick: 'nick_c2' },
334
+ ],
335
+ });
336
+
337
+ await rt.broadcast('#foo', [L('hi')]);
338
+ expect(received).toEqual({ c1: 1, c2: 1 });
339
+ });
340
+
341
+ it('silently drops delivery to members whose connection has gone', async () => {
342
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
343
+ let c2Received = 0;
344
+ rt.registerConnection(makeConn('c1', 'alice'), {
345
+ send: () => {},
346
+ disconnect: () => {},
347
+ });
348
+ rt.registerConnection(makeConn('c2', 'bob'), {
349
+ send: () => {
350
+ c2Received++;
351
+ },
352
+ disconnect: () => {},
353
+ });
354
+ await rt.applyChannelDelta('#foo', {
355
+ memberships: [
356
+ { type: 'add', conn: 'c1', nick: 'alice' },
357
+ { type: 'add', conn: 'c2', nick: 'bob' },
358
+ ],
359
+ });
360
+
361
+ // c2 vanishes without the channel being updated.
362
+ rt.unregisterConnection('c2');
363
+ await rt.broadcast('#foo', [L('hi')]);
364
+ expect(c2Received).toBe(0);
365
+ });
366
+
367
+ it('is a no-op for a channel that does not exist', async () => {
368
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
369
+ await expect(rt.broadcast('#nope', [L('hi')])).resolves.toBeUndefined();
370
+ });
371
+ });
372
+
373
+ describe('InMemoryRuntime — sendToNick', () => {
374
+ it('routes lines to the connection that owns the nick', async () => {
375
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
376
+ const sent: RawLine[][] = [];
377
+ rt.registerConnection(makeConn('c1', 'alice'), {
378
+ send: (lines) => {
379
+ sent.push(lines);
380
+ },
381
+ disconnect: () => {},
382
+ });
383
+ rt.registerConnection(makeConn('c2', 'bob'), { send: () => {}, disconnect: () => {} });
384
+ await rt.reserveNick('alice', 'c1');
385
+
386
+ await rt.sendToNick('alice', 'c2', [L(':bob PRIVMSG alice :hi')]);
387
+ expect(sent).toHaveLength(1);
388
+ expect(sent[0]).toEqual([{ text: ':bob PRIVMSG alice :hi' }]);
389
+ });
390
+
391
+ it('delivers notFoundLines to the sender when the nick is offline', async () => {
392
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
393
+ const senderOut: RawLine[][] = [];
394
+ rt.registerConnection(makeConn('c1', 'bob'), {
395
+ send: (lines) => {
396
+ senderOut.push(lines);
397
+ },
398
+ disconnect: () => {},
399
+ });
400
+
401
+ await rt.sendToNick('ghost', 'c1', [L('hi')], [L('401 bob ghost :No such nick')]);
402
+ expect(senderOut).toEqual([[{ text: '401 bob ghost :No such nick' }]]);
403
+ });
404
+
405
+ it('silently drops when the nick is offline and no notFoundLines were supplied', async () => {
406
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
407
+ const senderOut: RawLine[][] = [];
408
+ rt.registerConnection(makeConn('c1', 'bob'), {
409
+ send: (lines) => {
410
+ senderOut.push(lines);
411
+ },
412
+ disconnect: () => {},
413
+ });
414
+
415
+ await rt.sendToNick('ghost', 'c1', [L('hi')]);
416
+ expect(senderOut).toEqual([]);
417
+ });
418
+
419
+ it('drops silently when the sender itself is gone (cannot deliver notFoundLines)', async () => {
420
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
421
+ await expect(rt.sendToNick('ghost', 'c1', [L('hi')], [L('401')])).resolves.toBeUndefined();
422
+ });
423
+ });
424
+
425
+ describe('InMemoryRuntime — disconnect', () => {
426
+ it('invokes the registered disconnect handler with the reason', async () => {
427
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
428
+ let disconnected: string | undefined;
429
+ rt.registerConnection(makeConn('c1', 'alice'), {
430
+ send: () => {},
431
+ disconnect: (reason) => {
432
+ disconnected = reason;
433
+ },
434
+ });
435
+
436
+ await rt.disconnect('c1', 'Excess Flood');
437
+ expect(disconnected).toBe('Excess Flood');
438
+ });
439
+
440
+ it('passes undefined reason through unchanged', async () => {
441
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
442
+ let disconnected: string | undefined = 'unset';
443
+ rt.registerConnection(makeConn('c1', 'alice'), {
444
+ send: () => {},
445
+ disconnect: (reason) => {
446
+ disconnected = reason;
447
+ },
448
+ });
449
+
450
+ await rt.disconnect('c1');
451
+ expect(disconnected).toBeUndefined();
452
+ });
453
+
454
+ it('is a no-op for an unknown connection', async () => {
455
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
456
+ await expect(rt.disconnect('ghost')).resolves.toBeUndefined();
457
+ });
458
+ });
459
+
460
+ describe('InMemoryRuntime — connection lifecycle', () => {
461
+ it('registerConnection tracks a connection; unregisterConnection removes it', async () => {
462
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
463
+ rt.registerConnection(makeConn('c1', 'alice'), { send: () => {}, disconnect: () => {} });
464
+ expect(rt.hasConnection('c1')).toBe(true);
465
+
466
+ rt.unregisterConnection('c1');
467
+ expect(rt.hasConnection('c1')).toBe(false);
468
+ expect(await rt.getConnectionInfo('c1')).toBeNull();
469
+ });
470
+
471
+ it('unregisterConnection is idempotent', () => {
472
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
473
+ rt.registerConnection(makeConn('c1'), { send: () => {}, disconnect: () => {} });
474
+ rt.unregisterConnection('c1');
475
+ expect(() => rt.unregisterConnection('c1')).not.toThrow();
476
+ });
477
+
478
+ it('getConnection returns the live state object that reducers mutate in place', async () => {
479
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
480
+ const conn = makeConn('c1');
481
+ rt.registerConnection(conn, { send: () => {}, disconnect: () => {} });
482
+
483
+ const live = await rt.getConnection('c1');
484
+ expect(live).toBe(conn);
485
+ });
486
+
487
+ it('getConnection returns null for an unknown connection', async () => {
488
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
489
+ expect(await rt.getConnection('ghost')).toBeNull();
490
+ });
491
+
492
+ it('getOrCreateChannel creates a fresh channel keyed by nameLower on first access', () => {
493
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
494
+ const chan = rt.getOrCreateChannel('#Foo');
495
+ expect(chan.name).toBe('#Foo');
496
+ expect(chan.nameLower).toBe('#foo');
497
+ expect(chan.members.size).toBe(0);
498
+
499
+ // Second access returns the same object.
500
+ expect(rt.getOrCreateChannel('#foo')).toBe(chan);
501
+ });
502
+ });
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "rootDir": "./src",
6
+ "outDir": "./dist",
7
+ "tsBuildInfoFile": "./.tsbuildinfo",
8
+ "types": []
9
+ },
10
+ "include": ["src/**/*.ts"],
11
+ "exclude": ["dist", "tests", "**/*.test.ts"],
12
+ "references": [
13
+ { "path": "../irc-core/tsconfig.build.json" },
14
+ { "path": "../irc-server/tsconfig.build.json" }
15
+ ]
16
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": [],
6
+ "composite": false
7
+ },
8
+ "include": ["src/**/*.ts", "tests/**/*.ts"],
9
+ "exclude": ["dist"],
10
+ "references": [
11
+ { "path": "../irc-core/tsconfig.build.json" },
12
+ { "path": "../irc-server/tsconfig.build.json" }
13
+ ]
14
+ }
@@ -0,0 +1,21 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['tests/**/*.test.ts'],
7
+ coverage: {
8
+ provider: 'v8',
9
+ all: false,
10
+ include: ['src/**/*.ts'],
11
+ exclude: ['src/**/index.ts'],
12
+ reporter: ['text', 'html', 'json-summary'],
13
+ thresholds: {
14
+ lines: 100,
15
+ functions: 100,
16
+ branches: 100,
17
+ statements: 100,
18
+ },
19
+ },
20
+ },
21
+ });
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@serverless-ircd/irc-core",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Platform-agnostic IRC protocol core: parser, serializer, command reducers, state shapes",
6
+ "license": "BSD-3-Clause",
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ },
15
+ "./protocol": {
16
+ "types": "./dist/protocol/index.d.ts",
17
+ "import": "./dist/protocol/index.js"
18
+ }
19
+ },
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.build.json",
22
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest",
25
+ "coverage": "vitest run --coverage",
26
+ "clean": "rimraf dist coverage .tsbuildinfo .turbo"
27
+ },
28
+ "files": ["dist", "src", "README.md"]
29
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * IRCv3 capability table (data only).
3
+ *
4
+ * The CAP *negotiation reducer* ships separately; this module is the
5
+ * pure-data table that advertises what caps this server supports. PLAN §3
6
+ * lists the v1 cap set; later tickets implement each cap's behavior.
7
+ *
8
+ * The table is exposed as a frozen, sorted list so the `CAP LS` response
9
+ * (and any future `CAP NEW` push) serializes deterministically.
10
+ */
11
+
12
+ /** One advertised IRCv3 capability. */
13
+ export interface Capability {
14
+ /** Wire name (`server-time`, `sasl`, …). */
15
+ name: string;
16
+ /** Optional value (`PLAIN,EXTERNAL` for `sasl`); bare name when absent. */
17
+ value?: string;
18
+ }
19
+
20
+ /**
21
+ * Every cap advertised by this server in PLAN §3, in canonical (alpha) order
22
+ * so wire output is deterministic across runs.
23
+ */
24
+ export const SUPPORTED_CAPABILITIES: ReadonlyArray<Capability> = Object.freeze([
25
+ { name: 'account-tag' },
26
+ { name: 'away-notify' },
27
+ { name: 'batch' },
28
+ { name: 'chghost' },
29
+ { name: 'echo-message' },
30
+ { name: 'extended-join' },
31
+ { name: 'invite-notify' },
32
+ { name: 'message-tags' },
33
+ { name: 'multi-prefix' },
34
+ { name: 'safelist' },
35
+ { name: 'sasl', value: 'PLAIN,EXTERNAL' },
36
+ { name: 'server-time' },
37
+ ]);
38
+
39
+ /** Set form for fast membership checks during `CAP REQ`. */
40
+ export const SUPPORTED_CAP_NAMES: ReadonlySet<string> = new Set<string>(
41
+ SUPPORTED_CAPABILITIES.map((c) => c.name),
42
+ );
43
+
44
+ /** Returns true iff `name` is a supported cap (case-sensitive). */
45
+ export function isSupportedCap(name: string): boolean {
46
+ return SUPPORTED_CAP_NAMES.has(name);
47
+ }
48
+
49
+ /**
50
+ * Serializes one capability to its `CAP LS` token form:
51
+ * `name` when no value, `name=value` when present.
52
+ */
53
+ export function capToLsString(cap: Capability): string {
54
+ return cap.value === undefined ? cap.name : `${cap.name}=${cap.value}`;
55
+ }
56
+
57
+ /**
58
+ * Returns the full `CAP LS` payload (space-separated tokens) for every
59
+ * supported cap, in {@link SUPPORTED_CAPABILITIES} order.
60
+ */
61
+ export function getLsString(): string {
62
+ return SUPPORTED_CAPABILITIES.map(capToLsString).join(' ');
63
+ }
64
+
65
+ /**
66
+ * Splits a list of cap tokens into wire lines that each fit within
67
+ * `maxBytes` (the per-line content budget, NOT the full 512 — callers
68
+ * subtract the `:server CAP nick LS :` overhead before passing).
69
+ *
70
+ * Packing is greedy: each line is filled until the next token would
71
+ * overflow the budget. A token longer than the budget gets its own line
72
+ * (still emitted — never dropped).
73
+ *
74
+ * Returns `['']` for an empty token list so the wire layer always has at
75
+ * least one LS line to send (IRC clients expect a trailing `:*`).
76
+ */
77
+ export function splitCapsForWire(tokens: readonly string[], maxBytes: number): string[] {
78
+ if (tokens.length === 0) return [''];
79
+ const lines: string[] = [];
80
+ let current = '';
81
+ for (const token of tokens) {
82
+ if (current.length === 0) {
83
+ current = token;
84
+ continue;
85
+ }
86
+ const candidate = `${current} ${token}`;
87
+ if (candidate.length <= maxBytes) {
88
+ current = candidate;
89
+ } else {
90
+ lines.push(current);
91
+ current = token;
92
+ }
93
+ }
94
+ lines.push(current);
95
+ return lines;
96
+ }
@@ -0,0 +1 @@
1
+ export * from './capabilities.js';