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.
- package/.github/workflows/ci.yml +47 -0
- package/.github/workflows/deploy-cf.yml +97 -0
- package/LICENSE +29 -0
- package/README.md +323 -0
- package/apps/cf-worker/package.json +37 -0
- package/apps/cf-worker/scripts/smoke.mjs +175 -0
- package/apps/cf-worker/src/worker.ts +59 -0
- package/apps/cf-worker/tests/smoke.test.ts +150 -0
- package/apps/cf-worker/tsconfig.build.json +17 -0
- package/apps/cf-worker/tsconfig.test.json +18 -0
- package/apps/cf-worker/vitest.config.ts +31 -0
- package/apps/cf-worker/wrangler.test.toml +33 -0
- package/apps/cf-worker/wrangler.toml +98 -0
- package/apps/local-cli/package.json +35 -0
- package/apps/local-cli/src/line-scanner.ts +60 -0
- package/apps/local-cli/src/main.ts +145 -0
- package/apps/local-cli/src/motd-file.ts +45 -0
- package/apps/local-cli/src/server.ts +409 -0
- package/apps/local-cli/tests/e2e.test.ts +346 -0
- package/apps/local-cli/tests/id-factory.test.ts +25 -0
- package/apps/local-cli/tests/line-scanner.test.ts +81 -0
- package/apps/local-cli/tests/motd-file.test.ts +71 -0
- package/apps/local-cli/tests/tcp.test.ts +358 -0
- package/apps/local-cli/tsconfig.build.json +17 -0
- package/apps/local-cli/tsconfig.test.json +15 -0
- package/apps/local-cli/vitest.config.ts +24 -0
- package/biome.json +52 -0
- package/package.json +35 -0
- package/packages/cf-adapter/package.json +38 -0
- package/packages/cf-adapter/src/cf-runtime.ts +308 -0
- package/packages/cf-adapter/src/channel-do.ts +374 -0
- package/packages/cf-adapter/src/connection-do.ts +542 -0
- package/packages/cf-adapter/src/env.ts +67 -0
- package/packages/cf-adapter/src/index.ts +38 -0
- package/packages/cf-adapter/src/registry-do.ts +130 -0
- package/packages/cf-adapter/src/serialize.ts +142 -0
- package/packages/cf-adapter/src/sharding.ts +101 -0
- package/packages/cf-adapter/tests/cf-harness.ts +264 -0
- package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
- package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
- package/packages/cf-adapter/tests/integration/harness.ts +102 -0
- package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
- package/packages/cf-adapter/tests/integration/index.ts +17 -0
- package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
- package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
- package/packages/cf-adapter/tests/sharding.test.ts +100 -0
- package/packages/cf-adapter/tests/worker/main.ts +33 -0
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
- package/packages/cf-adapter/tsconfig.build.json +16 -0
- package/packages/cf-adapter/tsconfig.test.json +18 -0
- package/packages/cf-adapter/vitest.config.ts +32 -0
- package/packages/cf-adapter/wrangler.test.toml +50 -0
- package/packages/in-memory-runtime/package.json +29 -0
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
- package/packages/in-memory-runtime/src/index.ts +9 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
- package/packages/in-memory-runtime/tsconfig.build.json +16 -0
- package/packages/in-memory-runtime/tsconfig.test.json +14 -0
- package/packages/in-memory-runtime/vitest.config.ts +21 -0
- package/packages/irc-core/package.json +29 -0
- package/packages/irc-core/src/caps/capabilities.ts +96 -0
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/commands/away.ts +82 -0
- package/packages/irc-core/src/commands/cap.ts +257 -0
- package/packages/irc-core/src/commands/chghost.ts +30 -0
- package/packages/irc-core/src/commands/index.ts +40 -0
- package/packages/irc-core/src/commands/invite.ts +156 -0
- package/packages/irc-core/src/commands/isupport.ts +133 -0
- package/packages/irc-core/src/commands/join.ts +309 -0
- package/packages/irc-core/src/commands/kick.ts +162 -0
- package/packages/irc-core/src/commands/list.ts +126 -0
- package/packages/irc-core/src/commands/mode.ts +655 -0
- package/packages/irc-core/src/commands/motd.ts +146 -0
- package/packages/irc-core/src/commands/names.ts +164 -0
- package/packages/irc-core/src/commands/part.ts +118 -0
- package/packages/irc-core/src/commands/ping.ts +47 -0
- package/packages/irc-core/src/commands/privmsg.ts +256 -0
- package/packages/irc-core/src/commands/quit.ts +70 -0
- package/packages/irc-core/src/commands/registration.ts +251 -0
- package/packages/irc-core/src/commands/topic.ts +171 -0
- package/packages/irc-core/src/commands/who.ts +169 -0
- package/packages/irc-core/src/commands/whois.ts +165 -0
- package/packages/irc-core/src/effects.ts +184 -0
- package/packages/irc-core/src/index.ts +20 -0
- package/packages/irc-core/src/ports.ts +153 -0
- package/packages/irc-core/src/protocol/batch.ts +85 -0
- package/packages/irc-core/src/protocol/index.ts +18 -0
- package/packages/irc-core/src/protocol/messages.ts +36 -0
- package/packages/irc-core/src/protocol/numerics.ts +155 -0
- package/packages/irc-core/src/protocol/outbound.ts +145 -0
- package/packages/irc-core/src/protocol/parser.ts +175 -0
- package/packages/irc-core/src/protocol/serializer.ts +71 -0
- package/packages/irc-core/src/state/channel.ts +293 -0
- package/packages/irc-core/src/state/connection.ts +153 -0
- package/packages/irc-core/src/state/index.ts +25 -0
- package/packages/irc-core/src/state/registry.ts +22 -0
- package/packages/irc-core/src/types.ts +83 -0
- package/packages/irc-core/tests/batch.test.ts +79 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
- package/packages/irc-core/tests/commands/away.test.ts +233 -0
- package/packages/irc-core/tests/commands/batch.test.ts +178 -0
- package/packages/irc-core/tests/commands/cap.test.ts +499 -0
- package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
- package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
- package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
- package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
- package/packages/irc-core/tests/commands/invite.test.ts +321 -0
- package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
- package/packages/irc-core/tests/commands/join.test.ts +687 -0
- package/packages/irc-core/tests/commands/kick.test.ts +316 -0
- package/packages/irc-core/tests/commands/list.test.ts +452 -0
- package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
- package/packages/irc-core/tests/commands/motd.test.ts +322 -0
- package/packages/irc-core/tests/commands/names.test.ts +342 -0
- package/packages/irc-core/tests/commands/part.test.ts +265 -0
- package/packages/irc-core/tests/commands/ping.test.ts +144 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
- package/packages/irc-core/tests/commands/quit.test.ts +220 -0
- package/packages/irc-core/tests/commands/registration.test.ts +599 -0
- package/packages/irc-core/tests/commands/topic.test.ts +337 -0
- package/packages/irc-core/tests/commands/who.test.ts +441 -0
- package/packages/irc-core/tests/commands/whois.test.ts +422 -0
- package/packages/irc-core/tests/effects.test.ts +147 -0
- package/packages/irc-core/tests/message-tags.test.ts +182 -0
- package/packages/irc-core/tests/numerics.test.ts +98 -0
- package/packages/irc-core/tests/outbound.test.ts +232 -0
- package/packages/irc-core/tests/parser.test.ts +162 -0
- package/packages/irc-core/tests/ports.test.ts +101 -0
- package/packages/irc-core/tests/serializer.test.ts +164 -0
- package/packages/irc-core/tests/state/channel.test.ts +351 -0
- package/packages/irc-core/tests/state/connection.test.ts +122 -0
- package/packages/irc-core/tests/state/registry.test.ts +20 -0
- package/packages/irc-core/tests/types.test.ts +127 -0
- package/packages/irc-core/tsconfig.build.json +12 -0
- package/packages/irc-core/tsconfig.test.json +10 -0
- package/packages/irc-core/vitest.config.ts +21 -0
- package/packages/irc-server/package.json +28 -0
- package/packages/irc-server/src/actor.ts +449 -0
- package/packages/irc-server/src/dispatch.ts +120 -0
- package/packages/irc-server/src/index.ts +11 -0
- package/packages/irc-server/src/runtime.ts +61 -0
- package/packages/irc-server/tests/actor.test.ts +816 -0
- package/packages/irc-server/tests/dispatch.test.ts +512 -0
- package/packages/irc-server/tests/runtime.test.ts +52 -0
- package/packages/irc-server/tsconfig.build.json +13 -0
- package/packages/irc-server/tsconfig.test.json +11 -0
- package/packages/irc-server/vitest.config.ts +21 -0
- package/pnpm-workspace.yaml +4 -0
- package/tools/tcp-ws-forwarder/package.json +32 -0
- package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
- package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
- package/tools/tcp-ws-forwarder/src/main.ts +114 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
- package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
- package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
- package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
- package/tsconfig.base.json +25 -0
- package/turbo.json +29 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
3
|
+
import { WebSocket } from 'ws';
|
|
4
|
+
import { type LocalServer, startLocalServer } from '../src/server';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Minimal IRC-over-WS test client. Sends one IRC message per WS text frame
|
|
8
|
+
* (the simplest frame-per-message convention from PLAN §9) and parses
|
|
9
|
+
* incoming frames into IRC lines.
|
|
10
|
+
*/
|
|
11
|
+
class TestClient extends EventEmitter {
|
|
12
|
+
readonly ws: WebSocket;
|
|
13
|
+
readonly received: string[] = [];
|
|
14
|
+
|
|
15
|
+
constructor(url: string) {
|
|
16
|
+
super();
|
|
17
|
+
this.ws = new WebSocket(url);
|
|
18
|
+
this.ws.on('message', (data) => {
|
|
19
|
+
const text = data.toString('utf8');
|
|
20
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
21
|
+
if (line.length > 0) {
|
|
22
|
+
this.received.push(line);
|
|
23
|
+
this.emit('line', line);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Resolves once the WS handshake completes. */
|
|
30
|
+
opened(): Promise<void> {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
if (this.ws.readyState === WebSocket.OPEN) resolve();
|
|
33
|
+
else {
|
|
34
|
+
this.ws.once('open', () => resolve());
|
|
35
|
+
this.ws.once('error', reject);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
send(line: string): Promise<void> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
this.ws.send(`${line}\r\n`, (err) => (err ? reject(err) : resolve()));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Waits until a received line contains `needle` (substring match). */
|
|
47
|
+
waitFor(predicate: (line: string) => boolean, timeoutMs = 1000): Promise<string> {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const timeout = setTimeout(() => {
|
|
50
|
+
reject(new Error(`timeout waiting for line; received: ${JSON.stringify(this.received)}`));
|
|
51
|
+
}, timeoutMs);
|
|
52
|
+
const check = (line: string) => {
|
|
53
|
+
if (predicate(line)) {
|
|
54
|
+
clearTimeout(timeout);
|
|
55
|
+
this.off('line', check);
|
|
56
|
+
resolve(line);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
// Scan already-received lines first.
|
|
60
|
+
for (const line of this.received) {
|
|
61
|
+
if (predicate(line)) {
|
|
62
|
+
clearTimeout(timeout);
|
|
63
|
+
resolve(line);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
this.on('line', check);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
close(): Promise<void> {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
this.ws.once('close', () => resolve());
|
|
74
|
+
this.ws.close();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('local-cli e2e', () => {
|
|
80
|
+
let server: LocalServer;
|
|
81
|
+
|
|
82
|
+
beforeAll(async () => {
|
|
83
|
+
server = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterAll(async () => {
|
|
87
|
+
await server.close();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('boots on the requested port and accepts a WebSocket connection', async () => {
|
|
91
|
+
expect(server.port).toBeGreaterThan(0);
|
|
92
|
+
const client = new TestClient(`ws://127.0.0.1:${server.port}/`);
|
|
93
|
+
await client.opened();
|
|
94
|
+
await client.close();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('completes a register → join → privmsg flow end-to-end', async () => {
|
|
98
|
+
// Two clients: alice joins #foo, bob joins #foo, alice PRIVMSGs the channel.
|
|
99
|
+
const alice = new TestClient(`ws://127.0.0.1:${server.port}/`);
|
|
100
|
+
const bob = new TestClient(`ws://127.0.0.1:${server.port}/`);
|
|
101
|
+
await alice.opened();
|
|
102
|
+
await bob.opened();
|
|
103
|
+
|
|
104
|
+
await alice.send('NICK alice');
|
|
105
|
+
await alice.send('USER alice 0 * :Alice');
|
|
106
|
+
await alice.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
107
|
+
|
|
108
|
+
await bob.send('NICK bob');
|
|
109
|
+
await bob.send('USER bob 0 * :Bob');
|
|
110
|
+
await bob.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
111
|
+
|
|
112
|
+
await alice.send('JOIN #e2e');
|
|
113
|
+
await alice.waitFor((l) => l.includes('JOIN #e2e'));
|
|
114
|
+
await alice.waitFor((l) => l.startsWith(':irc.example.com 353 '));
|
|
115
|
+
|
|
116
|
+
await bob.send('JOIN #e2e');
|
|
117
|
+
// Bob sees his own JOIN + NAMES.
|
|
118
|
+
await bob.waitFor((l) => l.includes('JOIN #e2e'));
|
|
119
|
+
|
|
120
|
+
await alice.send('PRIVMSG #e2e :hello world');
|
|
121
|
+
// Bob receives the broadcast (alice's actor excludes alice as sender).
|
|
122
|
+
await bob.waitFor((l) => l.includes('PRIVMSG #e2e :hello world'));
|
|
123
|
+
|
|
124
|
+
await alice.close();
|
|
125
|
+
await bob.close();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('routes PING → PONG', async () => {
|
|
129
|
+
const client = new TestClient(`ws://127.0.0.1:${server.port}/`);
|
|
130
|
+
await client.opened();
|
|
131
|
+
await client.send('NICK charlie');
|
|
132
|
+
await client.send('USER charlie 0 * :Charlie');
|
|
133
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
134
|
+
|
|
135
|
+
await client.send('PING :tok-12345');
|
|
136
|
+
await client.waitFor((l) => l === 'PONG :tok-12345');
|
|
137
|
+
|
|
138
|
+
await client.close();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('emits 421 for an unknown command', async () => {
|
|
142
|
+
const client = new TestClient(`ws://127.0.0.1:${server.port}/`);
|
|
143
|
+
await client.opened();
|
|
144
|
+
await client.send('NICK dave');
|
|
145
|
+
await client.send('USER dave 0 * :Dave');
|
|
146
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
147
|
+
|
|
148
|
+
await client.send('BOGUSCMD');
|
|
149
|
+
await client.waitFor((l) => l.includes('421') && l.includes('BOGUSCMD'));
|
|
150
|
+
|
|
151
|
+
await client.close();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('handles QUIT cleanly (closes the socket, releases the nick)', async () => {
|
|
155
|
+
const client = new TestClient(`ws://127.0.0.1:${server.port}/`);
|
|
156
|
+
await client.opened();
|
|
157
|
+
await client.send('NICK eve');
|
|
158
|
+
await client.send('USER eve 0 * :Eve');
|
|
159
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
160
|
+
|
|
161
|
+
await client.send('QUIT :bye');
|
|
162
|
+
// The disconnect effect should close the socket.
|
|
163
|
+
await client.close();
|
|
164
|
+
// The nick should be released so a new client can claim it.
|
|
165
|
+
const next = new TestClient(`ws://127.0.0.1:${server.port}/`);
|
|
166
|
+
await next.opened();
|
|
167
|
+
await next.send('NICK eve');
|
|
168
|
+
await next.send('USER eve 0 * :Eve2');
|
|
169
|
+
await next.waitFor((l) => l.startsWith(':irc.example.com 001 '), 2000);
|
|
170
|
+
await next.close();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('local-cli error paths', () => {
|
|
175
|
+
it('rejects startLocalServer when the port is already in use', async () => {
|
|
176
|
+
const first = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
177
|
+
await expect(startLocalServer({ port: first.port, hostname: '127.0.0.1' })).rejects.toThrow();
|
|
178
|
+
await first.close();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('logs and recovers from a per-socket error without crashing the server', async () => {
|
|
182
|
+
const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
183
|
+
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
184
|
+
await client.opened();
|
|
185
|
+
// Synthesize a socket-level error on the SERVER-side ws via the
|
|
186
|
+
// test-only seam. This exercises the `ws.on('error')` handler that
|
|
187
|
+
// logs + unregisters the connection.
|
|
188
|
+
expect(srv.testSockets.size).toBe(1);
|
|
189
|
+
const serverSideWs = [...srv.testSockets][0];
|
|
190
|
+
if (serverSideWs === undefined) throw new Error('no server-side socket');
|
|
191
|
+
const wsErr = new Error('synthetic socket failure');
|
|
192
|
+
// biome-ignore lint/suspicious/noExplicitAny: ws emit accepts any Error
|
|
193
|
+
(serverSideWs as any).emit('error', wsErr);
|
|
194
|
+
// Give the server a tick to process the handler.
|
|
195
|
+
await new Promise((r) => setImmediate(r));
|
|
196
|
+
// Server should still serve new connections.
|
|
197
|
+
const next = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
198
|
+
await next.opened();
|
|
199
|
+
await next.close();
|
|
200
|
+
await client.close();
|
|
201
|
+
await srv.close();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('delivers configured MOTD lines on explicit MOTD command', async () => {
|
|
205
|
+
const srv = await startLocalServer({
|
|
206
|
+
port: 0,
|
|
207
|
+
hostname: '127.0.0.1',
|
|
208
|
+
motdLines: ['first line of motd', 'second line of motd'],
|
|
209
|
+
});
|
|
210
|
+
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
211
|
+
await client.opened();
|
|
212
|
+
await client.send('NICK karl');
|
|
213
|
+
await client.send('USER karl 0 * :Karl');
|
|
214
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
215
|
+
// Drain registration burst so the explicit MOTD responses below aren't
|
|
216
|
+
// confused with the placeholder MOTD emitted at registration.
|
|
217
|
+
client.received.length = 0;
|
|
218
|
+
await client.send('MOTD');
|
|
219
|
+
// Wait for end-of-MOTD so all 372 lines are buffered in `received`.
|
|
220
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 376 karl'));
|
|
221
|
+
const motdBody = client.received.filter((l) => l.startsWith(':irc.example.com 372 karl'));
|
|
222
|
+
expect(motdBody.some((l) => l.includes('first line of motd'))).toBe(true);
|
|
223
|
+
expect(motdBody.some((l) => l.includes('second line of motd'))).toBe(true);
|
|
224
|
+
await client.close();
|
|
225
|
+
await srv.close();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('defaults hostname to 127.0.0.1 when omitted', async () => {
|
|
229
|
+
const srv = await startLocalServer({ port: 0 });
|
|
230
|
+
expect(srv.hostname).toBe('127.0.0.1');
|
|
231
|
+
// Sanity-check that the server is actually reachable on the default.
|
|
232
|
+
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
233
|
+
await client.opened();
|
|
234
|
+
await client.close();
|
|
235
|
+
await srv.close();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('close() forcibly closes any still-open client sockets', async () => {
|
|
239
|
+
const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
240
|
+
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
241
|
+
await client.opened();
|
|
242
|
+
// Close the server WITHOUT first closing the client — exercises the
|
|
243
|
+
//srv.close cleanup loop that iterates live connections.
|
|
244
|
+
await srv.close();
|
|
245
|
+
// The client socket should have been closed by the server.
|
|
246
|
+
expect(client.ws.readyState).not.toBe(1); // not OPEN
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('close() is a no-op when no connections are live', async () => {
|
|
250
|
+
const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
251
|
+
await srv.close();
|
|
252
|
+
// Closing again should not throw (idempotent).
|
|
253
|
+
await expect(srv.close()).resolves.toBeUndefined();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('logs an actor failure without crashing the server (Error path)', async () => {
|
|
257
|
+
const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
258
|
+
// Force the runtime's `send` to throw a real Error so the actor's
|
|
259
|
+
// reducer pipeline rejects with an Error instance — exercises
|
|
260
|
+
// `stringifyErr`'s `instanceof Error` branch and `logError`.
|
|
261
|
+
const original = srv.runtime.send.bind(srv.runtime);
|
|
262
|
+
srv.runtime.send = async () => {
|
|
263
|
+
throw new Error('synthetic send failure');
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
267
|
+
await client.opened();
|
|
268
|
+
await client.send('NICK frank');
|
|
269
|
+
await client.send('USER frank 0 * :Frank');
|
|
270
|
+
// PING triggers a Send → synthetic throw → actor catch → logError.
|
|
271
|
+
await client.send('PING :will-throw');
|
|
272
|
+
// Give the server a tick to process the rejection.
|
|
273
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
274
|
+
|
|
275
|
+
// Restore and verify the server still serves new connections.
|
|
276
|
+
srv.runtime.send = original;
|
|
277
|
+
const next = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
278
|
+
await next.opened();
|
|
279
|
+
await next.send('NICK grace');
|
|
280
|
+
await next.send('USER grace 0 * :Grace');
|
|
281
|
+
await next.waitFor((l) => l.startsWith(':irc.example.com 001 '), 2000);
|
|
282
|
+
await next.close();
|
|
283
|
+
await client.close();
|
|
284
|
+
await srv.close();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('logs a non-Error throw from the actor pipeline (defensive path)', async () => {
|
|
288
|
+
const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
289
|
+
const original = srv.runtime.send.bind(srv.runtime);
|
|
290
|
+
srv.runtime.send = async () => {
|
|
291
|
+
// Throw a non-Error value to exercise stringifyErr's else branch.
|
|
292
|
+
throw 'synthetic non-Error throw';
|
|
293
|
+
};
|
|
294
|
+
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
295
|
+
await client.opened();
|
|
296
|
+
await client.send('NICK frank2');
|
|
297
|
+
await client.send('USER frank2 0 * :Frank2');
|
|
298
|
+
await client.send('PING :will-throw-non-error');
|
|
299
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
300
|
+
srv.runtime.send = original;
|
|
301
|
+
await client.close();
|
|
302
|
+
await srv.close();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('cleans up when a client terminates abruptly (server-side error path)', async () => {
|
|
306
|
+
const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
307
|
+
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
308
|
+
await client.opened();
|
|
309
|
+
await client.send('NICK ivan');
|
|
310
|
+
await client.send('USER ivan 0 * :Ivan');
|
|
311
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
312
|
+
const connId = await srv.runtime.lookupNick('ivan');
|
|
313
|
+
expect(connId).not.toBeNull();
|
|
314
|
+
expect(srv.runtime.hasConnection(connId as string)).toBe(true);
|
|
315
|
+
|
|
316
|
+
// Terminate the underlying socket abruptly; the server-side ws sees an
|
|
317
|
+
// 'error' followed by 'close', and the cleanup handler unregisters the
|
|
318
|
+
// connection from the runtime. (The nick itself is released by the
|
|
319
|
+
// QUIT reducer on graceful shutdown; abrupt termination leaks the nick
|
|
320
|
+
// pending a future gone-connection sweep — 041.)
|
|
321
|
+
client.ws.terminate();
|
|
322
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
323
|
+
expect(srv.runtime.hasConnection(connId as string)).toBe(false);
|
|
324
|
+
await srv.close();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('delivers an ERROR notice when a reducer supplies a disconnect reason', async () => {
|
|
328
|
+
const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
329
|
+
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
330
|
+
await client.opened();
|
|
331
|
+
await client.send('NICK heidi');
|
|
332
|
+
await client.send('USER heidi 0 * :Heidi');
|
|
333
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
334
|
+
|
|
335
|
+
// Call the runtime's disconnect directly with a reason (simulates a
|
|
336
|
+
// future flood-control / oper-kill effect). The runtime takes a ConnId;
|
|
337
|
+
// we resolve it from the nick registry.
|
|
338
|
+
const connId = await srv.runtime.lookupNick('heidi');
|
|
339
|
+
expect(connId).not.toBeNull();
|
|
340
|
+
const waitForErrorNotice = client.waitFor((l) => l.startsWith('ERROR :Closing link:'));
|
|
341
|
+
await srv.runtime.disconnect(connId as string, 'Excess Flood');
|
|
342
|
+
await expect(waitForErrorNotice).resolves.toContain('Excess Flood');
|
|
343
|
+
await client.close();
|
|
344
|
+
await srv.close();
|
|
345
|
+
});
|
|
346
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DEFAULT_ID_FACTORY } from '../src/server';
|
|
3
|
+
|
|
4
|
+
describe('DEFAULT_ID_FACTORY', () => {
|
|
5
|
+
it('batchId returns distinct UUID-shaped strings', () => {
|
|
6
|
+
const a = DEFAULT_ID_FACTORY.batchId();
|
|
7
|
+
const b = DEFAULT_ID_FACTORY.batchId();
|
|
8
|
+
expect(a).not.toBe(b);
|
|
9
|
+
expect(a).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('nonce returns distinct UUID-shaped strings', () => {
|
|
13
|
+
const a = DEFAULT_ID_FACTORY.nonce();
|
|
14
|
+
const b = DEFAULT_ID_FACTORY.nonce();
|
|
15
|
+
expect(a).not.toBe(b);
|
|
16
|
+
expect(a).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('sessionId returns distinct UUID-shaped strings', () => {
|
|
20
|
+
const a = DEFAULT_ID_FACTORY.sessionId();
|
|
21
|
+
const b = DEFAULT_ID_FACTORY.sessionId();
|
|
22
|
+
expect(a).not.toBe(b);
|
|
23
|
+
expect(a).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { LineScanner } from '../src/line-scanner';
|
|
3
|
+
|
|
4
|
+
const enc = (s: string): Buffer => Buffer.from(s, 'utf8');
|
|
5
|
+
|
|
6
|
+
describe('LineScanner', () => {
|
|
7
|
+
it('returns an empty array for an empty chunk', () => {
|
|
8
|
+
const sc = new LineScanner();
|
|
9
|
+
expect(sc.push(enc(''))).toEqual([]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('returns nothing until a line terminator arrives', () => {
|
|
13
|
+
const sc = new LineScanner();
|
|
14
|
+
expect(sc.push(enc('NICK alice'))).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('emits one complete CRLF-terminated line', () => {
|
|
18
|
+
const sc = new LineScanner();
|
|
19
|
+
expect(sc.push(enc('NICK alice\r\n'))).toEqual(['NICK alice']);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('emits one complete LF-terminated line (bare-newline tolerated)', () => {
|
|
23
|
+
const sc = new LineScanner();
|
|
24
|
+
expect(sc.push(enc('NICK alice\n'))).toEqual(['NICK alice']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('reassembles a line split across two chunks (mid-token)', () => {
|
|
28
|
+
const sc = new LineScanner();
|
|
29
|
+
expect(sc.push(enc('PING :split'))).toEqual([]);
|
|
30
|
+
expect(sc.push(enc('ted\r\n'))).toEqual(['PING :splitted']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('emits multiple lines delivered in one chunk', () => {
|
|
34
|
+
const sc = new LineScanner();
|
|
35
|
+
expect(sc.push(enc('NICK bob\r\nUSER bob 0 * :Bob\r\n'))).toEqual([
|
|
36
|
+
'NICK bob',
|
|
37
|
+
'USER bob 0 * :Bob',
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('handles a mix of CRLF and bare LF in the same chunk', () => {
|
|
42
|
+
const sc = new LineScanner();
|
|
43
|
+
expect(sc.push(enc('NICK carol\r\nUSER carol 0 * :C\n'))).toEqual([
|
|
44
|
+
'NICK carol',
|
|
45
|
+
'USER carol 0 * :C',
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('preserves a lone CR that is not followed by LF', () => {
|
|
50
|
+
const sc = new LineScanner();
|
|
51
|
+
// `CR` mid-text is data; only LF terminates. The CR right before LF is
|
|
52
|
+
// stripped as the CRLF pair's trailing CR.
|
|
53
|
+
expect(sc.push(enc('PRIVMSG #x :a\rb\r\n'))).toEqual(['PRIVMSG #x :a\rb']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('emits an empty line for two consecutive terminators', () => {
|
|
57
|
+
const sc = new LineScanner();
|
|
58
|
+
expect(sc.push(enc('foo\r\n\r\n'))).toEqual(['foo', '']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('decodes a multi-byte UTF-8 sequence split across chunks without corruption', () => {
|
|
62
|
+
const sc = new LineScanner();
|
|
63
|
+
// 'snowman' U+2603 encodes as 3 bytes: E2 98 83. Split after byte 1.
|
|
64
|
+
const whole = enc('PRIVMSG #x :☃\r\n');
|
|
65
|
+
expect(sc.push(whole.subarray(0, 14))).toEqual([]);
|
|
66
|
+
expect(sc.push(whole.subarray(14))).toEqual(['PRIVMSG #x :☃']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('flush returns an unterminated tail and clears the carry', () => {
|
|
70
|
+
const sc = new LineScanner();
|
|
71
|
+
sc.push(enc('NICK dave'));
|
|
72
|
+
expect(sc.flush()).toEqual(['NICK dave']);
|
|
73
|
+
// Carry cleared: subsequent push starts fresh.
|
|
74
|
+
expect(sc.push(enc('PING :t\r\n'))).toEqual(['PING :t']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('flush returns an empty array when nothing is buffered', () => {
|
|
78
|
+
const sc = new LineScanner();
|
|
79
|
+
expect(sc.flush()).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { loadMotdFile, parseMotdFileContent } from '../src/motd-file';
|
|
6
|
+
|
|
7
|
+
describe('parseMotdFileContent', () => {
|
|
8
|
+
it('returns an empty array for the empty string', () => {
|
|
9
|
+
expect(parseMotdFileContent('')).toEqual([]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('parses a single bare-LF-terminated line', () => {
|
|
13
|
+
expect(parseMotdFileContent('Welcome.\n')).toEqual(['Welcome.']);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('parses multiple LF-separated lines', () => {
|
|
17
|
+
expect(parseMotdFileContent('first\nsecond\nthird')).toEqual(['first', 'second', 'third']);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('strips a trailing CR from CRLF pairs', () => {
|
|
21
|
+
expect(parseMotdFileContent('hi\r\nthere\r\n')).toEqual(['hi', 'there']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('drops a single trailing newline but preserves interior blank lines', () => {
|
|
25
|
+
expect(parseMotdFileContent('a\n\nb\n')).toEqual(['a', '', 'b']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('preserves a lone CR that is not followed by LF', () => {
|
|
29
|
+
expect(parseMotdFileContent('a\rb\r\n')).toEqual(['a\rb']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('does not synthesize a trailing empty line for content lacking a final newline', () => {
|
|
33
|
+
expect(parseMotdFileContent('only')).toEqual(['only']);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('loadMotdFile', () => {
|
|
38
|
+
let dir: string;
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
if (dir !== undefined) rmSync(dir, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function setup(): string {
|
|
45
|
+
dir = mkdtempSync(join(tmpdir(), 'local-cli-motd-'));
|
|
46
|
+
return dir;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
it('reads and parses the file at the given path', () => {
|
|
50
|
+
const d = setup();
|
|
51
|
+
const path = join(d, 'motd.txt');
|
|
52
|
+
writeFileSync(path, 'line one\nline two\n');
|
|
53
|
+
|
|
54
|
+
expect(loadMotdFile(path)).toEqual(['line one', 'line two']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns an empty array for an empty file', () => {
|
|
58
|
+
const d = setup();
|
|
59
|
+
const path = join(d, 'empty.txt');
|
|
60
|
+
writeFileSync(path, '');
|
|
61
|
+
|
|
62
|
+
expect(loadMotdFile(path)).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('throws a descriptive error when the file does not exist', () => {
|
|
66
|
+
const d = setup();
|
|
67
|
+
const path = join(d, 'nope.txt');
|
|
68
|
+
|
|
69
|
+
expect(() => loadMotdFile(path)).toThrowError(/motd/i);
|
|
70
|
+
});
|
|
71
|
+
});
|