serverless-ircd 0.8.0 → 0.9.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 (80) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +160 -200
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/cf-tcp-container/package.json +1 -1
  6. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  7. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  8. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  9. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  10. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  11. package/apps/cf-worker/package.json +1 -1
  12. package/apps/local-cli/package.json +1 -1
  13. package/apps/local-cli/src/server.ts +94 -31
  14. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  15. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  16. package/apps/local-cli/tests/rehash.test.ts +147 -0
  17. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  18. package/apps/local-cli/tests/tcp.test.ts +89 -0
  19. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  20. package/apps/web/landing/index.html +226 -3
  21. package/apps/web/package.json +2 -1
  22. package/apps/web/scripts/build.mjs +25 -2
  23. package/apps/web/src/render-docs.ts +292 -0
  24. package/apps/web/tests/build-smoke.test.ts +31 -2
  25. package/apps/web/tests/landing-content.test.ts +103 -0
  26. package/apps/web/tests/render-docs.test.ts +198 -0
  27. package/docs/AWS-Adapter-Architecture.md +3 -2
  28. package/docs/Services.md +33 -1
  29. package/package.json +2 -2
  30. package/packages/aws-adapter/package.json +1 -1
  31. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  32. package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
  33. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  34. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  35. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  36. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  37. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  38. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  39. package/packages/cf-adapter/package.json +1 -1
  40. package/packages/cf-adapter/src/connection-do.ts +18 -6
  41. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  42. package/packages/in-memory-runtime/package.json +1 -1
  43. package/packages/irc-core/package.json +1 -1
  44. package/packages/irc-core/src/commands/account-auth.ts +46 -18
  45. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  46. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  47. package/packages/irc-core/src/commands/index.ts +1 -0
  48. package/packages/irc-core/src/commands/join.ts +41 -35
  49. package/packages/irc-core/src/commands/nickserv.ts +16 -4
  50. package/packages/irc-core/src/commands/registration.ts +27 -16
  51. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  52. package/packages/irc-core/src/commands/topic.ts +23 -10
  53. package/packages/irc-core/src/state/channel.ts +17 -0
  54. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  55. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  56. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  57. package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
  58. package/packages/irc-core/tests/commands/registration.test.ts +227 -6
  59. package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
  60. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  61. package/packages/irc-server/package.json +1 -1
  62. package/packages/irc-server/src/actor.ts +80 -30
  63. package/packages/irc-server/tests/actor.test.ts +365 -3
  64. package/packages/irc-test-support/package.json +1 -1
  65. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  66. package/packages/irc-test-support/src/scenarios.ts +21 -6
  67. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  68. package/packages/irc-test-support/vitest.config.ts +6 -1
  69. package/tools/ci-hardening/package.json +1 -1
  70. package/tools/load-test/package.json +1 -1
  71. package/tools/load-test/src/client.ts +13 -13
  72. package/tools/load-test/tests/client.test.ts +258 -2
  73. package/tools/load-test/tests/config.test.ts +39 -0
  74. package/tools/load-test/tests/harness.test.ts +21 -0
  75. package/tools/load-test/tests/metrics.test.ts +7 -0
  76. package/tools/tcp-ws-forwarder/package.json +1 -1
  77. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  78. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  79. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  80. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Coverage for `startLocalServer`'s config-resolution branches:
