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
@@ -8,6 +8,11 @@
8
8
  * • a `ws.WebSocketServer` on {@link StartServerOptions.port} — the
9
9
  * original WebSocket transport (one IRC message per text frame, or N
10
10
  * `\r\n`-joined messages per frame). Programmatic test clients use this.
11
+ * When a client negotiates an IRCv3 subprotocol
12
+ * (`text.ircv3.net` / `binary.ircv3.net`) the connection switches to
13
+ * spec framing: one IRC message per WebSocket message with no trailing
14
+ * CR-LF, a 510-byte inbound budget, and (for text) lone-surrogate
15
+ * sanitization. Un-negotiated clients keep the legacy framing.
11
16
  * • an optional `node:net` TCP server on {@link StartServerOptions.tcpPort}
12
17
  * — the RFC-style byte stream that real IRC clients (WeeChat, HexChat,
13
18
  * IRCCloud) speak. The TCP path uses {@link LineScanner} to reassemble
@@ -41,9 +46,19 @@ import {
41
46
  type MessageStore,
42
47
  type NickHistoryStore,
43
48
  type RawLine,
49
+ type ServerConfig,
50
+ WS_SUBPROTO_BINARY,
51
+ WS_SUBPROTO_TEXT,
52
+ type WsFrameMode,
53
+ type WsSubprotocol,
44
54
  createConnection,
55
+ frameToLines,
56
+ isWithinWsByteBudget,
57
+ sanitizeForTextMode,
58
+ selectSubprotocol,
59
+ wsFrameModeFor,
45
60
  } from '@serverless-ircd/irc-core';
46
- import { ConnectionActor } from '@serverless-ircd/irc-server';
61
+ import { ConnectionActor, type Transport } from '@serverless-ircd/irc-server';
47
62
  import { WebSocket, WebSocketServer } from 'ws';
48
63
  import { loadServerConfigFromCliArgs } from './config-loader.js';
49
64
  import { LineScanner } from './line-scanner.js';
@@ -73,6 +88,28 @@ type ResolvedServerConfig = Omit<
73
88
  | 'saslAccounts'
74
89
  >;
75
90
 
91
+ /**
92
+ * Builds the reducer-facing {@link ServerConfig} from the resolved local-cli
93
+ * config. Shared by {@link attachConnection} (initial config) and the
94
+ * `InMemoryRuntime` reload source (REHASH) so a reload returns the same
95
+ * shape the actor was bootstrapped with — keeping the two paths consistent.
96
+ */
97
+ function serverConfigFromResolved(cfg: ResolvedServerConfig): ServerConfig {
98
+ return {
99
+ serverName: cfg.serverName,
100
+ networkName: cfg.networkName,
101
+ maxChannelsPerUser: cfg.maxChannelsPerUser,
102
+ maxTargetsPerCommand: cfg.maxTargetsPerCommand,
103
+ maxListEntries: cfg.maxListEntries,
104
+ nickLen: cfg.nickLen,
105
+ channelLen: cfg.channelLen,
106
+ topicLen: cfg.topicLen,
107
+ quitMessage: cfg.quitMessage,
108
+ ...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
109
+ ...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
110
+ };
111
+ }
112
+
76
113
  /** Default per-IP / per-user caps used when callers don't override. */
77
114
  const DEFAULT_MAX_CONNECTIONS_PER_IP = 10;
78
115
  const DEFAULT_MAX_CONNECTIONS_PER_USER = 5;
@@ -216,7 +253,7 @@ const DEFAULT_CONFIG: Omit<
216
253
  * the three generators once at module load, not per-connection. No reducer in
217
254
  * the current codebase actually invokes `batchId` / `nonce` / `sessionId`;
218
255
  * they are forward-looking seams for SASL / BATCH / context-restoration
219
- * tickets and are exercised by irc-core's own unit tests via test fakes.
256
+ * and are exercised by irc-core's own unit tests via test fakes.
220
257
  *
221
258
  * Exported so `apps/local-cli` can ship a focused unit test asserting each
