serverless-ircd 0.4.0 → 0.5.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 (124) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/README.md +88 -19
  3. package/apps/aws-stack/README.md +4 -3
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/aws-stack/src/aws-stack.ts +32 -4
  6. package/apps/aws-stack/tests/stack.test.ts +47 -1
  7. package/apps/cf-tcp-container/package.json +1 -1
  8. package/apps/cf-worker/package.json +1 -1
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/server.ts +129 -21
  11. package/apps/local-cli/tests/e2e.test.ts +1 -1
  12. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  13. package/package.json +2 -2
  14. package/packages/aws-adapter/package.json +1 -1
  15. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  16. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  17. package/packages/aws-adapter/src/handlers/default.ts +63 -5
  18. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  19. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  20. package/packages/aws-adapter/src/index.ts +2 -0
  21. package/packages/aws-adapter/src/serialize.ts +11 -1
  22. package/packages/aws-adapter/src/stats.ts +80 -0
  23. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  24. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  25. package/packages/aws-adapter/tests/connect.test.ts +97 -1
  26. package/packages/aws-adapter/tests/handlers.test.ts +148 -0
  27. package/packages/aws-adapter/tests/nlb-stream.test.ts +2 -0
  28. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  29. package/packages/cf-adapter/package.json +5 -1
  30. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  31. package/packages/cf-adapter/src/channel-do.ts +2 -2
  32. package/packages/cf-adapter/src/connection-do.ts +182 -54
  33. package/packages/cf-adapter/src/env.ts +25 -6
  34. package/packages/cf-adapter/src/index.ts +2 -0
  35. package/packages/cf-adapter/src/registry-do.ts +22 -3
  36. package/packages/cf-adapter/src/sharding.ts +1 -2
  37. package/packages/cf-adapter/src/stats.ts +65 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  41. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  42. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  43. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  44. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  45. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  46. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  47. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  48. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  49. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  50. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  51. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  52. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  53. package/packages/cf-adapter/wrangler.test.toml +7 -0
  54. package/packages/in-memory-runtime/package.json +1 -1
  55. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  56. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  57. package/packages/irc-core/package.json +1 -1
  58. package/packages/irc-core/src/admission.ts +16 -15
  59. package/packages/irc-core/src/caps/capabilities.ts +1 -1
  60. package/packages/irc-core/src/commands/index.ts +8 -0
  61. package/packages/irc-core/src/commands/invite.ts +2 -4
  62. package/packages/irc-core/src/commands/isupport.ts +6 -2
  63. package/packages/irc-core/src/commands/kick.ts +2 -4
  64. package/packages/irc-core/src/commands/kill.ts +127 -0
  65. package/packages/irc-core/src/commands/list.ts +1 -1
  66. package/packages/irc-core/src/commands/lusers.ts +204 -0
  67. package/packages/irc-core/src/commands/mode.ts +4 -8
  68. package/packages/irc-core/src/commands/names.ts +3 -5
  69. package/packages/irc-core/src/commands/part.ts +2 -4
  70. package/packages/irc-core/src/commands/rehash.ts +119 -0
  71. package/packages/irc-core/src/commands/setname.ts +109 -0
  72. package/packages/irc-core/src/commands/stats.ts +152 -0
  73. package/packages/irc-core/src/commands/topic.ts +2 -4
  74. package/packages/irc-core/src/commands/trace.ts +137 -0
  75. package/packages/irc-core/src/commands/wallops.ts +118 -0
  76. package/packages/irc-core/src/config.ts +7 -0
  77. package/packages/irc-core/src/effects.ts +27 -1
  78. package/packages/irc-core/src/index.ts +2 -0
  79. package/packages/irc-core/src/ports.ts +179 -0
  80. package/packages/irc-core/src/protocol/numerics.ts +42 -11
  81. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  82. package/packages/irc-core/src/types.ts +8 -1
  83. package/packages/irc-core/src/ws-framing.ts +132 -0
  84. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  85. package/packages/irc-core/tests/admission.test.ts +18 -0
  86. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  87. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  88. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  89. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  90. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  91. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  92. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  93. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  94. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  95. package/packages/irc-core/tests/effects.test.ts +14 -0
  96. package/packages/irc-core/tests/numerics.test.ts +90 -0
  97. package/packages/irc-core/tests/outbound.test.ts +51 -0
  98. package/packages/irc-core/tests/ports.test.ts +22 -0
  99. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  100. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  101. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  102. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  103. package/packages/irc-server/package.json +1 -1
  104. package/packages/irc-server/src/actor.ts +249 -16
  105. package/packages/irc-server/src/dispatch.ts +1 -0
  106. package/packages/irc-server/src/routing.ts +3 -0
  107. package/packages/irc-server/src/runtime.ts +31 -0
  108. package/packages/irc-server/src/transport.ts +10 -7
  109. package/packages/irc-server/tests/actor.test.ts +1089 -1
  110. package/packages/irc-server/tests/dispatch.test.ts +37 -0
  111. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  112. package/packages/irc-server/tests/routing.test.ts +1 -0
  113. package/packages/irc-server/tests/runtime.test.ts +7 -0
  114. package/packages/irc-test-support/package.json +1 -1
  115. package/packages/irc-test-support/src/scenarios.ts +9 -1
  116. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  117. package/pnpm-workspace.yaml +1 -0
  118. package/tools/ci-hardening/package.json +1 -1
  119. package/tools/package.json +6 -1
  120. package/tools/seed-cf-accounts.ts +4 -1
  121. package/tools/tcp-ws-forwarder/package.json +1 -1
  122. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  123. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  124. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -18,6 +18,9 @@ function fakeRuntime(calls: RecordedCall[]): IrcRuntime {
18
18
  async broadcast(chan, lines, except) {
19
19
  calls.push({ method: 'broadcast', args: [chan, lines, except] });
20
20
  },
21
+ async broadcastWallops(lines, except) {
22
+ calls.push({ method: 'broadcastWallops', args: [lines, except] });
23
+ },
21
24
  async disconnect(conn, reason) {
22
25
  calls.push({ method: 'disconnect', args: [conn, reason] });
23
26
  },
@@ -60,6 +63,10 @@ function fakeRuntime(calls: RecordedCall[]): IrcRuntime {
60
63
  async listChannels() {
61
64
  return [];
62
65
  },
66
+ async reloadConfig() {
67
+ calls.push({ method: 'reloadConfig', args: [] });
68
+ throw new Error('reloadConfig not configured for this fake');
69
+ },
63
70
  };
64
71
  }