3
+ * - the conditional spreads that pass optional CLI knobs through to the
4
+ * shared schema (each knob's "defined" arm — exercised only when the
5
+ * caller explicitly supplies the value);
6
+ * - the admission-config `?? DEFAULT_*` fallbacks (exercised only when
7
+ * admission is enabled without the corresponding cap/rate).
8
+ *
9
+ * Starting a server (and closing it) is enough to drive the resolution — no
10
+ * client connection is required.
11
+ */
12
+
13
+ import { describe, expect, it } from 'vitest';
14
+ import { startLocalServer } from '../src/server';
15
+
16
+ describe('startLocalServer — threads explicit config knobs to the schema', () => {
17
+ it('accepts every optional limit/knob override and starts cleanly', async () => {
18
+ const srv = await startLocalServer({
19
+ port: 0,
20
+ hostname: '127.0.0.1',
21
+ serverName: 'cfg.example.com',
22
+ networkName: 'CfgNet',
23
+ motdLines: ['explicit motd line'],
24
+ maxChannelsPerUser: 7,
25
+ maxTargetsPerCommand: 3,
26
+ maxListEntries: 11,
27
+ nickLen: 20,
28
+ channelLen: 40,
29
+ topicLen: 200,
30
+ quitMessage: 'explicit quit',
31
+ });
32
+ expect(srv.port).toBeGreaterThan(0);
33
+ await srv.close();
34
+ });
35
+ });
36
+
37
+ describe('startLocalServer — admission defaults', () => {
38
+ it('falls back to the per-IP / per-user defaults when only the rate limit is set', async () => {
39
+ // Admission enabled, but maxConnectionsPerIp / maxConnectionsPerUser are
40
+ // left undefined → the `?? DEFAULT_*` arms apply.
41
+ const srv = await startLocalServer({
42
+ port: 0,
43
+ hostname: '127.0.0.1',
44
+ serverName: 'rate.example.com',
45
+ networkName: 'RateNet',
46
+ perIpConnectionRate: { max: 5, windowMs: 60_000 },
47
+ });
48
+ expect(srv.port).toBeGreaterThan(0);
49
+ await srv.close();
50
+ });
51
+
52
+ it('falls back to the per-IP cap and rate defaults when only per-user cap is set', async () => {
53
+ // Admission enabled, but maxConnectionsPerIp / perIpConnectionRate are
54
+ // left undefined → both `?? DEFAULT_*` arms apply.
55
+ const srv = await startLocalServer({
56
+ port: 0,
57
+ hostname: '127.0.0.1',
58
+ serverName: 'user.example.com',
59
+ networkName: 'UserNet',
60
+ maxConnectionsPerUser: 3,
61
+ });
62
+ expect(srv.port).toBeGreaterThan(0);
63
+ await srv.close();
64
+ });
65
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Isolated coverage for {@link loadMotdFile}'s non-Error throw wrap
3
+ * (motd-file.ts:41 — the `String(err)` branch). The rest of the motd-file
4
+ * suite exercises real `node:fs` I/O against a temp dir; this file
5
+ * `vi.mock`s `node:fs` so `readFileSync` rejects with a value that is NOT
6
+ * an `Error`, driving the `err instanceof Error ? ... : String(err)` ternary
7
+ * down its non-Error path. Lives in its own file so the mock never leaks
8
+ * into the real-I/O tests.
9
+ */
10
+
11
+ import { describe, expect, it, vi } from 'vitest';
12
+
13
+ vi.mock('node:fs', () => ({
14
+ // Throw a non-Error value so the catch wraps it via `String(err)`.
15
+ readFileSync: () => {
16
+ throw 'boom-non-error';
17
+ },
18
+ }));
19
+
20
+ // Imported AFTER `vi.mock` is established (vitest hoists the mock).
21
+ import { loadMotdFile } from '../src/motd-file';
22
+
23
+ describe('loadMotdFile — wraps a non-Error throw via String()', () => {
24
+ it('rethrows a descriptive Error whose detail is String(<thrown value>)', () => {
25
+ expect(() => loadMotdFile('/irrelevant/motd.txt')).toThrowError(
26
+ /motd: cannot read --motd-file at .*: boom-non-error/u,
27
+ );
28
+ });
29
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * oper `REHASH` end-to-end through the local CLI.
3
+ *
4
+ * Drives the `configLoader` closure bound at server.ts:358 — the REHASH
5
+ * reload source. An oper's `REHASH` re-invokes that closure (via the runtime's
6
+ * `reloadConfig`) and swaps the live ServerConfig; the actor emits
7
+ * `382 RPL_REHASHING` on success. A non-oper receives `481 ERR_NOPRIVILEGES`
8
+ * and no reload is attempted.
9
+ *
10
+ * Oper credentials are threaded through the local CLI's `operCreds` option
11
+ * (the shared schema already accepted them; `startLocalServer` now wires them
12
+ * to the reducer-facing ServerConfig).
13
+ */
14
+
15
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
16
+ import { WebSocket } from 'ws';
17
+ import { type LocalServer, startLocalServer } from '../src/server';
18
+
19
+ class IrcClient {
20
+ readonly ws: WebSocket;
21
+ readonly received: string[] = [];
22
+
23
+ constructor(url: string) {
24
+ this.ws = new WebSocket(url);
25
+ this.ws.on('message', (data) => {
26
+ const text = data.toString('utf8');
27
+ for (const line of text.split(/\r?\n/u)) {
28
+ if (line.length > 0) this.received.push(line);
29
+ }
30
+ });
31
+ }
32
+
33
+ opened(): Promise<void> {
34
+ return new Promise((resolve, reject) => {
35
+ if (this.ws.readyState === WebSocket.OPEN) resolve();
36
+ else {
37
+ this.ws.once('open', () => resolve());
38
+ this.ws.once('error', reject);
39
+ }
40
+ });
41
+ }
42
+
43
+ send(line: string): Promise<void> {
44
+ return new Promise((resolve, reject) => {
45
+ this.ws.send(`${line}\r\n`, (err) => (err ? reject(err) : resolve()));
46
+ });
47
+ }
48
+
49
+ waitFor(predicate: (line: string) => boolean, timeoutMs = 1_000): Promise<string> {
50
+ const check = (line: string): boolean => predicate(line);
51
+ return new Promise((resolve, reject) => {
52
+ const timeout = setTimeout(
53
+ () => reject(new Error(`timeout; received: ${JSON.stringify(this.received)}`)),
54
+ timeoutMs,
55
+ );
56
+ const onLine = (line: string): void => {
57
+ if (check(line)) {
58
+ clearTimeout(timeout);
59
+ this.ws.off('message', onMessage);
60
+ resolve(line);
61
+ }
62
+ };
63
+ const onMessage = (data: { toString: (enc: string) => string }): void => {
64
+ for (const line of data.toString('utf8').split(/\r?\n/u)) {
65
+ if (line.length > 0) onLine(line);
66
+ }
67
+ };
68
+ for (const line of this.received) {
69
+ if (check(line)) {
70
+ clearTimeout(timeout);
71
+ resolve(line);
72
+ return;
73
+ }
74
+ }
75
+ this.ws.on('message', onMessage);
76
+ });
77
+ }
78
+
79
+ close(): Promise<void> {
80
+ return new Promise((resolve) => {
81
+ this.ws.once('close', () => resolve());
82
+ this.ws.close();
83
+ });
84
+ }
85
+ }
86
+
87
+ describe('local-cli e2e — oper REHASH re-invokes the bound configLoader', () => {
88
+ let server: LocalServer;
89
+
90
+ beforeAll(async () => {
91
+ server = await startLocalServer({
92
+ port: 0,
93
+ hostname: '127.0.0.1',
94
+ serverName: 'rehash.example.com',
95
+ networkName: 'RehashNet',
96
+ operCreds: [{ user: 'admin', password: 's3cret' }],
97
+ });
98
+ });
99
+
100
+ afterAll(async () => {
101
+ await server.close();
102
+ });
103
+
104
+ it('grants oper then reloads config on REHASH (382 RPL_REHASHING, no error)', async () => {
105
+ const client = new IrcClient(`ws://127.0.0.1:${server.port}/`);
106
+ await client.opened();
107
+ await client.send('NICK oper1');
108
+ await client.send('USER oper1 0 * :Oper');
109
+ await client.waitFor((l) => l.startsWith(':rehash.example.com 001 '));
110
+
111
+ // Oper up so the REHASH oper gate passes.
112
+ await client.send('OPER admin s3cret');
113
+ const operUp = await client.waitFor((l) => l.includes(' 381 '));
114
+ expect(operUp).toContain('You are now an IRC operator');
115
+
116
+ // REHASH — the actor re-invokes the runtime's reloadConfig, which calls
117
+ // the configLoader closure (server.ts:358). A clean `382` (no `failed`)
118
+ // proves the reload succeeded.
119
+ await client.send('REHASH');
120
+ const rehashLine = await client.waitFor((l) => l.includes(' 382 '), 2_000);
121
+ expect(rehashLine).toContain('Rehashing from');
122
+ expect(rehashLine).not.toContain('failed');
123
+
124
+ await client.close();
125
+ });
126
+
127
+ it('rejects REHASH from a non-oper with 481 ERR_NOPRIVILEGES (no reload)', async () => {
128
+ const client = new IrcClient(`ws://127.0.0.1:${server.port}/`);
129
+ await client.opened();
130
+ await client.send('NICK peon');
131
+ await client.send('USER peon 0 * :Peon');
132
+ await client.waitFor((l) => l.startsWith(':rehash.example.com 001 '));
133
+
134
+ await client.send('REHASH');
135
+ const denied = await client.waitFor((l) => l.includes(' 481 '));
136
+ expect(denied).toContain('Permission Denied');
137
+ await client.close();
138
+ });
139
+
140
+ it('directly exercises the configLoader closure via runtime.reloadConfig()', async () => {
141
+ // Independent of the actor path: the closure bound at server.ts:358 must
142
+ // return the live ServerConfig the actor was bootstrapped with.
143
+ const reloaded = await server.runtime.reloadConfig();
144
+ expect(reloaded.serverName).toBe('rehash.example.com');
145
+ expect(reloaded.networkName).toBe('RehashNet');
146
+ });
147
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Unit tests for the small transport/adapter helpers extracted from
3
+ * `server.ts` as test seams. These wrap the genuinely-unreachable defensive
4
+ * branches (peer-IP fallback, listener-port fallback, write-to-unwritable,
5
+ * destroy-already-destroyed) so each branch is deterministically exercisable
6
+ * with a fake — the live WS/TCP integration paths only ever observe the
7
+ * non-defensive arm.
8
+ */
9
+
10
+ import { describe, expect, it, vi } from 'vitest';
11
+ import { listenerPort, peerHost, safeDestroy, safeSocketWrite } from '../src/server';
12
+
13
+ describe('peerHost', () => {
14
+ it('returns the remote address when present', () => {
15
+ expect(peerHost('203.0.113.7')).toBe('203.0.113.7');
16
+ });
17
+
18
+ it("falls back to 'unknown' when no remote address is exposed", () => {
19
+ expect(peerHost(undefined)).toBe('unknown');
20
+ });
21
+ });
22
+
23
+ describe('listenerPort', () => {
24
+ it('reads the port from an AddressInfo-shaped object', () => {
25
+ expect(listenerPort({ port: 6667 }, 0)).toBe(6667);
26
+ });
27
+
28
+ it('falls back when the address is a non-IP transport (string)', () => {
29
+ expect(listenerPort('/run/irc.sock', 7000)).toBe(7000);
30
+ });
31
+
32
+ it('falls back when the address is null', () => {
33
+ expect(listenerPort(null, 7000)).toBe(7000);
34
+ });
35
+ });
36
+
37
+ describe('safeSocketWrite', () => {
38
+ it('writes when the socket is writable', () => {
39
+ const write = vi.fn();
40
+ safeSocketWrite({ writable: true, write }, 'PONG :tok');
41
+ expect(write).toHaveBeenCalledWith('PONG :tok');
42
+ });
43
+
44
+ it('is a silent no-op when the socket is no longer writable', () => {
45
+ const write = vi.fn();
46
+ safeSocketWrite({ writable: false, write }, 'PONG :tok');
47
+ expect(write).not.toHaveBeenCalled();
48
+ });
49
+ });
50
+
51
+ describe('safeDestroy', () => {
52
+ it('destroys a live socket', () => {
53
+ const destroy = vi.fn();
54
+ safeDestroy({ destroyed: false, destroy });
55
+ expect(destroy).toHaveBeenCalledTimes(1);
56
+ });
57
+
58
+ it('is idempotent — skips a socket that is already destroyed', () => {
59
+ const destroy = vi.fn();
60
+ safeDestroy({ destroyed: true, destroy });
61
+ expect(destroy).not.toHaveBeenCalled();
62
+ });
63
+ });
@@ -215,6 +215,24 @@ describe('local-cli TCP transport — IRC flows', () => {
215
215
  await client.close();
216
216
  });
217
217
 
218
+ it('skips an empty line delivered over TCP without dispatching it', async () => {
219
+ // The LineScanner yields an empty string for a bare CRLF; the data
220
+ // handler's `if (line.length === 0) continue` must skip it without
221
+ // feeding an empty IRC line to the actor (server.ts:539).
222
+ const client = new TcpTestClient('127.0.0.1', server.tcpPort as number);
223
+ await client.opened();
224
+ await client.send('NICK blanker');
225
+ await client.send('USER blanker 0 * :Blanker');
226
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
227
+
228
+ // A bare CRLF (blank line) followed by a valid PING: the blank line is
229
+ // ignored and the PING still resolves to a PONG.
230
+ await client.writeRaw('\r\n');
231
+ await client.send('PING :after-blank');
232
+ await client.waitFor((l) => l === 'PONG :after-blank');
233
+ await client.close();
234
+ });
235
+
218
236
  it('processes multiple IRC lines delivered in a single TCP write', async () => {
219
237
  const client = new TcpTestClient('127.0.0.1', server.tcpPort as number);
220
238
  await client.opened();
@@ -367,6 +385,31 @@ describe('local-cli TCP transport — shutdown', () => {
367
385
  expect(client.socket.destroyed).toBe(true);
368
386
  });
369
387
 
388
+ it('close() destroys a still-live registered TCP connection via the close sweep', async () => {
389
+ // Registering the client (awaiting 001) guarantees the server-side
390
+ // connection handler has run and the socket is in `tcpConnections`, so
391
+ // `close()` deterministically enters the close sweep (server.ts:765) and
392
+ // destroys the live socket. (Connecting without registering races the
393
+ // handler under coverage instrumentation.)
394
+ const srv = await startLocalServer({
395
+ port: 0,
396
+ tcpPort: 0,
397
+ hostname: '127.0.0.1',
398
+ serverName: 'irc.local.example.com',
399
+ networkName: 'LocalNet',
400
+ });
401
+ const client = new TcpTestClient('127.0.0.1', srv.tcpPort as number);
402
+ await client.opened();
403
+ await client.send('NICK sweepUser');
404
+ await client.send('USER sweepUser 0 * :Sweep');
405
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
406
+
407
+ const closed = new Promise<void>((resolve) => client.socket.once('close', () => resolve()));
408
+ await srv.close();
409
+ await closed;
410
+ expect(client.socket.destroyed).toBe(true);
411
+ });
412
+
370
413
  it('close() is a no-op for TCP when tcpPort was never enabled', async () => {
371
414
  const srv = await startLocalServer({
372
415
  port: 0,
@@ -408,3 +451,49 @@ describe('local-cli TCP transport — shutdown', () => {
408
451
  await srv.close();
409
452
  });
410
453
  });
454
+
455
+ describe('local-cli TCP transport — admission gate', () => {
456
+ it('emits ERROR :Closing link and destroys the socket when admitConnection denies', async () => {
457
+ // Tight per-IP cap with a permissive rate limit so only the per-IP
458
+ // simultaneous-connection gate refuses the second connection.
459
+ const srv = await startLocalServer({
460
+ port: 0,
461
+ tcpPort: 0,
462
+ hostname: '127.0.0.1',
463
+ serverName: 'admit.example.com',
464
+ networkName: 'AdmitNet',
465
+ maxConnectionsPerIp: 1,
466
+ maxConnectionsPerUser: 1_000,
467
+ perIpConnectionRate: { max: 1_000, windowMs: 60_000 },
468
+ });
469
+
470
+ try {
471
+ // First connection fills the per-IP cap (maxConnectionsPerIp: 1).
472
+ const first = new TcpTestClient('127.0.0.1', srv.tcpPort as number);
473
+ await first.opened();
474
+
475
+ // Second connection from the same IP is denied at the TCP admission
476
+ // gate BEFORE an actor is attached. The server writes the RFC-style
477
+ // ERROR notice then destroys the socket (server.ts 511-517).
478
+ const rejected = new TcpTestClient('127.0.0.1', srv.tcpPort as number);
479
+ const errorLine = await rejected.waitFor((l) => l.startsWith('ERROR :Closing link'), 2_000);
480
+ expect(errorLine).toContain('connection limit reached');
481
+ expect(errorLine).toContain('max_connections_per_ip');
482
+
483
+ // The server destroys the socket after the ERROR notice.
484
+ await new Promise<void>((resolve) => {
485
+ if (rejected.socket.destroyed) resolve();
486
+ else rejected.socket.once('close', () => resolve());
487
+ });
488
+ expect(rejected.socket.destroyed).toBe(true);
489
+
490
+ // The denied socket is removed from the server's tracked set so the
491
+ // runtime admission counters don't leak it.
492
+ expect(srv.testTcpSockets?.has(rejected.socket)).toBe(false);
493
+
494
+ await first.close();
495
+ } finally {
496
+ await srv.close();
497
+ }
498
+ });
499
+ });
@@ -171,6 +171,20 @@ describe('local-cli IRCv3 WebSocket subprotocol', () => {
171
171
  await client.close();
172
172
  });
173
173
 
174
+ it('falls back to legacy framing when only unsupported protocols are offered', async () => {
175
+ // `selectSubprotocol` returns undefined for an all-unsupported offer, so
176
+ // `handleProtocols` returns false and the ws library completes the
177
+ // handshake with no negotiated subprotocol. Exercises the
178
+ // `?? false` fallback in handleProtocols. The offering client then
179
+ // aborts ("Server sent no subprotocol"), which is expected — the
180
+ // server-side branch is what this covers.
181
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, ['chat']);
182
+ client.ws.on('error', () => {
183
+ // Expected: server returned no subprotocol for an all-unsupported offer.
184
+ });
185
+ await expect(client.opened()).rejects.toThrow(/no subprotocol/iu);
186
+ });
187
+
174
188
  it('completes registration with one IRC message per WebSocket message (text mode)', async () => {
175
189
  const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
176
190
  await client.opened();
@@ -260,3 +274,81 @@ describe('local-cli IRCv3 WebSocket subprotocol', () => {
260
274
  });
261
275
  }
262
276
  });
277
+
278
+ describe('local-cli WebSocket — transport seams', () => {
279
+ let server: LocalServer;
280
+
281
+ beforeAll(async () => {
282
+ server = await startLocalServer({
283
+ port: 0,
284
+ hostname: '127.0.0.1',
285
+ serverName: 'seam.example.com',
286
+ networkName: 'SeamNet',
287
+ });
288
+ });
289
+
290
+ afterAll(async () => {
291
+ await server.close();
292
+ });
293
+
294
+ it('logs a spec-mode actor failure without crashing the server', async () => {
295
+ // Register normally first so the connection has a nick, then swap the
296
+ // runtime send to throw — the next inbound message's dispatch rejects
297
+ // inside receiveTextFrame and the spec-mode `.catch` (server.ts:453)
298
+ // routes it to logError instead of crashing the server.
299
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
300
+ await client.opened();
301
+ await client.send('NICK specErr');
302
+ await client.send('USER specErr 0 * :Spec Err');
303
+ await client.waitFor((l) => l.startsWith(':seam.example.com 001 '));
304
+
305
+ const original = server.runtime.send.bind(server.runtime);
306
+ server.runtime.send = async () => {
307
+ throw new Error('synthetic spec send failure');
308
+ };
309
+ try {
310
+ // PING dispatches a Send effect → synthetic throw → actor catch.
311
+ await client.send('PING :will-throw');
312
+ await new Promise((r) => setTimeout(r, 60));
313
+ } finally {
314
+ server.runtime.send = original;
315
+ }
316
+
317
+ // The server must still serve fresh spec-mode connections.
318
+ const next = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
319
+ await next.opened();
320
+ await next.send('NICK specRecover');
321
+ await next.send('USER specRecover 0 * :Spec Recover');
322
+ await next.waitFor((l) => l.startsWith(':seam.example.com 001 '), 2_000);
323
+ await next.close();
324
+ await client.close();
325
+ });
326
+
327
+ it('drops an outbound send to a WebSocket that is no longer OPEN', async () => {
328
+ // Register, then transition the server-side ws to CLOSING. The runtime
329
+ // still tracks the connection until the 'close' event fires on a later
330
+ // tick, so a synchronous dispatch hits the `ws.readyState !== OPEN`
331
+ // guard and returns before calling ws.send (server.ts:423).
332
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
333
+ await client.opened();
334
+ await client.send('NICK dropSend');
335
+ await client.send('USER dropSend 0 * :Drop');
336
+ await client.waitFor((l) => l.startsWith(':seam.example.com 001 '));
337
+
338
+ const connId = await server.runtime.lookupNick('dropSend');
339
+ expect(connId).not.toBeNull();
340
+ const serverWs = [...server.testSockets].find((ws) => ws.readyState === WebSocket.OPEN);
341
+ expect(serverWs).toBeDefined();
342
+ serverWs?.close();
343
+ // Synchronous dispatch before the 'close' cleanup runs: the send handler
344
+ // must observe readyState !== OPEN and no-op without throwing.
345
+ await expect(
346
+ server.runtime.send(connId as string, [{ text: ':seam NOTICE dropSend :dropped' }]),
347
+ ).resolves.toBeUndefined();
348
+
349
+ await new Promise<void>((resolve) => {
350
+ client.ws.once('close', () => resolve());
351
+ client.ws.close();
352
+ });
353
+ });
354
+ });