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,358 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { type Socket, createConnection } from 'node:net';
3
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
4
+ import { WebSocket } from 'ws';
5
+ import { type LocalServer, startLocalServer } from '../src/server';
6
+
7
+ /**
8
+ * Minimal IRC-over-TCP test client. Mirrors the WS `TestClient` in
9
+ * `e2e.test.ts` but speaks the RFC-style TCP byte stream: writes lines
10
+ * with a trailing CRLF, parses inbound bytes into IRC lines (tolerant of
11
+ * CRLF and bare LF, matching the server's `LineScanner`).
12
+ */
13
+ class TcpTestClient extends EventEmitter {
14
+ readonly socket: Socket;
15
+ readonly received: string[] = [];
16
+
17
+ constructor(host: string, port: number) {
18
+ super();
19
+ this.socket = createConnection({ host, port });
20
+ this.socket.setEncoding('utf8');
21
+ this.socket.on('data', (text: string) => {
22
+ for (const line of text.split(/\r?\n/u)) {
23
+ if (line.length > 0) {
24
+ this.received.push(line);
25
+ this.emit('line', line);
26
+ }
27
+ }
28
+ });
29
+ }
30
+
31
+ /** Resolves once the TCP connection is established. */
32
+ opened(): Promise<void> {
33
+ return new Promise((resolve, reject) => {
34
+ if (this.socket.readyState === 'open') resolve();
35
+ else {
36
+ this.socket.once('connect', () => resolve());
37
+ this.socket.once('error', reject);
38
+ }
39
+ });
40
+ }
41
+
42
+ /** Writes one IRC line with a trailing CRLF. */
43
+ send(line: string): Promise<void> {
44
+ return new Promise((resolve, reject) => {
45
+ this.socket.write(`${line}\r\n`, (err) => (err ? reject(err) : resolve()));
46
+ });
47
+ }
48
+
49
+ /** Raw write — used by framing tests that need to emit partial lines. */
50
+ writeRaw(chunk: string): Promise<void> {
51
+ return new Promise((resolve, reject) => {
52
+ this.socket.write(chunk, 'utf8', (err) => (err ? reject(err) : resolve()));
53
+ });
54
+ }
55
+
56
+ /** Waits until a received line matches `predicate` (substring/predicate). */
57
+ waitFor(predicate: (line: string) => boolean, timeoutMs = 1000): Promise<string> {
58
+ return new Promise((resolve, reject) => {
59
+ const timeout = setTimeout(() => {
60
+ reject(new Error(`timeout waiting for line; received: ${JSON.stringify(this.received)}`));
61
+ }, timeoutMs);
62
+ const check = (line: string): void => {
63
+ if (predicate(line)) {
64
+ clearTimeout(timeout);
65
+ this.off('line', check);
66
+ resolve(line);
67
+ }
68
+ };
69
+ for (const line of this.received) {
70
+ if (predicate(line)) {
71
+ clearTimeout(timeout);
72
+ resolve(line);
73
+ return;
74
+ }
75
+ }
76
+ this.on('line', check);
77
+ });
78
+ }
79
+
80
+ close(): Promise<void> {
81
+ return new Promise((resolve) => {
82
+ this.socket.once('close', () => resolve());
83
+ this.socket.end();
84
+ });
85
+ }
86
+
87
+ /** Force-closes the underlying socket without a FIN handshake. */
88
+ destroy(): void {
89
+ this.socket.destroy();
90
+ }
91
+ }
92
+
93
+ describe('local-cli TCP transport — startLocalServer tcpPort option', () => {
94
+ it('exposes tcpPort and testTcpSockets when tcpPort is provided', async () => {
95
+ const srv = await startLocalServer({ port: 0, tcpPort: 0, hostname: '127.0.0.1' });
96
+ expect(srv.tcpPort).toBeGreaterThan(0);
97
+ expect(srv.testTcpSockets).toBeDefined();
98
+ expect(srv.testTcpSockets?.size).toBe(0);
99
+ await srv.close();
100
+ });
101
+
102
+ it('omits tcpPort / testTcpSockets when tcpPort is not provided', async () => {
103
+ const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
104
+ expect(srv.tcpPort).toBeUndefined();
105
+ expect(srv.testTcpSockets).toBeUndefined();
106
+ await srv.close();
107
+ });
108
+
109
+ it('rejects when the TCP port is already in use', async () => {
110
+ const first = await startLocalServer({ port: 0, tcpPort: 0, hostname: '127.0.0.1' });
111
+ await expect(
112
+ startLocalServer({ port: 0, tcpPort: first.tcpPort, hostname: '127.0.0.1' }),
113
+ ).rejects.toThrow();
114
+ await first.close();
115
+ });
116
+ });
117
+
118
+ describe('local-cli TCP transport — IRC flows', () => {
119
+ let server: LocalServer;
120
+
121
+ beforeAll(async () => {
122
+ server = await startLocalServer({ port: 0, tcpPort: 0, hostname: '127.0.0.1' });
123
+ });
124
+
125
+ afterAll(async () => {
126
+ await server.close();
127
+ });
128
+
129
+ it('accepts a TCP connection on the tcpPort', async () => {
130
+ const client = new TcpTestClient('127.0.0.1', server.tcpPort as number);
131
+ await client.opened();
132
+ expect(server.testTcpSockets?.size).toBe(1);
133
+ await client.close();
134
+ });
135
+
136
+ it('completes register → join → privmsg over TCP', async () => {
137
+ const alice = new TcpTestClient('127.0.0.1', server.tcpPort as number);
138
+ const bob = new TcpTestClient('127.0.0.1', server.tcpPort as number);
139
+ await alice.opened();
140
+ await bob.opened();
141
+
142
+ await alice.send('NICK alice');
143
+ await alice.send('USER alice 0 * :Alice');
144
+ await alice.waitFor((l) => l.startsWith(':irc.example.com 001 '));
145
+
146
+ await bob.send('NICK bob');
147
+ await bob.send('USER bob 0 * :Bob');
148
+ await bob.waitFor((l) => l.startsWith(':irc.example.com 001 '));
149
+
150
+ await alice.send('JOIN #tcp-e2e');
151
+ await alice.waitFor((l) => l.includes('JOIN #tcp-e2e'));
152
+ await bob.send('JOIN #tcp-e2e');
153
+ await bob.waitFor((l) => l.includes('JOIN #tcp-e2e'));
154
+
155
+ await alice.send('PRIVMSG #tcp-e2e :over tcp');
156
+ await bob.waitFor((l) => l.includes('PRIVMSG #tcp-e2e :over tcp'));
157
+
158
+ await alice.close();
159
+ await bob.close();
160
+ });
161
+
162
+ it('routes PING → PONG over TCP', async () => {
163
+ const client = new TcpTestClient('127.0.0.1', server.tcpPort as number);
164
+ await client.opened();
165
+ await client.send('NICK pingUser');
166
+ await client.send('USER pingUser 0 * :Ping');
167
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
168
+
169
+ await client.send('PING :tcp-tok');
170
+ await client.waitFor((l) => l === 'PONG :tcp-tok');
171
+ await client.close();
172
+ });
173
+
174
+ it('reassembles a single IRC message split across two TCP writes', async () => {
175
+ const client = new TcpTestClient('127.0.0.1', server.tcpPort as number);
176
+ await client.opened();
177
+ await client.send('NICK splitter');
178
+ await client.send('USER splitter 0 * :Splitter');
179
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
180
+
181
+ // Split a PING line mid-token across two writes. The server's LineScanner
182
+ // must buffer the partial line and emit one PONG when the terminator lands.
183
+ await client.writeRaw('PING :split');
184
+ await client.writeRaw('ted-tok\r\n');
185
+ await client.waitFor((l) => l === 'PONG :splitted-tok');
186
+ await client.close();
187
+ });
188
+
189
+ it('processes multiple IRC lines delivered in a single TCP write', async () => {
190
+ const client = new TcpTestClient('127.0.0.1', server.tcpPort as number);
191
+ await client.opened();
192
+ // NICK and USER bundled into one write, CRLF-separated.
193
+ await client.writeRaw('NICK bundler\r\nUSER bundler 0 * :Bundler\r\n');
194
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 bundler'));
195
+ await client.close();
196
+ });
197
+
198
+ it('cleans up the runtime connection on abrupt TCP socket termination', async () => {
199
+ const client = new TcpTestClient('127.0.0.1', server.tcpPort as number);
200
+ await client.opened();
201
+ await client.send('NICK termUser');
202
+ await client.send('USER termUser 0 * :Term');
203
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
204
+
205
+ const connId = await server.runtime.lookupNick('termUser');
206
+ expect(connId).not.toBeNull();
207
+ expect(server.runtime.hasConnection(connId as string)).toBe(true);
208
+
209
+ client.destroy();
210
+ // Give the server a tick to process the 'close' / 'error' handler.
211
+ await new Promise((r) => setTimeout(r, 100));
212
+ expect(server.runtime.hasConnection(connId as string)).toBe(false);
213
+ });
214
+
215
+ it('delivers an ERROR notice over TCP when a reducer supplies a disconnect reason', async () => {
216
+ const client = new TcpTestClient('127.0.0.1', server.tcpPort as number);
217
+ await client.opened();
218
+ await client.send('NICK reasonUser');
219
+ await client.send('USER reasonUser 0 * :Reason');
220
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
221
+
222
+ // Synthesize a flood-control / oper-kill style disconnect with a reason.
223
+ const connId = await server.runtime.lookupNick('reasonUser');
224
+ expect(connId).not.toBeNull();
225
+ const wait = client.waitFor((l) => l.startsWith('ERROR :Closing link:'));
226
+ await server.runtime.disconnect(connId as string, 'Excess Flood');
227
+ await expect(wait).resolves.toContain('Excess Flood');
228
+ await client.close();
229
+ });
230
+
231
+ it('logs an actor failure on the TCP path without crashing the server', async () => {
232
+ const original = server.runtime.send.bind(server.runtime);
233
+ server.runtime.send = async () => {
234
+ throw new Error('synthetic tcp send failure');
235
+ };
236
+ const client = new TcpTestClient('127.0.0.1', server.tcpPort as number);
237
+ await client.opened();
238
+ await client.send('NICK tcpErr');
239
+ await client.send('USER tcpErr 0 * :TcpErr');
240
+ // PING triggers a Send → synthetic throw → actor catch → logError.
241
+ await client.send('PING :will-throw');
242
+ await new Promise((r) => setTimeout(r, 50));
243
+
244
+ server.runtime.send = original;
245
+ // Server should still serve new TCP connections.
246
+ const next = new TcpTestClient('127.0.0.1', server.tcpPort as number);
247
+ await next.opened();
248
+ await next.send('NICK tcpRecover');
249
+ await next.send('USER tcpRecover 0 * :TcpRecover');
250
+ await next.waitFor((l) => l.startsWith(':irc.example.com 001 '), 2000);
251
+ await next.close();
252
+ await client.close();
253
+ });
254
+ });
255
+
256
+ describe('local-cli TCP × WS cross-transport interop', () => {
257
+ it('delivers a TCP PRIVMSG to a WS client in the same channel', async () => {
258
+ const srv = await startLocalServer({ port: 0, tcpPort: 0, hostname: '127.0.0.1' });
259
+
260
+ // WS side.
261
+ const wsClient = new WebSocket(`ws://127.0.0.1:${srv.port}/`);
262
+ const wsReceived: string[] = [];
263
+ const wsOpened = new Promise<void>((resolve, reject) => {
264
+ wsClient.once('open', () => resolve());
265
+ wsClient.once('error', reject);
266
+ });
267
+ wsClient.on('message', (data) => {
268
+ for (const line of data.toString('utf8').split(/\r?\n/u)) {
269
+ if (line.length > 0) wsReceived.push(line);
270
+ }
271
+ });
272
+ await wsOpened;
273
+
274
+ const wsSend = (line: string): void => {
275
+ wsClient.send(`${line}\r\n`);
276
+ };
277
+ const wsWaitFor = async (
278
+ predicate: (l: string) => boolean,
279
+ timeoutMs = 1000,
280
+ ): Promise<string> => {
281
+ const start = Date.now();
282
+ while (Date.now() - start < timeoutMs) {
283
+ for (const l of wsReceived) if (predicate(l)) return l;
284
+ await new Promise((r) => setTimeout(r, 10));
285
+ }
286
+ throw new Error(`timeout; received: ${JSON.stringify(wsReceived)}`);
287
+ };
288
+
289
+ wsSend('NICK wsUser');
290
+ wsSend('USER wsUser 0 * :WS');
291
+ await wsWaitFor((l) => l.startsWith(':irc.example.com 001 '));
292
+ wsSend('JOIN #interop');
293
+ await wsWaitFor((l) => l.includes('JOIN #interop'));
294
+
295
+ // TCP side.
296
+ const tcp = new TcpTestClient('127.0.0.1', srv.tcpPort as number);
297
+ await tcp.opened();
298
+ await tcp.send('NICK tcpUser');
299
+ await tcp.send('USER tcpUser 0 * :TCP');
300
+ await tcp.waitFor((l) => l.startsWith(':irc.example.com 001 '));
301
+ await tcp.send('JOIN #interop');
302
+ await tcp.waitFor((l) => l.includes('JOIN #interop'));
303
+
304
+ await tcp.send('PRIVMSG #interop :hello from tcp');
305
+ await wsWaitFor((l) => l.includes('PRIVMSG #interop :hello from tcp'));
306
+
307
+ await tcp.close();
308
+ await new Promise<void>((resolve) => {
309
+ wsClient.once('close', () => resolve());
310
+ wsClient.close();
311
+ });
312
+ await srv.close();
313
+ });
314
+ });
315
+
316
+ describe('local-cli TCP transport — shutdown', () => {
317
+ it('close() tears down the TCP server and live TCP sockets', async () => {
318
+ const srv = await startLocalServer({ port: 0, tcpPort: 0, hostname: '127.0.0.1' });
319
+ const client = new TcpTestClient('127.0.0.1', srv.tcpPort as number);
320
+ await client.opened();
321
+ // Closing the server destroys the server-side socket; the client side
322
+ // observes the close via its own 'close' event.
323
+ const closed = new Promise<void>((resolve) => client.socket.once('close', () => resolve()));
324
+ await srv.close();
325
+ await closed;
326
+ expect(client.socket.destroyed).toBe(true);
327
+ });
328
+
329
+ it('close() is a no-op for TCP when tcpPort was never enabled', async () => {
330
+ const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
331
+ await expect(srv.close()).resolves.toBeUndefined();
332
+ });
333
+
334
+ it('logs and recovers from a per-TCP-socket error without crashing the server', async () => {
335
+ const srv = await startLocalServer({ port: 0, tcpPort: 0, hostname: '127.0.0.1' });
336
+ const client = new TcpTestClient('127.0.0.1', srv.tcpPort as number);
337
+ await client.opened();
338
+ // Wait for the server-side 'connection' handler to register the socket
339
+ // in `testTcpSockets` (it fires on a later tick than the client's
340
+ // 'connect' event).
341
+ const sockets = srv.testTcpSockets as Set<Socket>;
342
+ for (let i = 0; i < 50 && sockets.size === 0; i++) {
343
+ await new Promise((r) => setTimeout(r, 5));
344
+ }
345
+ expect(sockets.size).toBe(1);
346
+ const serverSideSocket = [...sockets][0];
347
+ if (serverSideSocket === undefined) throw new Error('no server-side TCP socket');
348
+ serverSideSocket.emit('error', new Error('synthetic tcp socket failure'));
349
+ // Give the server a tick to process the handler.
350
+ await new Promise((r) => setImmediate(r));
351
+ // Server should still serve new TCP connections.
352
+ const next = new TcpTestClient('127.0.0.1', srv.tcpPort as number);
353
+ await next.opened();
354
+ await next.close();
355
+ await client.close();
356
+ await srv.close();
357
+ });
358
+ });
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "rootDir": "./src",
6
+ "outDir": "./dist",
7
+ "tsBuildInfoFile": "./.tsbuildinfo",
8
+ "types": ["node"]
9
+ },
10
+ "include": ["src/**/*.ts"],
11
+ "exclude": ["dist", "tests", "**/*.test.ts"],
12
+ "references": [
13
+ { "path": "../../packages/irc-core/tsconfig.build.json" },
14
+ { "path": "../../packages/irc-server/tsconfig.build.json" },
15
+ { "path": "../../packages/in-memory-runtime/tsconfig.build.json" }
16
+ ]
17
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": ["node"],
6
+ "composite": false
7
+ },
8
+ "include": ["src/**/*.ts", "tests/**/*.ts"],
9
+ "exclude": ["dist"],
10
+ "references": [
11
+ { "path": "../../packages/irc-core/tsconfig.build.json" },
12
+ { "path": "../../packages/irc-server/tsconfig.build.json" },
13
+ { "path": "../../packages/in-memory-runtime/tsconfig.build.json" }
14
+ ]
15
+ }
@@ -0,0 +1,24 @@
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
+ // `main.ts` is CLI glue (argv parsing, signal handlers); exercised
12
+ // manually by `pnpm start`. PLAN §1 requires ≥90% on core packages
13
+ // and 100% on protocol/reducers; this dev harness targets 90%.
14
+ exclude: ['src/main.ts'],
15
+ reporter: ['text', 'html', 'json-summary'],
16
+ thresholds: {
17
+ lines: 90,
18
+ functions: 90,
19
+ branches: 80,
20
+ statements: 90,
21
+ },
22
+ },
23
+ },
24
+ });
package/biome.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "files": {
9
+ "ignoreUnknown": true,
10
+ "ignore": [
11
+ "**/dist/**",
12
+ "**/coverage/**",
13
+ "**/node_modules/**",
14
+ "**/.turbo/**",
15
+ "pnpm-lock.yaml"
16
+ ]
17
+ },
18
+ "formatter": {
19
+ "enabled": true,
20
+ "indentStyle": "space",
21
+ "indentWidth": 2,
22
+ "lineWidth": 100,
23
+ "lineEnding": "lf"
24
+ },
25
+ "organizeImports": {
26
+ "enabled": true
27
+ },
28
+ "linter": {
29
+ "enabled": true,
30
+ "rules": {
31
+ "recommended": true,
32
+ "style": {
33
+ "useImportType": "error",
34
+ "useExportType": "error",
35
+ "noNonNullAssertion": "warn"
36
+ },
37
+ "suspicious": {
38
+ "noExplicitAny": "error"
39
+ }
40
+ }
41
+ },
42
+ "javascript": {
43
+ "formatter": {
44
+ "quoteStyle": "single",
45
+ "jsxQuoteStyle": "double",
46
+ "semicolons": "always",
47
+ "trailingCommas": "all",
48
+ "arrowParentheses": "always",
49
+ "bracketSpacing": true
50
+ }
51
+ }
52
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "serverless-ircd",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
6
+ "license": "BSD-3-Clause",
7
+ "packageManager": "pnpm@9.15.9",
8
+ "engines": {
9
+ "node": ">=20"
10
+ },
11
+ "scripts": {
12
+ "build": "turbo run build",
13
+ "typecheck": "turbo run typecheck",
14
+ "test": "turbo run test",
15
+ "test:watch": "turbo run test:watch",
16
+ "coverage": "turbo run coverage",
17
+ "lint": "biome check .",
18
+ "lint:fix": "biome check --write .",
19
+ "format": "biome format --write .",
20
+ "clean": "turbo run clean && rimraf node_modules",
21
+ "deploy:cf:staging": "pnpm --filter @serverless-ircd/cf-worker deploy:staging",
22
+ "deploy:cf:prod": "pnpm --filter @serverless-ircd/cf-worker deploy:prod",
23
+ "smoke:cf:staging": "pnpm --filter @serverless-ircd/cf-worker smoke:staging"
24
+ },
25
+ "devDependencies": {
26
+ "@biomejs/biome": "^1.9.0",
27
+ "@vitest/coverage-v8": "^4.1.0",
28
+ "fast-check": "^3.19.0",
29
+ "rimraf": "^6.0.0",
30
+ "turbo": "^2.0.0",
31
+ "typescript": "^5.6.0",
32
+ "vite": "^7.3.0",
33
+ "vitest": "^4.1.0"
34
+ }
35
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@serverless-ircd/cf-adapter",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Cloudflare Workers adapter: Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and CfRuntime implementing IrcRuntime",
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
+ },
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.build.json",
18
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest",
21
+ "coverage": "vitest run --coverage",
22
+ "clean": "rimraf dist coverage .tsbuildinfo .turbo"
23
+ },
24
+ "files": ["dist", "src", "README.md"],
25
+ "dependencies": {
26
+ "@serverless-ircd/irc-core": "workspace:*",
27
+ "@serverless-ircd/irc-server": "workspace:*"
28
+ },
29
+ "devDependencies": {
30
+ "@cloudflare/vitest-pool-workers": "0.18.6",
31
+ "@cloudflare/workers-types": "^5.20260714.1",
32
+ "@serverless-ircd/in-memory-runtime": "workspace:*",
33
+ "@vitest/coverage-v8": "^4.1.0",
34
+ "rimraf": "^6.0.0",
35
+ "typescript": "^5.6.0",
36
+ "vitest": "^4.1.0"
37
+ }
38
+ }