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,618 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import http from 'node:http';
3
+ import { type Socket, createConnection, createServer as createNetServer } from 'node:net';
4
+ import type { AddressInfo } from 'node:net';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import type { RawData, WebSocket } from 'ws';
7
+ import { WebSocketServer as WsServer } from 'ws';
8
+ import { startForwarder } from '../src/forwarder';
9
+
10
+ /**
11
+ * Integration tests for the TCP↔WS forwarder against real sockets.
12
+ *
13
+ * Each test spins up:
14
+ * - a target `ws` server (the stand-in for the deployed ServerlessIRCd
15
+ * WebSocket endpoint), and
16
+ * - the forwarder, pointed at that target,
17
+ * then opens a raw TCP socket into the forwarder and asserts the bridge
18
+ * behaves like an invisible IRC wire in both directions.
19
+ *
20
+ * The forwarder is protocol-agnostic; these tests frame behavior in
21
+ * IRC-shaped lines but assert only on bytes/frames.
22
+ */
23
+
24
+ /** Decodes any `ws` RawData variant to a UTF-8 string. */
25
+ function toUtf8(data: RawData): string {
26
+ const buf = Array.isArray(data)
27
+ ? Buffer.concat(data)
28
+ : Buffer.isBuffer(data)
29
+ ? data
30
+ : data instanceof ArrayBuffer
31
+ ? Buffer.from(data)
32
+ : Buffer.from(data as Uint8Array);
33
+ return buf.toString('utf8');
34
+ }
35
+
36
+ /** Reserves an ephemeral port then releases it, returning a now-closed port. */
37
+ async function getClosedPort(): Promise<number> {
38
+ const s = createNetServer();
39
+ return new Promise((resolve, reject) => {
40
+ s.once('error', reject);
41
+ s.listen(0, '127.0.0.1', () => {
42
+ const port = (s.address() as AddressInfo).port;
43
+ s.close(() => resolve(port));
44
+ });
45
+ });
46
+ }
47
+
48
+ interface Target {
49
+ readonly port: number;
50
+ readonly frames: string[];
51
+ readonly connections: WebSocket[];
52
+ waitForConnection(): Promise<WebSocket>;
53
+ close(): Promise<void>;
54
+ }
55
+
56
+ /** Starts a fast (immediate-upgrade) target WS server on an ephemeral port. */
57
+ function startTarget(): Promise<Target> {
58
+ return startDelayedTarget(0);
59
+ }
60
+
61
+ /** Starts a target WS server whose upgrade completes after `delayMs`. */
62
+ function startDelayedTarget(delayMs: number): Promise<Target> {
63
+ const wss = new WsServer({ noServer: true });
64
+ const server = http.createServer();
65
+ const frames: string[] = [];
66
+ const connections: WebSocket[] = [];
67
+ // Each waitForConnection() call expects the next distinct connection, in
68
+ // arrival order; queue waiters that resolve one-for-one as connections land.
69
+ const waiters: Array<(ws: WebSocket) => void> = [];
70
+ let waitForCount = 0;
71
+
72
+ server.on('upgrade', (req, socket, head) => {
73
+ const finish = () => {
74
+ wss.handleUpgrade(req, socket, head, (ws) => {
75
+ connections.push(ws);
76
+ ws.on('message', (data) => frames.push(toUtf8(data as RawData)));
77
+ const waiter = waiters.shift();
78
+ if (waiter) waiter(ws);
79
+ });
80
+ };
81
+ if (delayMs > 0) setTimeout(finish, delayMs);
82
+ else finish();
83
+ });
84
+
85
+ return new Promise((resolve, reject) => {
86
+ server.once('error', reject);
87
+ server.listen(0, '127.0.0.1', () => {
88
+ const port = (server.address() as AddressInfo).port;
89
+ resolve({
90
+ port,
91
+ frames,
92
+ connections,
93
+ waitForConnection: () =>
94
+ new Promise((res) => {
95
+ const want = waitForCount++;
96
+ if (connections.length > want) res(connections[want] as WebSocket);
97
+ else waiters.push(res);
98
+ }),
99
+ close: () =>
100
+ new Promise<void>((r) => {
101
+ for (const ws of connections) ws.close();
102
+ wss.close(() => server.close(() => r()));
103
+ }),
104
+ });
105
+ });
106
+ });
107
+ }
108
+
109
+ /** Minimal line-oriented TCP IRC-style client used to drive the forwarder. */
110
+ class TcpClient extends EventEmitter {
111
+ readonly socket: Socket;
112
+ readonly received: string[] = [];
113
+ private buf = '';
114
+
115
+ constructor(port: number, host = '127.0.0.1') {
116
+ super();
117
+ this.socket = createConnection({ port, host });
118
+ this.socket.setEncoding('utf8');
119
+ this.socket.on('data', (chunk: string) => {
120
+ this.buf += chunk;
121
+ let idx = this.buf.indexOf('\n');
122
+ while (idx !== -1) {
123
+ const line = this.buf.slice(0, idx).replace(/\r$/u, '');
124
+ this.buf = this.buf.slice(idx + 1);
125
+ if (line.length > 0) {
126
+ this.received.push(line);
127
+ this.emit('line', line);
128
+ }
129
+ idx = this.buf.indexOf('\n');
130
+ }
131
+ });
132
+ }
133
+
134
+ opened(): Promise<void> {
135
+ return new Promise((res, rej) => {
136
+ if (this.socket.destroyed) return res();
137
+ this.socket.once('connect', () => res());
138
+ this.socket.once('error', rej);
139
+ });
140
+ }
141
+
142
+ send(line: string): void {
143
+ this.socket.write(`${line}\r\n`);
144
+ }
145
+
146
+ sendRaw(raw: string): void {
147
+ this.socket.write(raw);
148
+ }
149
+
150
+ waitFor(predicate: (line: string) => boolean, timeoutMs = 2000): Promise<string> {
151
+ return new Promise((resolve, reject) => {
152
+ for (const line of this.received) {
153
+ if (predicate(line)) return resolve(line);
154
+ }
155
+ const timer = setTimeout(() => {
156
+ this.off('line', check);
157
+ reject(new Error(`timeout; received: ${JSON.stringify(this.received)}`));
158
+ }, timeoutMs);
159
+ const check = (line: string) => {
160
+ if (predicate(line)) {
161
+ clearTimeout(timer);
162
+ this.off('line', check);
163
+ resolve(line);
164
+ }
165
+ };
166
+ this.on('line', check);
167
+ });
168
+ }
169
+
170
+ /** Resolves when the underlying socket fully closes. */
171
+ closed(): Promise<void> {
172
+ return new Promise((res) => {
173
+ if (this.socket.destroyed) return res();
174
+ this.socket.once('close', () => res());
175
+ });
176
+ }
177
+
178
+ close(): Promise<void> {
179
+ return new Promise((res) => {
180
+ this.socket.once('close', () => res());
181
+ this.socket.end();
182
+ });
183
+ }
184
+ }
185
+
186
+ interface PendingCloser {
187
+ close(): Promise<void>;
188
+ }
189
+ const openServers: PendingCloser[] = [];
190
+ const openClients: TcpClient[] = [];
191
+
192
+ async function shutdownAll(): Promise<void> {
193
+ for (const c of openClients.splice(0)) c.socket.destroy();
194
+ for (const s of openServers.splice(0)) await s.close();
195
+ }
196
+
197
+ afterEach(async () => {
198
+ await shutdownAll();
199
+ });
200
+
201
+ describe('startForwarder — lifecycle', () => {
202
+ it('binds the requested listen port and exposes targetUrl/hostname', async () => {
203
+ const target = await startTarget();
204
+ openServers.push(target);
205
+ const fwd = await startForwarder({
206
+ listenPort: 0,
207
+ listenHost: '127.0.0.1',
208
+ targetUrl: `ws://127.0.0.1:${target.port}/irctest`,
209
+ });
210
+ openServers.push(fwd);
211
+ expect(fwd.listenPort).toBeGreaterThan(0);
212
+ expect(fwd.listenHost).toBe('127.0.0.1');
213
+ expect(fwd.targetUrl).toBe(`ws://127.0.0.1:${target.port}/irctest`);
214
+ });
215
+
216
+ it('defaults listenHost to 127.0.0.1 when omitted', async () => {
217
+ const target = await startTarget();
218
+ openServers.push(target);
219
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
220
+ openServers.push(fwd);
221
+ expect(fwd.listenHost).toBe('127.0.0.1');
222
+ });
223
+
224
+ it('accepts a TCP connection as soon as it is listening', async () => {
225
+ const target = await startTarget();
226
+ openServers.push(target);
227
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
228
+ openServers.push(fwd);
229
+ const client = new TcpClient(fwd.listenPort);
230
+ openClients.push(client);
231
+ await expect(client.opened()).resolves.toBeUndefined();
232
+ });
233
+
234
+ it('rejects when the listen port is already in use', async () => {
235
+ const target = await startTarget();
236
+ openServers.push(target);
237
+ const first = await startForwarder({
238
+ listenPort: 0,
239
+ targetUrl: `ws://127.0.0.1:${target.port}`,
240
+ });
241
+ openServers.push(first);
242
+ await expect(
243
+ startForwarder({ listenPort: first.listenPort, targetUrl: `ws://127.0.0.1:${target.port}` }),
244
+ ).rejects.toThrow();
245
+ });
246
+
247
+ it('throws synchronously for a non-ws/wss target scheme', () => {
248
+ expect(() => startForwarder({ listenPort: 0, targetUrl: 'http://127.0.0.1:6667/' })).toThrow(
249
+ /ws:\/\/|wss:\/\//u,
250
+ );
251
+ });
252
+
253
+ it('throws synchronously for an unparseable target URL', () => {
254
+ expect(() => startForwarder({ listenPort: 0, targetUrl: 'not a url' })).toThrow();
255
+ });
256
+
257
+ it('accepts a wss:// target URL (validates scheme, does not connect on bind)', async () => {
258
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: 'wss://example.invalid/' });
259
+ openServers.push(fwd);
260
+ expect(fwd.targetUrl).toBe('wss://example.invalid/');
261
+ });
262
+ });
263
+
264
+ describe('forwarder — TCP→WS framing', () => {
265
+ it('forwards a single CRLF-terminated line as one WS frame (bare)', async () => {
266
+ const target = await startTarget();
267
+ openServers.push(target);
268
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
269
+ openServers.push(fwd);
270
+
271
+ const client = new TcpClient(fwd.listenPort);
272
+ openClients.push(client);
273
+ await client.opened();
274
+ client.send('PING :tok');
275
+
276
+ const ws = await target.waitForConnection();
277
+ await new Promise<void>((res) => ws.once('message', () => res()));
278
+ expect(target.frames).toEqual(['PING :tok']);
279
+ });
280
+
281
+ it('forwards each of several lines in one TCP chunk as its own WS frame', async () => {
282
+ const target = await startTarget();
283
+ openServers.push(target);
284
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
285
+ openServers.push(fwd);
286
+
287
+ const client = new TcpClient(fwd.listenPort);
288
+ openClients.push(client);
289
+ await client.opened();
290
+ client.sendRaw('NICK alice\r\nUSER alice 0 * :Alice\r\n');
291
+
292
+ const ws = await target.waitForConnection();
293
+ // Wait for the second frame.
294
+ await new Promise<void>((res) => {
295
+ const onMsg = (): void => {
296
+ if (target.frames.length >= 2) {
297
+ ws.off('message', onMsg);
298
+ res();
299
+ }
300
+ };
301
+ ws.on('message', onMsg);
302
+ });
303
+ expect(target.frames).toEqual(['NICK alice', 'USER alice 0 * :Alice']);
304
+ });
305
+
306
+ it('reassembles a line split across two TCP chunks into one WS frame', async () => {
307
+ const target = await startTarget();
308
+ openServers.push(target);
309
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
310
+ openServers.push(fwd);
311
+
312
+ const client = new TcpClient(fwd.listenPort);
313
+ openClients.push(client);
314
+ await client.opened();
315
+ client.sendRaw('PI');
316
+ // Let the first chunk be consumed before the second half is written.
317
+ await new Promise((r) => setImmediate(r));
318
+ client.sendRaw('NG :tok\r\n');
319
+
320
+ const ws = await target.waitForConnection();
321
+ await new Promise<void>((res) => ws.once('message', () => res()));
322
+ expect(target.frames).toEqual(['PING :tok']);
323
+ });
324
+
325
+ it('tolerates a bare-LF line ending from the TCP client', async () => {
326
+ const target = await startTarget();
327
+ openServers.push(target);
328
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
329
+ openServers.push(fwd);
330
+
331
+ const client = new TcpClient(fwd.listenPort);
332
+ openClients.push(client);
333
+ await client.opened();
334
+ client.sendRaw('PING :bare\n');
335
+
336
+ await target.waitForConnection();
337
+ await new Promise<void>((res) => setTimeout(res, 25));
338
+ expect(target.frames).toEqual(['PING :bare']);
339
+ });
340
+
341
+ it('buffers TCP data that arrives before the WS handshake completes', async () => {
342
+ // A slow-upgrading target guarantees the forwarder sees TCP bytes
343
+ // while the WS is still CONNECTING, deterministically exercising the
344
+ // early-data buffer + flush-on-open path.
345
+ const target = await startDelayedTarget(60);
346
+ openServers.push(target);
347
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
348
+ openServers.push(fwd);
349
+
350
+ const client = new TcpClient(fwd.listenPort);
351
+ openClients.push(client);
352
+ await client.opened();
353
+ client.send('PING :early'); // written before WS upgrade (60ms) completes
354
+
355
+ const ws = await target.waitForConnection();
356
+ await new Promise<void>((res) => ws.once('message', () => res()));
357
+ expect(target.frames).toEqual(['PING :early']);
358
+ });
359
+ });
360
+
361
+ describe('forwarder — WS→TCP framing', () => {
362
+ it('writes a server WS frame to the TCP client with CRLF termination', async () => {
363
+ const target = await startTarget();
364
+ openServers.push(target);
365
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
366
+ openServers.push(fwd);
367
+
368
+ const client = new TcpClient(fwd.listenPort);
369
+ openClients.push(client);
370
+ await client.opened();
371
+
372
+ const ws = await target.waitForConnection();
373
+ ws.send(':srv 001 alice :Welcome');
374
+ expect(await client.waitFor((l) => l.startsWith(':srv 001'))).toBe(':srv 001 alice :Welcome');
375
+ });
376
+
377
+ it('normalizes a multi-message WS frame into separate CRLF lines for the TCP client', async () => {
378
+ const target = await startTarget();
379
+ openServers.push(target);
380
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
381
+ openServers.push(fwd);
382
+
383
+ const client = new TcpClient(fwd.listenPort);
384
+ openClients.push(client);
385
+ await client.opened();
386
+
387
+ const ws = await target.waitForConnection();
388
+ // Server joins two messages in one frame (PLAN §9 tolerance).
389
+ ws.send(':srv 001 alice :Hi\r\n:srv 376 alice :End');
390
+ await client.waitFor((l) => l.startsWith(':srv 376'));
391
+ expect(client.received).toEqual([':srv 001 alice :Hi', ':srv 376 alice :End']);
392
+ });
393
+
394
+ it('adds CRLF to a bare server frame that has no terminator', async () => {
395
+ const target = await startTarget();
396
+ openServers.push(target);
397
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
398
+ openServers.push(fwd);
399
+
400
+ const client = new TcpClient(fwd.listenPort);
401
+ openClients.push(client);
402
+ await client.opened();
403
+
404
+ const ws = await target.waitForConnection();
405
+ ws.send('PONG :bare'); // no trailing CRLF
406
+ expect(await client.waitFor((l) => l === 'PONG :bare')).toBe('PONG :bare');
407
+ });
408
+ });
409
+
410
+ describe('forwarder — close propagation', () => {
411
+ it('closes the upstream WS when the TCP client disconnects', async () => {
412
+ const target = await startTarget();
413
+ openServers.push(target);
414
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
415
+ openServers.push(fwd);
416
+
417
+ const client = new TcpClient(fwd.listenPort);
418
+ openClients.push(client);
419
+ await client.opened();
420
+ const ws = await target.waitForConnection();
421
+
422
+ const wsClosed = new Promise<void>((res) => ws.once('close', () => res()));
423
+ await client.close();
424
+ await expect(wsClosed).resolves.toBeUndefined();
425
+ });
426
+
427
+ it('closes the TCP client when the upstream WS closes', async () => {
428
+ const target = await startTarget();
429
+ openServers.push(target);
430
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
431
+ openServers.push(fwd);
432
+
433
+ const client = new TcpClient(fwd.listenPort);
434
+ openClients.push(client);
435
+ await client.opened();
436
+ const ws = await target.waitForConnection();
437
+
438
+ ws.close();
439
+ await expect(client.closed()).resolves.toBeUndefined();
440
+ });
441
+
442
+ it('keeps serving other connections after one TCP client drops', async () => {
443
+ const target = await startTarget();
444
+ openServers.push(target);
445
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
446
+ openServers.push(fwd);
447
+
448
+ const a = new TcpClient(fwd.listenPort);
449
+ openClients.push(a);
450
+ await a.opened();
451
+ await target.waitForConnection();
452
+ await a.close();
453
+
454
+ const b = new TcpClient(fwd.listenPort);
455
+ openClients.push(b);
456
+ await b.opened();
457
+ const ws = await target.waitForConnection();
458
+ ws.send(':srv PONG :still-up');
459
+ expect(await b.waitFor((l) => l.includes('still-up'))).toBeDefined();
460
+ });
461
+ });
462
+
463
+ describe('forwarder — concurrency & teardown', () => {
464
+ it('gives each TCP connection its own upstream WS', async () => {
465
+ const target = await startTarget();
466
+ openServers.push(target);
467
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
468
+ openServers.push(fwd);
469
+
470
+ const a = new TcpClient(fwd.listenPort);
471
+ const b = new TcpClient(fwd.listenPort);
472
+ openClients.push(a, b);
473
+ await a.opened();
474
+ await b.opened();
475
+
476
+ await target.waitForConnection();
477
+ await target.waitForConnection();
478
+ expect(target.connections.length).toBe(2);
479
+ });
480
+
481
+ it('close() tears down the listener and any live bridges', async () => {
482
+ const target = await startTarget();
483
+ openServers.push(target);
484
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
485
+
486
+ const client = new TcpClient(fwd.listenPort);
487
+ openClients.push(client);
488
+ await client.opened();
489
+ await target.waitForConnection();
490
+
491
+ await fwd.close();
492
+ // The TCP client socket should have been torn down by the forwarder
493
+ // (its end is destroyed; the client-side close event arrives async).
494
+ await expect(client.closed()).resolves.toBeUndefined();
495
+ // A new client can no longer connect.
496
+ const next = new TcpClient(fwd.listenPort);
497
+ openClients.push(next);
498
+ await expect(next.opened()).rejects.toThrow();
499
+ await target.close();
500
+ });
501
+
502
+ it('close() is idempotent', async () => {
503
+ const target = await startTarget();
504
+ openServers.push(target);
505
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
506
+ await fwd.close();
507
+ await expect(fwd.close()).resolves.toBeUndefined();
508
+ await target.close();
509
+ });
510
+ });
511
+
512
+ describe('forwarder — IRC-shaped round trip', () => {
513
+ it('relays a bidirectional PING/PONG exchange through the bridge', async () => {
514
+ // Target acts as a naive IRC server: echoes any PING line back as PONG.
515
+ const target = await startTarget();
516
+ openServers.push(target);
517
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
518
+ openServers.push(fwd);
519
+
520
+ const client = new TcpClient(fwd.listenPort);
521
+ openClients.push(client);
522
+ await client.opened();
523
+
524
+ const ws = await target.waitForConnection();
525
+ ws.on('message', (data) => {
526
+ const line = toUtf8(data as RawData);
527
+ if (line.startsWith('PING ')) {
528
+ ws.send(line.replace(/^PING/u, 'PONG'));
529
+ }
530
+ });
531
+
532
+ client.send('PING :roundtrip');
533
+ expect(await client.waitFor((l) => l === 'PONG :roundtrip')).toBe('PONG :roundtrip');
534
+ });
535
+
536
+ it('ignores an empty WS frame and keeps bridging subsequent frames', async () => {
537
+ const target = await startTarget();
538
+ openServers.push(target);
539
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
540
+ openServers.push(fwd);
541
+
542
+ const client = new TcpClient(fwd.listenPort);
543
+ openClients.push(client);
544
+ await client.opened();
545
+ const ws = await target.waitForConnection();
546
+
547
+ ws.send(''); // empty frame: must not error or emit a line
548
+ ws.send(':srv 001 nick :alive');
549
+ expect(await client.waitFor((l) => l.includes('alive'))).toBeDefined();
550
+ });
551
+ });
552
+
553
+ describe('forwarder — error handling', () => {
554
+ it('logs a server-side tcp socket Error and tears the bridge down', async () => {
555
+ const target = await startTarget();
556
+ openServers.push(target);
557
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
558
+ openServers.push(fwd);
559
+
560
+ const client = new TcpClient(fwd.listenPort);
561
+ openClients.push(client);
562
+ await client.opened();
563
+ await target.waitForConnection();
564
+
565
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
566
+ const serverSide = [...fwd.testSockets][0];
567
+ if (serverSide === undefined) throw new Error('no server-side socket');
568
+ serverSide.emit('error', new Error('synthetic tcp failure'));
569
+ await new Promise((r) => setImmediate(r));
570
+
571
+ expect(errSpy).toHaveBeenCalled();
572
+ const logged = JSON.parse(String(errSpy.mock.calls[0]?.[0]));
573
+ expect(logged.msg).toBe('forwarder tcp socket error');
574
+ expect(logged.err.message).toBe('synthetic tcp failure');
575
+ expect(logged.err.name).toBe('Error');
576
+ errSpy.mockRestore();
577
+ });
578
+
579
+ it('stringifies a non-Error value surfaced to the tcp error handler', async () => {
580
+ const target = await startTarget();
581
+ openServers.push(target);
582
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
583
+ openServers.push(fwd);
584
+
585
+ const client = new TcpClient(fwd.listenPort);
586
+ openClients.push(client);
587
+ await client.opened();
588
+ await target.waitForConnection();
589
+
590
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
591
+ const serverSide = [...fwd.testSockets][0];
592
+ if (serverSide === undefined) throw new Error('no server-side socket');
593
+ serverSide.emit('error', 'a bare string failure');
594
+ await new Promise((r) => setImmediate(r));
595
+
596
+ const logged = JSON.parse(String(errSpy.mock.calls[0]?.[0]));
597
+ expect(logged.err).toBe('a bare string failure');
598
+ errSpy.mockRestore();
599
+ });
600
+
601
+ it('logs an upstream ws error and closes the tcp client when the target is unreachable', async () => {
602
+ const closedPort = await getClosedPort();
603
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${closedPort}` });
604
+ openServers.push(fwd);
605
+
606
+ const client = new TcpClient(fwd.listenPort);
607
+ openClients.push(client);
608
+ await client.opened();
609
+
610
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
611
+ // The upstream ws connect fails (ECONNREFUSED) → 'error' → teardown →
612
+ // the tcp client is destroyed.
613
+ await expect(client.closed()).resolves.toBeUndefined();
614
+ expect(errSpy).toHaveBeenCalled();
615
+ expect(String(errSpy.mock.calls[0]?.[0])).toContain('forwarder upstream ws error');
616
+ errSpy.mockRestore();
617
+ });
618
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { RawData } from 'ws';
3
+ import { splitFrameLines, toBuffer } from '../src/forwarder';
4
+
5
+ /**
6
+ * Pure-function unit tests for the WS-side framing helpers. These edges
7
+ * (empty frames, and the ArrayBuffer/Uint8Array/Buffer[] RawData variants
8
+ * that ws only emits under non-default settings) are awkward to reach
9
+ * through real sockets, so they are pinned here in isolation.
10
+ */
11
+ describe('splitFrameLines', () => {
12
+ it('returns no lines for an empty frame', () => {
13
+ expect(splitFrameLines('')).toEqual([]);
14
+ });
15
+
16
+ it('returns a bare unterminated line as a single element', () => {
17
+ expect(splitFrameLines('PING :tok')).toEqual(['PING :tok']);
18
+ });
19
+
20
+ it('splits a CRLF-joined multi-message frame', () => {
21
+ expect(splitFrameLines(':srv 001\r\n:srv 376')).toEqual([':srv 001', ':srv 376']);
22
+ });
23
+
24
+ it('keeps the trailing empty string for a terminated frame (consumer filters it)', () => {
25
+ // Documents the raw split behavior; the forwarder drops empty lines.
26
+ expect(splitFrameLines('A\r\nB\r\n')).toEqual(['A', 'B', '']);
27
+ });
28
+ });
29
+
30
+ describe('toBuffer', () => {
31
+ it('returns a Buffer payload unchanged', () => {
32
+ const src = Buffer.from('hello', 'utf8');
33
+ expect(toBuffer(src)).toBe(src);
34
+ });
35
+
36
+ it('concatenates a Buffer[] payload', () => {
37
+ const out = toBuffer([Buffer.from('foo', 'utf8'), Buffer.from('bar', 'utf8')] as RawData);
38
+ expect(out.toString('utf8')).toBe('foobar');
39
+ });
40
+
41
+ it('wraps an ArrayBuffer payload', () => {
42
+ const src = new ArrayBuffer(3);
43
+ new Uint8Array(src).set([0x41, 0x42, 0x43]); // 'ABC'
44
+ expect(toBuffer(src as RawData).toString('utf8')).toBe('ABC');
45
+ });
46
+
47
+ it('copies a Uint8Array payload', () => {
48
+ const src = new Uint8Array([0x41, 0x42, 0x43]);
49
+ expect(toBuffer(src as RawData).toString('utf8')).toBe('ABC');
50
+ });
51
+ });