65
72
 
@@ -126,6 +133,24 @@ describe('dispatch', () => {
126
133
  });
127
134
  });
128
135
 
136
+ it('routes BroadcastWallops with lines and except', async () => {
137
+ const calls: RecordedCall[] = [];
138
+ await dispatch([Effect.broadcastWallops([L(':a WALLOPS :hi')], 'c1')], fakeRuntime(calls));
139
+ expect(calls[0]).toEqual({
140
+ method: 'broadcastWallops',
141
+ args: [[{ text: ':a WALLOPS :hi' }], 'c1'],
142
+ });
143
+ });
144
+
145
+ it('routes BroadcastWallops preserving undefined except', async () => {
146
+ const calls: RecordedCall[] = [];
147
+ await dispatch([Effect.broadcastWallops([L(':a WALLOPS :hi')])], fakeRuntime(calls));
148
+ expect(calls[0]).toEqual({
149
+ method: 'broadcastWallops',
150
+ args: [[{ text: ':a WALLOPS :hi' }], undefined],
151
+ });
152
+ });
153
+
129
154
  it('calls reserveNick, changeNick, releaseNick in order', async () => {
130
155
  const calls: RecordedCall[] = [];
131
156
  await dispatch(
@@ -247,6 +272,7 @@ function capAwareRuntime(members: ReadonlyMap<ConnId, MemberInfo>): {
247
272
  async broadcast(chan, lines, except) {
248
273
  broadcasts.push({ chan, lines, except });
249
274
  },
275
+ async broadcastWallops() {},
250
276
  async disconnect() {},
251
277
  async sendToNick() {},
252
278
  async reserveNick() {
@@ -304,6 +330,9 @@ function capAwareRuntime(members: ReadonlyMap<ConnId, MemberInfo>): {
304
330
  async listChannels() {
305
331
  return [];
306
332
  },
333
+ async reloadConfig() {
334
+ throw new Error('reloadConfig not configured for this fake');
335
+ },
307
336
  };
308
337
  return { runtime, sends, broadcasts };
309
338
  }
@@ -382,6 +411,7 @@ describe('dispatch — Broadcast cap / capLines', () => {
382
411
  sends.push({ to, lines });
383
412
  },
384
413
  async broadcast() {},
414
+ async broadcastWallops() {},
385
415
  async disconnect() {},
386
416
  async sendToNick() {},
387
417
  async reserveNick() {
@@ -410,6 +440,9 @@ describe('dispatch — Broadcast cap / capLines', () => {
410
440
  async listChannels() {
411
441
  return [];
412
442
  },
443
+ async reloadConfig() {
444
+ throw new Error('reloadConfig not configured for this fake');
445
+ },
413
446
  };
414
447
 
415
448
  await dispatch([Effect.broadcast('#nope', [L('x')], undefined, 'chghost')], runtime);
@@ -425,6 +458,7 @@ describe('dispatch — Broadcast cap / capLines', () => {
425
458
  sends.push({ to, lines });
426
459
  },
427
460
  async broadcast() {},
461
+ async broadcastWallops() {},
428
462
  async disconnect() {},
429
463
  async sendToNick() {},
430
464
  async reserveNick() {
@@ -483,6 +517,9 @@ describe('dispatch — Broadcast cap / capLines', () => {
483
517
  async listChannels() {
484
518
  return [];
485
519
  },
520
+ async reloadConfig() {
521
+ throw new Error('reloadConfig not configured for this fake');
522
+ },
486
523
  };
487
524
 
488
525
  await dispatch(
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Ambient declaration so `?raw` imports (Vite's built-in transform that
3
+ * inlines a file's text at bundle time) typecheck under the strict
4
+ * `tsconfig.test.json`. Used by source-level regression guards that
5
+ * assert the actor default-branch triage stays accurate.
6
+ */
7
+ declare module '*?raw' {
8
+ const content: string;
9
+ // eslint-disable-next-line import/no-default-export
10
+ export default content;
11
+ }
@@ -131,6 +131,7 @@ describe('buildRoutedReducers', () => {
131
131
  'privmsgChannel',
132
132
  'privmsgUser',
133
133
  'quit',
134
+ 'setname',
134
135
  'tagmsgChannel',
135
136
  'tagmsgUser',
136
137
  'time',
@@ -34,6 +34,10 @@ describe('IrcRuntime contract', () => {
34
34
  async listChannels() {
35
35
  return [];
36
36
  },
37
+ async reloadConfig() {
38
+ throw new Error('reloadConfig not configured for this fake');
39
+ },
40
+ async broadcastWallops() {},
37
41
  };
38
42
  expect(runtime).toBeDefined();
39
43
  expect(typeof runtime.send).toBe('function');
@@ -48,5 +52,8 @@ describe('IrcRuntime contract', () => {
48
52
  expect(typeof runtime.getConnectionInfo).toBe('function');
49
53
  expect(typeof runtime.getConnection).toBe('function');
50
54
  expect(typeof runtime.getChannelSnapshot).toBe('function');
55
+ expect(typeof runtime.listChannels).toBe('function');
56
+ expect(typeof runtime.reloadConfig).toBe('function');
57
+ expect(typeof runtime.broadcastWallops).toBe('function');
51
58
  });
52
59
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-test-support",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": true,
5
5
  "description": "Shared IRC scenario runner + harness seam used by every adapter's integration suite",
6
6
  "license": "BSD-3-Clause",
@@ -192,7 +192,15 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
192
192
  await alice.waitForLine((l) => l.includes('JOIN #bar'));
193
193
  alice.received.length = 0;
194
194
  await alice.send('JOIN 0');
195
- await alice.waitForLine((l) => l.includes('PART'));
195
+ // The two PART echoes arrive as separate deliveries (one per
196
+ // channel, with an ApplyChannelDelta in between). On distributed
197
+ // runtimes (cf-adapter) they land in distinct WS messages with
198
+ // cross-DO latency between them, so waiting for the first generic
199
+ // PART and then snapshotting `received` races the second one.
200
+ // Wait for each channel's PART explicitly to make the observation
201
+ // deterministic across in-memory and DO-backed runtimes.
202
+ await alice.waitForLine((l) => l.includes('PART') && l.includes('#foo'));
203
+ await alice.waitForLine((l) => l.includes('PART') && l.includes('#bar'));
196
204
  const parts = alice.received.filter((l) => l.includes('PART'));
197
205
  expect(parts.length).toBe(2);
198
206
  expect(parts.some((l) => l.includes('#foo'))).toBe(true);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Parametrized IRC scenarios — 032 + 057.
2
+ * Parametrized IRC scenarios.
3
3
  *
4
4
  * The scenario bodies live in `src/scenarios.ts` (now re-exported as
5
5
  * `runIrcScenarios`) so adapter packages can register their own
@@ -1,6 +1,7 @@
1
1
  packages:
2
2
  - "packages/*"
3
3
  - "apps/*"
4
+ - "tools"
4
5
  - "tools/*"
5
6
  linkWorkspacePackages: true
6
7
  allowBuilds:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/ci-hardening",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": true,
5
5
  "description": "CI hardening helpers: coverage-gate config validator + mutation-testing driver for irc-core",
6
6
  "license": "BSD-3-Clause",
@@ -1,4 +1,9 @@
1
1
  {
2
+ "name": "@serverless-ircd/tools",
2
3
  "private": true,
3
- "type": "module"
4
+ "type": "module",
5
+ "dependencies": {
6
+ "@serverless-ircd/aws-adapter": "workspace:*",
7
+ "@serverless-ircd/cf-adapter": "workspace:*"
8
+ }
4
9
  }
@@ -33,7 +33,10 @@
33
33
  import { execFileSync } from 'node:child_process';
34
34
  import { readFileSync } from 'node:fs';
35
35
  import { parseArgs } from 'node:util';
36
- import { CREATE_ACCOUNTS_TABLE_SQL, buildAccountPutStatement } from '@serverless-ircd/cf-adapter';
36
+ import {
37
+ CREATE_ACCOUNTS_TABLE_SQL,
38
+ buildAccountPutStatement,
39
+ } from '@serverless-ircd/cf-adapter/accounts';
37
40
 
38
41
  const { values } = parseArgs({
39
42
  options: {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/tcp-ws-forwarder",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": true,
5
5
  "description": "Local TCP to ws/wss forwarder so standard IRC clients can reach the WebSocket-only ServerlessIRCd transport",
6
6
  "license": "BSD-3-Clause",
@@ -11,16 +11,25 @@
11
11
  * IRC client ──TCP──▶ forwarder ──WS──▶ ServerlessIRCd endpoint
12
12
  * IRC client ◀─TCP── forwarder ◀─WS── ServerlessIRCd endpoint
13
13
  *
14
+ * IRCv3 WebSocket subprotocol: every dial offers
15
+ * `['binary.ircv3.net', 'text.ircv3.net']` so a spec-capable upstream
16
+ * (e.g. the CF `ConnectionDO`) agrees on one-message-per-frame framing. A
17
+ * server that supports neither simply leaves the subprotocol unset and the
18
+ * bridge keeps working with its tolerant line-split framing.
19
+ *
14
20
  * Framing:
15
21
  * • TCP→WS: the TCP byte stream is reassembled into IRC lines with the
16
22
  * byte-accurate {@link LineScanner} (tolerant of CRLF and bare LF per
17
23
  * PLAN §9). Each complete line is sent as one bare WebSocket text frame
18
24
  * — the canonical "one message per frame" convention the server's
19
- * `ConnectionActor` expects.
25
+ * `ConnectionActor` expects — with any trailing CR-LF stripped by
26
+ * {@link toWsMessagePayload} per the IRCv3 spec ("no trailing CR-LF on
27
+ * the wire").
20
28
  * • WS→TCP: each inbound WS frame is split on line terminators and every
21
29
  * non-empty line is written back to the TCP socket with a trailing
22
- * CRLF, normalizing whatever framing the server used (single message,
23
- * `\r\n`-joined, or bare) into canonical IRC wire format.
30
+ * CRLF via {@link toTcpWire}, normalizing whatever framing the server
31
+ * used (single message, `\r\n`-joined, or bare) into canonical IRC wire
32
+ * format.
24
33
  *
25
34
  * Lifetime: closing either side tears down the whole bridge. The forwarder
26
35
  * itself is transport-agnostic and has no `@serverless-ircd/*` dependency.
@@ -31,6 +40,16 @@ import { type RawData, WebSocket } from 'ws';
31
40
  import { LineScanner } from './line-scanner.js';
32
41
  import { ConsoleLogger, LogLevel, type Logger } from './logger.js';
33
42
 
43
+ /**
44
+ * IRCv3 WebSocket subprotocols the forwarder offers when dialing the upstream
45
+ * IRCd, in preference order. `binary.ircv3.net` is offered first so a
46
+ * spec-capable server agrees on binary framing; `text.ircv3.net` is the
47
+ * fallback. A server that supports neither (e.g. a legacy deployment) leaves
48
+ * the subprotocol unset and the bridge keeps working with its tolerant
49
+ * line-split framing.
50
+ */
51
+ export const FORWARDER_WS_SUBPROTOCOLS = ['binary.ircv3.net', 'text.ircv3.net'] as const;
52
+
34
53
  /** Options accepted by {@link startForwarder}. */
35
54
  export interface StartForwarderOptions {
36
55
  /** TCP port to listen on. Use 0 for an ephemeral port. */
@@ -157,7 +176,7 @@ function bridgeConnection(
157
176
  logger: Logger,
158
177
  connId: number,
159
178
  ): () => void {
160
- const ws = new WebSocket(targetHref);
179
+ const ws = new WebSocket(targetHref, [...FORWARDER_WS_SUBPROTOCOLS]);
161
180
  const outgoing = new LineScanner();
162
181
  const pending: string[] = [];
163
182
  let wsReady = false;
@@ -167,8 +186,8 @@ function bridgeConnection(
167
186
  logger.info('bridge.accepted', { conn: connId, remote });
168
187
 
169
188
  const sendLine = (line: string): void => {
170
- if (wsReady) ws.send(line);
171
- else pending.push(line);
189
+ if (wsReady) ws.send(toWsMessagePayload(line));
190
+ else pending.push(toWsMessagePayload(line));
172
191
  };
173
192
 
174
193
  tcp.on('data', (chunk: Buffer) => {
@@ -191,9 +210,7 @@ function bridgeConnection(
191
210
  ws.on('message', (data: RawData) => {
192
211
  const text = toBuffer(data).toString('utf8');
193
212
  logger.debug('ws.frame', { conn: connId, frame: text });
194
- for (const line of splitFrameLines(text)) {
195
- if (line.length > 0) tcp.write(`${line}\r\n`);
196
- }
213
+ for (const chunk of toTcpWire(text)) tcp.write(chunk);
197
214
  });
198
215
 
199
216
  const teardown = (reason: CloseReason): void => {
@@ -241,6 +258,37 @@ export function splitFrameLines(text: string): string[] {
241
258
  return text.split(/\r?\n/u);
242
259
  }
243
260
 
261
+ /**
262
+ * Translates a TCP IRC line into the WebSocket message payload for the
263
+ * upstream send direction. Strips a single trailing CR-LF (or bare LF) so
264
+ * the WS message carries the bare IRC line, per the IRCv3 WebSocket spec
265
+ * ("no trailing CR-LF on the wire"). Idempotent on already-bare lines
266
+ * (the {@link LineScanner} output), so callers can apply it unconditionally
267
+ * without inspecting the line first. Exported so the CRLF-strip contract is
268
+ * unit-testable in isolation.
269
+ */
270
+ export function toWsMessagePayload(line: string): string {
271
+ if (line.endsWith('\r\n')) return line.slice(0, -2);
272
+ if (line.endsWith('\n')) return line.slice(0, -1);
273
+ return line;
274
+ }
275
+
276
+ /**
277
+ * Translates an inbound WebSocket message payload into the CRLF-terminated
278
+ * TCP chunk(s) to write back to the IRC client. Each non-empty IRC line is
279
+ * returned as its own entry ending in CRLF, ready for `socket.write`. A
280
+ * single trailing CR-LF on the message (defensive against servers that emit
281
+ * one) is collapsed rather than doubled. Exported so the CRLF-re-append
282
+ * contract is unit-testable in isolation.
283
+ */
284
+ export function toTcpWire(text: string): string[] {
285
+ const out: string[] = [];
286
+ for (const line of splitFrameLines(text)) {
287
+ if (line.length > 0) out.push(`${line}\r\n`);
288
+ }
289
+ return out;
290
+ }
291
+
244
292
  /**
245
293
  * Normalizes a `ws` `RawData` payload into a single UTF-8 `Buffer`. Exported
246
294
  * so the `ArrayBuffer` / `Uint8Array` / `Buffer[]` variants — which ws only
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from 'node:events';
2
- import http from 'node:http';
2
+ import http, { type IncomingHttpHeaders } from 'node:http';
3
3
  import { type Socket, createConnection, createServer as createNetServer } from 'node:net';
4
4
  import type { AddressInfo } from 'node:net';
5
5
  import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -51,6 +51,12 @@ interface Target {
51
51
  readonly frames: string[];
52
52
  readonly connections: WebSocket[];
53
53
  waitForConnection(): Promise<WebSocket>;
54
+ /**
55
+ * Resolves the next inbound upgrade request, in arrival order. Used to
56
+ * assert headers the forwarder sends during the WS handshake (e.g. the
57
+ * offered `Sec-WebSocket-Protocol`).
58
+ */
59
+ waitForUpgradeRequest(): Promise<{ headers: IncomingHttpHeaders }>;
54
60
  close(): Promise<void>;
55
61
  }
56
62
 
@@ -69,8 +75,14 @@ function startDelayedTarget(delayMs: number): Promise<Target> {
69
75
  // arrival order; queue waiters that resolve one-for-one as connections land.
70
76
  const waiters: Array<(ws: WebSocket) => void> = [];
71
77
  let waitForCount = 0;
78
+ // Likewise for upgrade requests, captured before the WS is handed off so
79
+ // headers can be asserted even when the caller never awaits a connection.
80
+ const upgradeReqWaiters: Array<(req: { headers: IncomingHttpHeaders }) => void> = [];
72
81
 
73
82
  server.on('upgrade', (req, socket, head) => {
83
+ const captured = { headers: req.headers };
84
+ const waiter = upgradeReqWaiters.shift();
85
+ if (waiter) waiter(captured);
74
86
  const finish = () => {
75
87
  wss.handleUpgrade(req, socket, head, (ws) => {
76
88
  connections.push(ws);
@@ -97,6 +109,10 @@ function startDelayedTarget(delayMs: number): Promise<Target> {
97
109
  if (connections.length > want) res(connections[want] as WebSocket);
98
110
  else waiters.push(res);
99
111
  }),
112
+ waitForUpgradeRequest: () =>
113
+ new Promise((res) => {
114
+ upgradeReqWaiters.push(res);
115
+ }),
100
116
  close: () =>
101
117
  new Promise<void>((r) => {
102
118
  for (const ws of connections) ws.close();
@@ -260,6 +276,23 @@ describe('startForwarder — lifecycle', () => {
260
276
  openServers.push(fwd);
261
277
  expect(fwd.targetUrl).toBe('wss://example.invalid/');
262
278
  });
279
+
280
+ it('offers binary.ircv3.net and text.ircv3.net in the upstream WS handshake', async () => {
281
+ const target = await startTarget();
282
+ openServers.push(target);
283
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
284
+ openServers.push(fwd);
285
+
286
+ const client = new TcpClient(fwd.listenPort);
287
+ openClients.push(client);
288
+ await client.opened();
289
+
290
+ const req = await target.waitForUpgradeRequest();
291
+ const offered = req.headers['sec-websocket-protocol'];
292
+ expect(typeof offered).toBe('string');
293
+ const list = (offered as string).split(',').map((s) => s.trim());
294
+ expect(list).toEqual(['binary.ircv3.net', 'text.ircv3.net']);
295
+ });
263
296
  });
264
297
 
265
298
  describe('forwarder — TCP→WS framing', () => {
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { RawData } from 'ws';
3
- import { splitFrameLines, toBuffer } from '../src/forwarder';
3
+ import {
4
+ FORWARDER_WS_SUBPROTOCOLS,
5
+ splitFrameLines,
6
+ toBuffer,
7
+ toTcpWire,
8
+ toWsMessagePayload,
9
+ } from '../src/forwarder';
4
10
 
5
11
  /**
6
12
  * Pure-function unit tests for the WS-side framing helpers. These edges
@@ -49,3 +55,61 @@ describe('toBuffer', () => {
49
55
  expect(toBuffer(src as RawData).toString('utf8')).toBe('ABC');
50
56
  });
51
57
  });
58
+
59
+ describe('FORWARDER_WS_SUBPROTOCOLS', () => {
60
+ it('offers binary.ircv3.net first (preferred)', () => {
61
+ expect(FORWARDER_WS_SUBPROTOCOLS[0]).toBe('binary.ircv3.net');
62
+ });
63
+
64
+ it('offers text.ircv3.net as the fallback', () => {
65
+ expect(FORWARDER_WS_SUBPROTOCOLS).toContain('text.ircv3.net');
66
+ });
67
+
68
+ it('offers exactly the two IRCv3 subprotocols', () => {
69
+ expect([...FORWARDER_WS_SUBPROTOCOLS].sort()).toEqual(['binary.ircv3.net', 'text.ircv3.net']);
70
+ });
71
+ });
72
+
73
+ describe('toWsMessagePayload — TCP line → WS message (CRLF stripped)', () => {
74
+ it('strips a trailing CRLF from a TCP IRC line', () => {
75
+ expect(toWsMessagePayload('PING :tok\r\n')).toBe('PING :tok');
76
+ });
77
+
78
+ it('strips a trailing bare LF', () => {
79
+ expect(toWsMessagePayload('PING :bare\n')).toBe('PING :bare');
80
+ });
81
+
82
+ it('leaves an already-bare line unchanged (idempotent on LineScanner output)', () => {
83
+ expect(toWsMessagePayload('NICK alice')).toBe('NICK alice');
84
+ });
85
+
86
+ it('strips only one trailing terminator, keeping embedded content', () => {
87
+ expect(toWsMessagePayload('PRIVMSG #foo :a\r\nb\r\n')).toBe('PRIVMSG #foo :a\r\nb');
88
+ });
89
+
90
+ it('returns the empty string unchanged', () => {
91
+ expect(toWsMessagePayload('')).toBe('');
92
+ });
93
+ });
94
+
95
+ describe('toTcpWire — WS message → TCP bytes (CRLF re-appended)', () => {
96
+ it('appends CRLF to a bare spec-mode WS message', () => {
97
+ expect(toTcpWire(':srv 001 nick :Welcome')).toEqual([':srv 001 nick :Welcome\r\n']);
98
+ });
99
+
100
+ it('collapses a single trailing CRLF instead of doubling it', () => {
101
+ expect(toTcpWire(':srv 001 nick :Hi\r\n')).toEqual([':srv 001 nick :Hi\r\n']);
102
+ });
103
+
104
+ it('emits no bytes for an empty WS message', () => {
105
+ expect(toTcpWire('')).toEqual([]);
106
+ });
107
+
108
+ it('emits no bytes for a message that is only a terminator', () => {
109
+ expect(toTcpWire('\r\n')).toEqual([]);
110
+ });
111
+
112
+ it('re-appends CRLF to each line of a legacy joined multi-message frame', () => {
113
+ expect(toTcpWire(':srv 001\r\n:srv 376')).toEqual([':srv 001\r\n', ':srv 376\r\n']);
114
+ });
115
+ });