222
259
  * generator returns a fresh UUID-shaped string (defending against future
@@ -312,6 +349,10 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
312
349
  const runtime = new InMemoryRuntime({
313
350
  clock: { now: () => Date.now() },
314
351
  ...(admission !== undefined ? { admission } : {}),
352
+ // REHASH reload source: re-derive the live ServerConfig from the
353
+ // resolved config so an oper's `REHASH` re-fetches the latest values
354
+ // (rotated oper creds, MOTD, limits) without restarting the process.
355
+ configLoader: async () => serverConfigFromResolved(cfg),
315
356
  });
316
357
 
317
358
  // Per-process chat-history store: one ring buffer shared by every
@@ -333,8 +374,23 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
333
374
  ? new InMemoryAccountStore(cfg.saslAccounts)
334
375
  : undefined;
335
376
 
336
- const wss = new WebSocketServer({ port: opts.port, host: hostname });
377
+ const wss = new WebSocketServer({
378
+ port: opts.port,
379
+ host: hostname,
380
+ // IRCv3 WebSocket subprotocol negotiation: select the first supported
381
+ // offer in client-preference order (core selector), or fall through to
382
+ // legacy framing when none match. The `ws` library only invokes this
383
+ // when the client offered protocols; returning the selected name echoes
384
+ // it, returning false completes the handshake with no
385
+ // Sec-WebSocket-Protocol header (legacy) — it never rejects.
386
+ handleProtocols: (protocols: Set<string>): string | false =>
387
+ selectSubprotocol([...protocols]) ?? false,
388
+ });
337
389
  const wsConnections = new Map<WebSocket, ConnectionBindings>();
390
+ // Per-connection negotiated IRCv3 subprotocol registry (null = legacy
391
+ // framing). Mirrors the selection made in `handleProtocols`; the resolved
392
+ // frame mode is also captured in the per-connection handlers below.
393
+ const wsModes = new Map<WebSocket, WsSubprotocol | null>();
338
394
 
339
395
  wss.on('connection', (ws, req) => {
340
396
  // Admission gate: refuse the connection BEFORE we attach an actor / emit
@@ -350,18 +406,50 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
350
406
  const admissionRecordId = decision.recordId;
351
407
  runtime.commitAdmission(ip, undefined, admissionRecordId);
352
408
 
409
+ // Resolve the negotiated IRCv3 subprotocol (echoed by handleProtocols)
410
+ // and derive the frame mode. `ws.protocol` is '' when none negotiated.
411
+ const subproto: WsSubprotocol | null =
412
+ ws.protocol === WS_SUBPROTO_TEXT || ws.protocol === WS_SUBPROTO_BINARY
413
+ ? (ws.protocol as WsSubprotocol)
414
+ : null;
415
+ wsModes.set(ws, subproto);
416
+ const frameMode = wsFrameModeFor(subproto ?? undefined);
417
+
353
418
  const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
354
419
  sendText: (text) => {
355
- if (ws.readyState === WebSocket.OPEN) ws.send(text);
420
+ if (ws.readyState !== WebSocket.OPEN) return;
421
+ // binary.ircv3.net connections exchange binary WebSocket frames;
422
+ // text/legacy connections send UTF-8 text frames.
423
+ ws.send(frameMode === 'spec-binary' ? Buffer.from(text, 'utf8') : text);
356
424
  },
357
425
  closeTransport: () => ws.close(),
358
426
  admissionRecordId,
359
427
  sourceHost: ip,
428
+ frameMode,
429
+ // Spec-mode framing: one IRC message per WS message (strip a single
430
+ // trailing CR-LF, never split on an embedded line break). Legacy
431
+ // connections fall through to the actor's default WsTextFrameTransport.
432
+ ...(frameMode === 'legacy'
433
+ ? {}
434
+ : { actorTransport: { feed: (chunk: string) => frameToLines(chunk, frameMode) } }),
360
435
  });
361
436
  wsConnections.set(ws, { state, actor });
362
437
 
363
438
  ws.on('message', (data) => {
364
439
  const text = data.toString('utf8');
440
+ // Spec-mode inbound: enforce the IRCv3 510-byte message budget and
441
+ // sanitize lone surrogates for text frames before framing. Legacy
442
+ // frames pass through unchanged (the 512-byte line cap is enforced in
443
+ // the parser, as before).
444
+ if (frameMode === 'spec-text' || frameMode === 'spec-binary') {
445
+ if (!isWithinWsByteBudget(text)) {
446
+ ws.close(1009, 'Message Too Big');
447
+ return;
448
+ }
449
+ const inbound = frameMode === 'spec-text' ? sanitizeForTextMode(text) : text;
450
+ actor.receiveTextFrame(inbound).catch((err) => logError('actor failure', err));
451
+ return;
452
+ }
365
453
  // Fire-and-forget: the actor's reducer pipeline is synchronous through
366
454
  // to dispatch, and dispatch awaits each runtime call in order. Errors
367
455
  // propagate to the catch here and are logged (never crash the server).
@@ -376,6 +464,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
376
464
  // admission record (if any) so per-IP / per-user counters stay accurate.
377
465
  const cleanup = (): void => {
378
466
  wsConnections.delete(ws);
467
+ wsModes.delete(ws);
379
468
  runtime.unregisterConnection(state.id);
380
469
  };
381
470
  ws.on('close', cleanup);
@@ -527,6 +616,20 @@ function attachConnection(
527
616
  closeTransport: () => void;
528
617
  admissionRecordId?: string;
529
618
  sourceHost?: string;
619
+ /**
620
+ * Negotiated IRCv3 WebSocket frame mode for this connection. Spec modes
621
+ * (`spec-text` / `spec-binary`) switch outbound delivery to one WS
622
+ * message per IRC line with no trailing CR-LF (and surrogate
623
+ * sanitization for text). `undefined` / `legacy` keeps the original
624
+ * `\r\n`-joined batch. TCP connections never set this.
625
+ */
626
+ frameMode?: WsFrameMode;
627
+ /**
628
+ * Inbound line-framing seam forwarded to the actor's `Transport`. Only
629
+ * set for spec-mode WebSocket connections (one IRC line per frame);
630
+ * when omitted the actor defaults to its WS-text-frame transport.
631
+ */
632
+ actorTransport?: Transport;
530
633
  },
531
634
  ): ConnectionBindings {
532
635
  const id = randomUUID();
@@ -547,19 +650,8 @@ function attachConnection(
547
650
  state,
548
651
  runtime,
549
652
  channels: runtime,
550
- serverConfig: {
551
- serverName: cfg.serverName,
552
- networkName: cfg.networkName,
553
- maxChannelsPerUser: cfg.maxChannelsPerUser,
554
- maxTargetsPerCommand: cfg.maxTargetsPerCommand,
555
- maxListEntries: cfg.maxListEntries,
556
- nickLen: cfg.nickLen,
557
- channelLen: cfg.channelLen,
558
- topicLen: cfg.topicLen,
559
- quitMessage: cfg.quitMessage,
560
- ...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
561
- ...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
562
- },
653
+ serverConfig: serverConfigFromResolved(cfg),
654
+ configSource: 'config file',
563
655
  clock: { now: () => Date.now() },
564
656
  ids: DEFAULT_ID_FACTORY,
565
657
  motd: { lines: () => cfg.motdLines },
@@ -567,21 +659,37 @@ function attachConnection(
567
659
  ...(accounts !== undefined ? { accounts } : {}),
568
660
  history,
569
661
  logger,
662
+ ...(transport.actorTransport !== undefined ? { transport: transport.actorTransport } : {}),
570
663
  });
571
664
 
572
665
  runtime.registerConnection(
573
666
  state,
574
667
  {
575
668
  send: (lines: RawLine[]) => {
576
- const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
577
- transport.sendText(text);
669
+ const fm = transport.frameMode;
670
+ if (fm === 'spec-text' || fm === 'spec-binary') {
671
+ // Spec mode: one WebSocket message per IRC line, no trailing
672
+ // CR-LF. Sanitize lone surrogates for text frames (they have no
673
+ // valid UTF-8 encoding); binary frames pass through verbatim.
674
+ for (const l of lines) {
675
+ transport.sendText(fm === 'spec-text' ? sanitizeForTextMode(l.text) : l.text);
676
+ }
677
+ } else {
678
+ const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
679
+ transport.sendText(text);
680
+ }
578
681
  },
579
682
  disconnect: (reason?: string) => {
580
683
  // If a reason was supplied, send the RFC-style ERROR notice before
581
684
  // closing. (QUIT does not supply one; future paths like flood
582
- // control will.)
685
+ // control will.) In spec mode the notice is its own message with no
686
+ // trailing CR-LF.
583
687
  if (reason !== undefined) {
584
- transport.sendText(`ERROR :Closing link: (${reason})\r\n`);
688
+ const line = `ERROR :Closing link: (${reason})`;
689
+ const fm = transport.frameMode;
690
+ if (fm === 'spec-text') transport.sendText(sanitizeForTextMode(line));
691
+ else if (fm === 'spec-binary') transport.sendText(line);
692
+ else transport.sendText(`${line}\r\n`);
585
693
  }
586
694
  transport.closeTransport();
587
695
  },
@@ -330,7 +330,7 @@ describe('local-cli error paths', () => {
330
330
  // 'error' followed by 'close', and the cleanup handler unregisters the
331
331
  // connection from the runtime. (The nick itself is released by the
332
332
  // QUIT reducer on graceful shutdown; abrupt termination leaks the nick
333
- // pending a future gone-connection sweep — 041.)
333
+ // pending a future gone-connection sweep.)
334
334
  client.ws.terminate();
335
335
  await new Promise((r) => setTimeout(r, 100));
336
336
  expect(srv.runtime.hasConnection(connId as string)).toBe(false);
@@ -0,0 +1,257 @@
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
+ * IRCv3 WebSocket subprotocol negotiation + per-message framing e2e.
8
+ *
9
+ * A spec-mode (`text.ircv3.net` / `binary.ircv3.net`) connection MUST:
10
+ * - have its offered subprotocol echoed back in the handshake;
11
+ * - exchange exactly one IRC message per WebSocket message, with NO
12
+ * trailing CR-LF on either direction.
13
+ *
14
+ * A legacy (un-negotiated) connection keeps the original `\r\n`-batched
15
+ * framing, so the existing integration suite stays green.
16
+ */
17
+
18
+ /** A spec-mode client: one IRC line per WS message, no trailing CRLF. */
19
+ class SpecClient extends EventEmitter {
20
+ readonly ws: WebSocket;
21
+ readonly received: string[] = [];
22
+
23
+ constructor(url: string, protocols: string | string[]) {
24
+ super();
25
+ this.ws = new WebSocket(url, protocols);
26
+ // Each inbound WS message is exactly one IRC line in spec mode (no CRLF).
27
+ this.ws.on('message', (data) => {
28
+ const line = data.toString('utf8');
29
+ if (line.length > 0) {
30
+ this.received.push(line);
31
+ this.emit('line', line);
32
+ }
33
+ });
34
+ }
35
+
36
+ /** Resolves once the WS handshake completes. */
37
+ opened(): Promise<void> {
38
+ return new Promise((resolve, reject) => {
39
+ if (this.ws.readyState === WebSocket.OPEN) resolve();
40
+ else {
41
+ this.ws.once('open', () => resolve());
42
+ this.ws.once('error', reject);
43
+ }
44
+ });
45
+ }
46
+
47
+ /** Sends one bare IRC line as its own WS message (no trailing CRLF). */
48
+ send(line: string): Promise<void> {
49
+ return new Promise((resolve, reject) => {
50
+ this.ws.send(line, (err) => (err ? reject(err) : resolve()));
51
+ });
52
+ }
53
+
54
+ /** Waits until a received line satisfies `predicate` (substring match). */
55
+ waitFor(predicate: (line: string) => boolean, timeoutMs = 1000): Promise<string> {
56
+ return new Promise((resolve, reject) => {
57
+ const timeout = setTimeout(
58
+ () => reject(new Error(`timeout; received: ${JSON.stringify(this.received)}`)),
59
+ timeoutMs,
60
+ );
61
+ const check = (line: string) => {
62
+ if (predicate(line)) {
63
+ clearTimeout(timeout);
64
+ this.off('line', check);
65
+ resolve(line);
66
+ }
67
+ };
68
+ for (const line of this.received) {
69
+ if (predicate(line)) {
70
+ clearTimeout(timeout);
71
+ resolve(line);
72
+ return;
73
+ }
74
+ }
75
+ this.on('line', check);
76
+ });
77
+ }
78
+
79
+ close(): Promise<void> {
80
+ return new Promise((resolve) => {
81
+ if (this.ws.readyState === WebSocket.CLOSED) {
82
+ resolve();
83
+ return;
84
+ }
85
+ this.ws.once('close', () => resolve());
86
+ this.ws.close();
87
+ });
88
+ }
89
+ }
90
+
91
+ describe('local-cli IRCv3 WebSocket subprotocol', () => {
92
+ let server: LocalServer;
93
+
94
+ beforeAll(async () => {
95
+ server = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
96
+ });
97
+
98
+ afterAll(async () => {
99
+ await server.close();
100
+ });
101
+
102
+ it('echoes text.ircv3.net when offered', async () => {
103
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
104
+ await client.opened();
105
+ expect(client.ws.protocol).toBe('text.ircv3.net');
106
+ await client.close();
107
+ });
108
+
109
+ it('echoes binary.ircv3.net when offered', async () => {
110
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'binary.ircv3.net');
111
+ await client.opened();
112
+ expect(client.ws.protocol).toBe('binary.ircv3.net');
113
+ await client.close();
114
+ });
115
+
116
+ it('completes a full session over binary.ircv3.net (binary frames)', async () => {
117
+ // binary.ircv3.net exchanges binary WebSocket frames. The server emits
118
+ // binary outbound frames (Buffer) and decodes inbound as UTF-8; the
119
+ // round-trip must decode to valid IRC lines with no trailing CRLF.
120
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'binary.ircv3.net');
121
+ await client.opened();
122
+ await client.send('NICK spec-bin');
123
+ await client.send('USER spec-bin 0 * :Spec Bin');
124
+ const welcome = await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
125
+ expect(welcome).toContain('spec-bin');
126
+
127
+ await client.send('PING :bin-tok');
128
+ const pong = await client.waitFor((l) => l === 'PONG :bin-tok');
129
+ expect(pong).toBe('PONG :bin-tok');
130
+
131
+ // Every decoded outbound message is a single bare IRC line.
132
+ for (const line of client.received) {
133
+ expect(line.includes('\r')).toBe(false);
134
+ expect(line.includes('\n')).toBe(false);
135
+ }
136
+ await client.close();
137
+ });
138
+
139
+ it('selects the first-listed supported subprotocol (client preference)', async () => {
140
+ // Client prefers binary over text; server honours the order.
141
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, [
142
+ 'binary.ircv3.net',
143
+ 'text.ircv3.net',
144
+ ]);
145
+ await client.opened();
146
+ expect(client.ws.protocol).toBe('binary.ircv3.net');
147
+ await client.close();
148
+ });
149
+
150
+ it('ignores unsupported offers and still selects a supported one', async () => {
151
+ // Client lists an unsupported protocol first, then a supported one. The
152
+ // server MUST select the supported entry, not blindly echo the first.
153
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, ['chat', 'text.ircv3.net']);
154
+ await client.opened();
155
+ expect(client.ws.protocol).toBe('text.ircv3.net');
156
+ await client.close();
157
+ });
158
+
159
+ it('connects in legacy mode when no subprotocol is offered', async () => {
160
+ // A plain client (no Sec-WebSocket-Protocol) is the legacy path: the
161
+ // server does not negotiate and the existing integration suite keeps
162
+ // working unchanged.
163
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, []);
164
+ await client.opened();
165
+ expect(client.ws.protocol).toBe('');
166
+ await client.close();
167
+ });
168
+
169
+ it('completes registration with one IRC message per WebSocket message (text mode)', async () => {
170
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
171
+ await client.opened();
172
+ // One bare line per WS message (no trailing CRLF).
173
+ await client.send('NICK spec-alice');
174
+ await client.send('USER spec-alice 0 * :Spec Alice');
175
+ const welcome = await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
176
+ expect(welcome).toContain('spec-alice');
177
+
178
+ // Every received message must be a single IRC line with no trailing CRLF
179
+ // and no embedded line break (one IRC message per WS message).
180
+ for (const line of client.received) {
181
+ expect(line.includes('\r')).toBe(false);
182
+ expect(line.includes('\n')).toBe(false);
183
+ }
184
+ await client.close();
185
+ });
186
+
187
+ it('delivers each outbound line as a distinct WebSocket message in spec mode', async () => {
188
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
189
+ await client.opened();
190
+ await client.send('NICK spec-bob');
191
+ await client.send('USER spec-bob 0 * :Spec Bob');
192
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
193
+
194
+ // The welcome sequence is many numerics (001..005, 375/372/376). In spec
195
+ // mode each arrives as its own WS message; a legacy batch would have
196
+ // concatenated them with CRLF inside one message. Assert we observed
197
+ // several distinct messages.
198
+ expect(client.received.length).toBeGreaterThan(1);
199
+ await client.close();
200
+ });
201
+
202
+ it('routes PING → PONG as a single-message frame in spec mode', async () => {
203
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
204
+ await client.opened();
205
+ await client.send('NICK spec-ping');
206
+ await client.send('USER spec-ping 0 * :Spec Ping');
207
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
208
+
209
+ await client.send('PING :spec-tok');
210
+ const pong = await client.waitFor((l) => l === 'PONG :spec-tok');
211
+ expect(pong).toBe('PONG :spec-tok');
212
+ await client.close();
213
+ });
214
+
215
+ it('rejects an inbound message exceeding the 510-byte budget in spec mode', async () => {
216
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
217
+ await client.opened();
218
+ await client.send('NICK spec-budget');
219
+ await client.send('USER spec-budget 0 * :Spec Budget');
220
+ // Drain the entire registration burst (001..376) so delayed numerics
221
+ // don't pollute the post-overlong-message assertion below.
222
+ await client.waitFor((l) => l.startsWith(':irc.example.com 376 '));
223
+
224
+ client.received.length = 0;
225
+ // 511-byte payload: `PRIVMSG #x :` (12 bytes) + 499 chars = 511 total.
226
+ const overlong = `PRIVMSG #x :${'a'.repeat(499)}`;
227
+ expect(overlong.length).toBe(511);
228
+ await client.send(overlong);
229
+ // Give the server a moment to process + close. An over-budget message
230
+ // MUST NOT produce a PRIVMSG broadcast or numeric reply.
231
+ await new Promise((r) => setTimeout(r, 80));
232
+ expect(client.received.length).toBe(0);
233
+ await client.close();
234
+ });
235
+
236
+ // A reducer-driven disconnect with a reason (e.g. flood control / oper
237
+ // kill) emits the RFC-style ERROR notice. In spec mode it MUST arrive as
238
+ // its own WebSocket message with no trailing CR-LF, for both text and
239
+ // binary negotiated connections.
240
+ for (const subproto of ['text.ircv3.net', 'binary.ircv3.net'] as const) {
241
+ it(`delivers the ERROR notice as a single message on a ${subproto} connection`, async () => {
242
+ const nick = subproto === 'text.ircv3.net' ? 'spec-disc-text' : 'spec-disc-bin';
243
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, subproto);
244
+ await client.opened();
245
+ await client.send(`NICK ${nick}`);
246
+ await client.send(`USER ${nick} 0 * :Spec Disc`);
247
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
248
+
249
+ const connId = await server.runtime.lookupNick(nick);
250
+ expect(connId).not.toBeNull();
251
+ await server.runtime.disconnect(connId as string, 'Excess Flood');
252
+ const notice = await client.waitFor((l) => l.startsWith('ERROR :Closing link:'));
253
+ expect(notice).toBe('ERROR :Closing link: (Excess Flood)');
254
+ await client.close();
255
+ });
256
+ }
257
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serverless-ircd",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
6
6
  "license": "BSD-3-Clause",
@@ -17,7 +17,7 @@
17
17
  "typescript": "^5.9.3",
18
18
  "vite": "^7.3.6",
19
19
  "vitest": "^4.1.10",
20
- "@serverless-ircd/aws-adapter": "0.4.0"
20
+ "@serverless-ircd/aws-adapter": "0.5.0"
21
21
  },
22
22
  "scripts": {
23
23
  "build": "turbo run build",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-adapter",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": true,
5
5
  "description": "AWS Lambda + DynamoDB adapter: AwsRuntime implementing IrcRuntime + $connect/$disconnect/$default handlers",
6
6
  "license": "BSD-3-Clause",
@@ -51,6 +51,7 @@ import {
51
51
  type Nick,
52
52
  type RawLine,
53
53
  type RosterEntry,
54
+ type ServerConfig,
54
55
  SystemClock,
55
56
  emptyModes,
56
57
  toSnapshot as toConnSnapshot,
@@ -119,6 +120,14 @@ export interface AwsRuntimeOptions {
119
120
  */
120
121
  managementApi: ApiGatewayManagementApi | PostToConnection | null;
121
122
  clock?: Clock;
123
+ /**
124
+ * Config-reload source for `REHASH`. The Lambda handler binds this to a
125
+ * closure that re-invokes {@link loadServerConfigFromLambdaEnv} against the
126
+ * invocation env (Secrets Manager / SSM / env vars). When omitted
127
+ * `reloadConfig` rejects so the actor applies a graceful error-suffixed
128
+ * `382` and retains the prior config.
129
+ */
130
+ readonly configLoader?: () => Promise<ServerConfig>;
122
131
  }
123
132
 
124
133
  /**
@@ -133,6 +142,7 @@ export class AwsRuntime implements IrcRuntime {
133
142
  private readonly handlers: AwsRuntimeHandlers;
134
143
  private readonly managementApi: ApiGatewayManagementApi | PostToConnection | null;
135
144
  private readonly clock: Clock;
145
+ private readonly configLoader: (() => Promise<ServerConfig>) | undefined;
136
146
 
137
147
  constructor(opts: AwsRuntimeOptions) {
138
148
  this.dynamo = opts.dynamo;
@@ -141,6 +151,7 @@ export class AwsRuntime implements IrcRuntime {
141
151
  this.handlers = opts.handlers;
142
152
  this.managementApi = opts.managementApi;
143
153
  this.clock = opts.clock ?? SystemClock;
154
+ this.configLoader = opts.configLoader;
144
155
  }
145
156
 
146
157
  // -------------------------------------------------------------------------
@@ -210,6 +221,51 @@ export class AwsRuntime implements IrcRuntime {
210
221
  }
211
222
  }
212
223
 
224
+ /**
225
+ * Global cross-connection fan-out for `WALLOPS`. `Scan`s the `Connections`
226
+ * table filtered to rows whose `userModes.wallops` is `true`, then
227
+ * `PostToConnection`s each one (skipping `except`). The bound connection's
228
+ * own delivery goes through the in-process handlers (no APIGW round-trip),
229
+ * mirroring `send`.
230
+ *
231
+ * Cost: a DynamoDB `Scan` reads the ENTIRE table (chargeable per row read,
232
+ * not per match), so this is expensive at high connection counts. A fleet
233
+ * of 10k connections costs one full scan per WALLOPS plus one
234
+ * `PostToConnection` per `+w` recipient. Deployments should bound the
235
+ * oper's WALLOPS rate; a projected GSI on `userModes.wallops` would lift
236
+ * the scan to a targeted query (documented follow-up). The scan uses
237
+ * `FilterExpression` so only `+w` rows survive to the `PostToConnection`
238
+ * phase — the APIGW call count is bounded by the genuine recipient set.
239
+ */
240
+ async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
241
+ let startKey: Record<string, NativeAttributeValue> | undefined;
242
+ // Paginate the scan: DynamoDB caps a single Scan at 1 MB. Loop until
243
+ // no `LastEvaluatedKey` remains so every +w connection is reached.
244
+ do {
245
+ const result = await this.dynamo.send(
246
+ new ScanCommand({
247
+ TableName: this.tables.Connections,
248
+ FilterExpression: 'userModes.#m = :true',
249
+ ExpressionAttributeNames: { '#m': 'wallops' },
250
+ ExpressionAttributeValues: { ':true': true },
251
+ ...(startKey !== undefined ? { ExclusiveStartKey: startKey } : {}),
252
+ }),
253
+ );
254
+ const items = (result.Items ?? []) as unknown as MarshalledConnection[];
255
+ for (const row of items) {
256
+ const connId = row.connectionId;
257
+ if (connId === except) continue;
258
+ // `send` short-cuts the bound connection through the in-process
259
+ // handlers and catches GoneException so vanished sockets are
260
+ // cleaned up rather than aborting the fan-out mid-loop.
261
+ if (row.userModes.wallops) {
262
+ await this.send(connId, lines);
263
+ }
264
+ }
265
+ startKey = result.LastEvaluatedKey as Record<string, NativeAttributeValue> | undefined;
266
+ } while (startKey !== undefined);
267
+ }
268
+
213
269
  // -------------------------------------------------------------------------
214
270
  // Nick registry
215
271
  // -------------------------------------------------------------------------
@@ -365,6 +421,19 @@ export class AwsRuntime implements IrcRuntime {
365
421
  return out;
366
422
  }
367
423
 
424
+ /**
425
+ * Re-invokes the bound config loader (Secrets Manager / SSM / env) and
426
+ * returns the fresh {@link ServerConfig}. The Lambda handler binds the
427
+ * loader at construction; when no loader was supplied this rejects so the
428
+ * actor's graceful failure path applies.
429
+ */
430
+ async reloadConfig(): Promise<ServerConfig> {
431
+ if (this.configLoader === undefined) {
432
+ throw new Error('no config loader bound to AwsRuntime');
433
+ }
434
+ return this.configLoader();
435
+ }
436
+
368
437
  // -------------------------------------------------------------------------
369
438
  // Public helpers (re-exported surface)
370
439
  // -------------------------------------------------------------------------