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,512 @@
1
+ import { Effect } from '@serverless-ircd/irc-core';
2
+ import type { ChannelDelta, ConnId, RawLine } from '@serverless-ircd/irc-core';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { dispatch } from '../src/dispatch';
5
+ import type { IrcRuntime } from '../src/runtime';
6
+
7
+ interface RecordedCall {
8
+ method: string;
9
+ args: unknown[];
10
+ }
11
+
12
+ function fakeRuntime(calls: RecordedCall[]): IrcRuntime {
13
+ return {
14
+ async send(to, lines) {
15
+ calls.push({ method: 'send', args: [to, lines] });
16
+ },
17
+ async broadcast(chan, lines, except) {
18
+ calls.push({ method: 'broadcast', args: [chan, lines, except] });
19
+ },
20
+ async disconnect(conn, reason) {
21
+ calls.push({ method: 'disconnect', args: [conn, reason] });
22
+ },
23
+ async sendToNick(nick, sender, lines, notFoundLines) {
24
+ calls.push({ method: 'sendToNick', args: [nick, sender, lines, notFoundLines] });
25
+ },
26
+ async reserveNick(nick, conn) {
27
+ calls.push({ method: 'reserveNick', args: [nick, conn] });
28
+ return { ok: true };
29
+ },
30
+ async changeNick(conn, oldNick, newNick) {
31
+ calls.push({ method: 'changeNick', args: [conn, oldNick, newNick] });
32
+ return true;
33
+ },
34
+ async releaseNick(nick) {
35
+ calls.push({ method: 'releaseNick', args: [nick] });
36
+ },
37
+ async applyChannelDelta(chan, delta) {
38
+ calls.push({ method: 'applyChannelDelta', args: [chan, delta] });
39
+ },
40
+ async lookupNick(nick) {
41
+ calls.push({ method: 'lookupNick', args: [nick] });
42
+ return null;
43
+ },
44
+ async getConnectionInfo(conn) {
45
+ calls.push({ method: 'getConnectionInfo', args: [conn] });
46
+ return null;
47
+ },
48
+ async getConnection(conn) {
49
+ calls.push({ method: 'getConnection', args: [conn] });
50
+ return null;
51
+ },
52
+ async getChannelSnapshot(chan) {
53
+ calls.push({ method: 'getChannelSnapshot', args: [chan] });
54
+ return null;
55
+ },
56
+ async getChannelConnections() {
57
+ return new Map();
58
+ },
59
+ async listChannels() {
60
+ return [];
61
+ },
62
+ };
63
+ }
64
+
65
+ const L = (text: string): RawLine => ({ text });
66
+
67
+ describe('dispatch', () => {
68
+ it('processes a mixed sequence of effects in order', async () => {
69
+ const calls: RecordedCall[] = [];
70
+ await dispatch(
71
+ [
72
+ Effect.send('c1', [L('PONG :tok')]),
73
+ Effect.broadcast('#foo', [L('hi')], 'c1'),
74
+ Effect.disconnect('c2', 'excess flood'),
75
+ ],
76
+ fakeRuntime(calls),
77
+ );
78
+ expect(calls).toEqual<RecordedCall[]>([
79
+ { method: 'send', args: ['c1', [{ text: 'PONG :tok' }]] },
80
+ { method: 'broadcast', args: ['#foo', [{ text: 'hi' }], 'c1'] },
81
+ { method: 'disconnect', args: ['c2', 'excess flood'] },
82
+ ]);
83
+ });
84
+
85
+ it('returns synchronously when the effect list is empty', async () => {
86
+ const calls: RecordedCall[] = [];
87
+ await dispatch([], fakeRuntime(calls));
88
+ expect(calls).toEqual([]);
89
+ });
90
+
91
+ it('routes Send with no lines verbatim to the runtime', async () => {
92
+ const calls: RecordedCall[] = [];
93
+ await dispatch([Effect.send('c1', [])], fakeRuntime(calls));
94
+ expect(calls).toEqual([{ method: 'send', args: ['c1', []] }]);
95
+ });
96
+
97
+ it('routes Broadcast without except (undefined is preserved)', async () => {
98
+ const calls: RecordedCall[] = [];
99
+ await dispatch([Effect.broadcast('#x', [L('m')])], fakeRuntime(calls));
100
+ expect(calls[0]).toEqual({ method: 'broadcast', args: ['#x', [{ text: 'm' }], undefined] });
101
+ });
102
+
103
+ it('routes Disconnect without reason (undefined is preserved)', async () => {
104
+ const calls: RecordedCall[] = [];
105
+ await dispatch([Effect.disconnect('c1')], fakeRuntime(calls));
106
+ expect(calls[0]).toEqual({ method: 'disconnect', args: ['c1', undefined] });
107
+ });
108
+
109
+ it('routes SendToNick with all four arguments in order', async () => {
110
+ const calls: RecordedCall[] = [];
111
+ const notFound = [L('401')];
112
+ await dispatch([Effect.sendToNick('bob', 'c1', [L('hi')], notFound)], fakeRuntime(calls));
113
+ expect(calls[0]).toEqual({
114
+ method: 'sendToNick',
115
+ args: ['bob', 'c1', [{ text: 'hi' }], notFound],
116
+ });
117
+ });
118
+
119
+ it('routes SendToNick preserving undefined notFoundLines', async () => {
120
+ const calls: RecordedCall[] = [];
121
+ await dispatch([Effect.sendToNick('bob', 'c1', [L('hi')])], fakeRuntime(calls));
122
+ expect(calls[0]).toEqual({
123
+ method: 'sendToNick',
124
+ args: ['bob', 'c1', [{ text: 'hi' }], undefined],
125
+ });
126
+ });
127
+
128
+ it('calls reserveNick, changeNick, releaseNick in order', async () => {
129
+ const calls: RecordedCall[] = [];
130
+ await dispatch(
131
+ [
132
+ Effect.reserveNick('alice', 'c1'),
133
+ Effect.changeNick('c1', 'alice', 'bob'),
134
+ Effect.releaseNick('alice'),
135
+ ],
136
+ fakeRuntime(calls),
137
+ );
138
+ expect(calls.map((c) => c.method)).toEqual(['reserveNick', 'changeNick', 'releaseNick']);
139
+ });
140
+
141
+ it('calls applyChannelDelta with the supplied delta', async () => {
142
+ const calls: RecordedCall[] = [];
143
+ const delta: ChannelDelta = { memberships: [{ type: 'remove', conn: 'c1' }] };
144
+ await dispatch([Effect.applyChannelDelta('#foo', delta)], fakeRuntime(calls));
145
+ expect(calls[0]).toEqual({ method: 'applyChannelDelta', args: ['#foo', delta] });
146
+ });
147
+
148
+ it('invokes lookupNick, getConnectionInfo, getChannelSnapshot lookups', async () => {
149
+ const calls: RecordedCall[] = [];
150
+ await dispatch(
151
+ [
152
+ Effect.lookupNick('alice'),
153
+ Effect.getConnectionInfo('c1'),
154
+ Effect.getChannelSnapshot('#foo'),
155
+ ],
156
+ fakeRuntime(calls),
157
+ );
158
+ expect(calls.map((c) => c.method)).toEqual([
159
+ 'lookupNick',
160
+ 'getConnectionInfo',
161
+ 'getChannelSnapshot',
162
+ ]);
163
+ });
164
+
165
+ it('awaits each effect in sequence (one fails, the rest after it do not run)', async () => {
166
+ const calls: RecordedCall[] = [];
167
+ const failing: IrcRuntime = {
168
+ ...fakeRuntime(calls),
169
+ async send() {
170
+ calls.push({ method: 'send', args: [] });
171
+ throw new Error('boom');
172
+ },
173
+ };
174
+ await expect(
175
+ dispatch([Effect.send('c1', []), Effect.disconnect('c1')], failing),
176
+ ).rejects.toThrow('boom');
177
+ expect(calls.map((c) => c.method)).toEqual(['send']);
178
+ });
179
+
180
+ it('deduplicates consecutive identical Send effects to the same target', async () => {
181
+ const calls: RecordedCall[] = [];
182
+ await dispatch(
183
+ [Effect.send('c1', [L('hi')]), Effect.send('c1', [L('hi')])],
184
+ fakeRuntime(calls),
185
+ );
186
+ expect(calls).toHaveLength(1);
187
+ expect(calls[0]).toEqual({ method: 'send', args: ['c1', [{ text: 'hi' }]] });
188
+ });
189
+
190
+ it('does not deduplicate Sends with different targets or different content', async () => {
191
+ const calls: RecordedCall[] = [];
192
+ await dispatch(
193
+ [Effect.send('c1', [L('hi')]), Effect.send('c2', [L('hi')]), Effect.send('c1', [L('bye')])],
194
+ fakeRuntime(calls),
195
+ );
196
+ expect(calls).toHaveLength(3);
197
+ });
198
+
199
+ it('does not deduplicate Sends to the same target with different line counts', async () => {
200
+ const calls: RecordedCall[] = [];
201
+ await dispatch(
202
+ [Effect.send('c1', [L('hi')]), Effect.send('c1', [L('hi'), L('bye')])],
203
+ fakeRuntime(calls),
204
+ );
205
+ expect(calls).toHaveLength(2);
206
+ });
207
+
208
+ it('does not deduplicate Sends with same line count but different text', async () => {
209
+ const calls: RecordedCall[] = [];
210
+ await dispatch(
211
+ [Effect.send('c1', [L('hi')]), Effect.send('c1', [L('bye')])],
212
+ fakeRuntime(calls),
213
+ );
214
+ expect(calls).toHaveLength(2);
215
+ });
216
+
217
+ it('does not deduplicate non-Send effects even if identical', async () => {
218
+ const calls: RecordedCall[] = [];
219
+ await dispatch(
220
+ [Effect.disconnect('c1', 'x'), Effect.disconnect('c1', 'x')],
221
+ fakeRuntime(calls),
222
+ );
223
+ expect(calls).toHaveLength(2);
224
+ expect(calls.map((c) => c.method)).toEqual(['disconnect', 'disconnect']);
225
+ });
226
+ });
227
+
228
+ // ============================================================================
229
+ // Broadcast cap filtering (cap / capLines)
230
+ //
231
+ // IRCv3 capabilities like `extended-join` and `chghost` need per-recipient
232
+ // delivery: which line a peer sees depends on the caps THEY negotiated. The
233
+ // reducer annotates the Broadcast effect with `cap` (and optionally
234
+ // `capLines`); dispatch resolves each member's caps via `getConnectionInfo`
235
+ // and routes per-recipient with `send`.
236
+ //
237
+ // - cap unset → lines to everyone (fast path).
238
+ // - cap set, capLines set (split) → capLines to cap-enabled peers,
239
+ // lines to everyone else.
240
+ // - cap set, capLines unset (cap-only) → lines ONLY to cap-enabled peers.
241
+ // ============================================================================
242
+
243
+ interface MemberInfo {
244
+ caps: ReadonlySet<string>;
245
+ }
246
+
247
+ /**
248
+ * Builds a fake runtime whose `getChannelSnapshot` returns the given roster
249
+ * and whose `getConnectionInfo` returns each member's caps. Records `send`
250
+ * and `broadcast` calls so tests can assert per-recipient delivery.
251
+ */
252
+ function capAwareRuntime(members: ReadonlyMap<ConnId, MemberInfo>): {
253
+ runtime: IrcRuntime;
254
+ sends: { to: ConnId; lines: RawLine[] }[];
255
+ broadcasts: { chan: string; lines: RawLine[]; except?: ConnId | undefined }[];
256
+ } {
257
+ const sends: { to: ConnId; lines: RawLine[] }[] = [];
258
+ const broadcasts: { chan: string; lines: RawLine[]; except?: ConnId | undefined }[] = [];
259
+ const runtime: IrcRuntime = {
260
+ async send(to, lines) {
261
+ sends.push({ to, lines });
262
+ },
263
+ async broadcast(chan, lines, except) {
264
+ broadcasts.push({ chan, lines, except });
265
+ },
266
+ async disconnect() {},
267
+ async sendToNick() {},
268
+ async reserveNick() {
269
+ return { ok: true };
270
+ },
271
+ async changeNick() {
272
+ return true;
273
+ },
274
+ async releaseNick() {},
275
+ async applyChannelDelta() {},
276
+ async lookupNick() {
277
+ return null;
278
+ },
279
+ async getConnectionInfo(conn) {
280
+ const info = members.get(conn);
281
+ if (info === undefined) return null;
282
+ return {
283
+ id: conn,
284
+ registration: 'registered',
285
+ caps: info.caps,
286
+ joinedChannels: new Set<string>(),
287
+ userModes: { invisible: false, oper: false, wallops: false, serverNotices: false },
288
+ lastSeen: 0,
289
+ connectedSince: 0,
290
+ };
291
+ },
292
+ async getConnection() {
293
+ return null;
294
+ },
295
+ async getChannelSnapshot(chan) {
296
+ const roster = new Map();
297
+ for (const connId of members.keys()) {
298
+ roster.set(connId, { conn: connId, nick: connId, op: false, voice: false });
299
+ }
300
+ return {
301
+ name: chan,
302
+ nameLower: chan.toLowerCase(),
303
+ modes: {
304
+ inviteOnly: false,
305
+ topicLock: false,
306
+ noExternal: false,
307
+ moderated: false,
308
+ secret: false,
309
+ private: false,
310
+ },
311
+ banMasks: new Set<string>(),
312
+ members: roster,
313
+ pendingInvites: new Set<string>(),
314
+ createdAt: 0,
315
+ };
316
+ },
317
+ async getChannelConnections() {
318
+ return new Map();
319
+ },
320
+ async listChannels() {
321
+ return [];
322
+ },
323
+ };
324
+ return { runtime, sends, broadcasts };
325
+ }
326
+
327
+ function memberMap(entries: ReadonlyArray<[ConnId, MemberInfo]>): ReadonlyMap<ConnId, MemberInfo> {
328
+ return new Map(entries);
329
+ }
330
+
331
+ describe('dispatch — Broadcast cap / capLines', () => {
332
+ it('takes the runtime.broadcast fast path when cap is unset', async () => {
333
+ const members = memberMap([['c1', { caps: new Set<string>() }]]);
334
+ const { runtime, sends, broadcasts } = capAwareRuntime(members);
335
+
336
+ await dispatch([Effect.broadcast('#foo', [L('hi')])], runtime);
337
+
338
+ expect(broadcasts).toEqual([{ chan: '#foo', lines: [L('hi')], except: undefined }]);
339
+ expect(sends).toHaveLength(0);
340
+ });
341
+
342
+ it('delivers capLines to cap-enabled members and lines to others (split mode)', async () => {
343
+ const members = memberMap([
344
+ ['c1', { caps: new Set(['extended-join']) }],
345
+ ['c2', { caps: new Set<string>() }],
346
+ ]);
347
+ const { runtime, sends, broadcasts } = capAwareRuntime(members);
348
+
349
+ await dispatch(
350
+ [
351
+ Effect.broadcast('#foo', [L(':a JOIN #foo')], undefined, 'extended-join', [
352
+ L(':a JOIN #foo acct :real'),
353
+ ]),
354
+ ],
355
+ runtime,
356
+ );
357
+
358
+ // No fast-path broadcast call: dispatch routes per-recipient.
359
+ expect(broadcasts).toHaveLength(0);
360
+ const toC1 = sends.find((s) => s.to === 'c1');
361
+ const toC2 = sends.find((s) => s.to === 'c2');
362
+ expect(toC1?.lines).toEqual([L(':a JOIN #foo acct :real')]); // extended
363
+ expect(toC2?.lines).toEqual([L(':a JOIN #foo')]); // legacy
364
+ });
365
+
366
+ it('delivers lines ONLY to cap-enabled members when capLines is unset (cap-only mode)', async () => {
367
+ const members = memberMap([
368
+ ['c1', { caps: new Set(['chghost']) }],
369
+ ['c2', { caps: new Set<string>() }],
370
+ ]);
371
+ const { runtime, sends } = capAwareRuntime(members);
372
+
373
+ await dispatch(
374
+ [Effect.broadcast('#foo', [L(':a CHGHOST u :h')], undefined, 'chghost')],
375
+ runtime,
376
+ );
377
+
378
+ expect(sends.map((s) => s.to)).toEqual(['c1']);
379
+ expect(sends[0]?.lines).toEqual([L(':a CHGHOST u :h')]);
380
+ });
381
+
382
+ it('honors `except` alongside cap filters (excepted member never receives)', async () => {
383
+ const members = memberMap([
384
+ ['c1', { caps: new Set(['chghost']) }], // the changing user
385
+ ['c2', { caps: new Set(['chghost']) }],
386
+ ]);
387
+ const { runtime, sends } = capAwareRuntime(members);
388
+
389
+ await dispatch([Effect.broadcast('#foo', [L(':a CHGHOST u :h')], 'c1', 'chghost')], runtime);
390
+
391
+ expect(sends.map((s) => s.to)).toEqual(['c2']);
392
+ });
393
+
394
+ it('is a no-op when the channel snapshot is null (channel does not exist)', async () => {
395
+ const sends: { to: ConnId; lines: RawLine[] }[] = [];
396
+ const runtime: IrcRuntime = {
397
+ async send(to, lines) {
398
+ sends.push({ to, lines });
399
+ },
400
+ async broadcast() {},
401
+ async disconnect() {},
402
+ async sendToNick() {},
403
+ async reserveNick() {
404
+ return { ok: true };
405
+ },
406
+ async changeNick() {
407
+ return true;
408
+ },
409
+ async releaseNick() {},
410
+ async applyChannelDelta() {},
411
+ async lookupNick() {
412
+ return null;
413
+ },
414
+ async getConnectionInfo() {
415
+ return null;
416
+ },
417
+ async getConnection() {
418
+ return null;
419
+ },
420
+ async getChannelSnapshot() {
421
+ return null;
422
+ },
423
+ async getChannelConnections() {
424
+ return new Map();
425
+ },
426
+ async listChannels() {
427
+ return [];
428
+ },
429
+ };
430
+
431
+ await dispatch([Effect.broadcast('#nope', [L('x')], undefined, 'chghost')], runtime);
432
+
433
+ expect(sends).toHaveLength(0);
434
+ });
435
+
436
+ it('treats a member with unavailable connection info as lacking the cap', async () => {
437
+ // c2 is on the roster but getConnectionInfo returns null for it (gone).
438
+ const sends: { to: ConnId; lines: RawLine[] }[] = [];
439
+ const runtime: IrcRuntime = {
440
+ async send(to, lines) {
441
+ sends.push({ to, lines });
442
+ },
443
+ async broadcast() {},
444
+ async disconnect() {},
445
+ async sendToNick() {},
446
+ async reserveNick() {
447
+ return { ok: true };
448
+ },
449
+ async changeNick() {
450
+ return true;
451
+ },
452
+ async releaseNick() {},
453
+ async applyChannelDelta() {},
454
+ async lookupNick() {
455
+ return null;
456
+ },
457
+ async getConnectionInfo(conn) {
458
+ if (conn === 'c1') {
459
+ return {
460
+ id: 'c1',
461
+ registration: 'registered',
462
+ caps: new Set(['chghost']),
463
+ joinedChannels: new Set<string>(),
464
+ userModes: { invisible: false, oper: false, wallops: false, serverNotices: false },
465
+ lastSeen: 0,
466
+ connectedSince: 0,
467
+ };
468
+ }
469
+ return null; // c2 gone
470
+ },
471
+ async getConnection() {
472
+ return null;
473
+ },
474
+ async getChannelSnapshot(chan) {
475
+ const roster = new Map([
476
+ ['c1', { conn: 'c1', nick: 'c1', op: false, voice: false }],
477
+ ['c2', { conn: 'c2', nick: 'c2', op: false, voice: false }],
478
+ ]);
479
+ return {
480
+ name: chan,
481
+ nameLower: chan.toLowerCase(),
482
+ modes: {
483
+ inviteOnly: false,
484
+ topicLock: false,
485
+ noExternal: false,
486
+ moderated: false,
487
+ secret: false,
488
+ private: false,
489
+ },
490
+ banMasks: new Set<string>(),
491
+ members: roster,
492
+ pendingInvites: new Set<string>(),
493
+ createdAt: 0,
494
+ };
495
+ },
496
+ async getChannelConnections() {
497
+ return new Map();
498
+ },
499
+ async listChannels() {
500
+ return [];
501
+ },
502
+ };
503
+
504
+ await dispatch(
505
+ [Effect.broadcast('#foo', [L(':a CHGHOST u :h')], undefined, 'chghost')],
506
+ runtime,
507
+ );
508
+
509
+ // c2 gone → no caps → skipped by the cap-only filter.
510
+ expect(sends.map((s) => s.to)).toEqual(['c1']);
511
+ });
512
+ });
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { IrcRuntime } from '../src/runtime';
3
+
4
+ describe('IrcRuntime contract', () => {
5
+ it('is satisfied by a minimal object implementing every method', () => {
6
+ const runtime: IrcRuntime = {
7
+ async send() {},
8
+ async broadcast() {},
9
+ async disconnect() {},
10
+ async sendToNick() {},
11
+ async reserveNick() {
12
+ return { ok: true };
13
+ },
14
+ async changeNick() {
15
+ return true;
16
+ },
17
+ async releaseNick() {},
18
+ async applyChannelDelta() {},
19
+ async lookupNick() {
20
+ return null;
21
+ },
22
+ async getConnectionInfo() {
23
+ return null;
24
+ },
25
+ async getConnection() {
26
+ return null;
27
+ },
28
+ async getChannelSnapshot() {
29
+ return null;
30
+ },
31
+ async getChannelConnections() {
32
+ return new Map();
33
+ },
34
+ async listChannels() {
35
+ return [];
36
+ },
37
+ };
38
+ expect(runtime).toBeDefined();
39
+ expect(typeof runtime.send).toBe('function');
40
+ expect(typeof runtime.broadcast).toBe('function');
41
+ expect(typeof runtime.disconnect).toBe('function');
42
+ expect(typeof runtime.sendToNick).toBe('function');
43
+ expect(typeof runtime.reserveNick).toBe('function');
44
+ expect(typeof runtime.changeNick).toBe('function');
45
+ expect(typeof runtime.releaseNick).toBe('function');
46
+ expect(typeof runtime.applyChannelDelta).toBe('function');
47
+ expect(typeof runtime.lookupNick).toBe('function');
48
+ expect(typeof runtime.getConnectionInfo).toBe('function');
49
+ expect(typeof runtime.getConnection).toBe('function');
50
+ expect(typeof runtime.getChannelSnapshot).toBe('function');
51
+ });
52
+ });
@@ -0,0 +1,13 @@
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": [{ "path": "../irc-core/tsconfig.build.json" }]
13
+ }
@@ -0,0 +1,11 @@
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": [{ "path": "../irc-core/tsconfig.build.json" }]
11
+ }
@@ -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,4 @@
1
+ packages:
2
+ - "packages/*"
3
+ - "apps/*"
4
+ - "tools/*"
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@serverless-ircd/tcp-ws-forwarder",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Local TCP to ws/wss forwarder so standard IRC clients can reach the WebSocket-only ServerlessIRCd transport",
6
+ "license": "BSD-3-Clause",
7
+ "type": "module",
8
+ "main": "./dist/forwarder.js",
9
+ "bin": {
10
+ "tcp-ws-forwarder": "./dist/main.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.build.json",
14
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
15
+ "start": "node --enable-source-maps ./dist/main.js",
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "coverage": "vitest run --coverage",
19
+ "clean": "rimraf dist coverage .tsbuildinfo .turbo"
20
+ },
21
+ "dependencies": {
22
+ "ws": "^8.18.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^26.1.1",
26
+ "@types/ws": "^8.5.13",
27
+ "@vitest/coverage-v8": "^4.1.0",
28
+ "rimraf": "^6.0.0",
29
+ "typescript": "^5.6.0",
30
+ "vitest": "^4.1.0"
31
+ }
32
+ }