serverless-ircd 0.3.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 (223) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +349 -18
  8. package/README.md +132 -30
  9. package/apps/aws-stack/README.md +6 -5
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +150 -10
  13. package/apps/aws-stack/tests/stack.test.ts +145 -4
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +149 -24
  38. package/apps/local-cli/tests/e2e.test.ts +1 -1
  39. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  40. package/package.json +14 -10
  41. package/packages/aws-adapter/package.json +3 -3
  42. package/packages/aws-adapter/src/account-store.ts +1 -1
  43. package/packages/aws-adapter/src/admission.ts +74 -0
  44. package/packages/aws-adapter/src/aws-runtime.ts +75 -4
  45. package/packages/aws-adapter/src/config-loader.ts +32 -0
  46. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  47. package/packages/aws-adapter/src/handlers/connect.ts +96 -9
  48. package/packages/aws-adapter/src/handlers/default.ts +98 -7
  49. package/packages/aws-adapter/src/handlers/index.ts +109 -4
  50. package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
  51. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  52. package/packages/aws-adapter/src/index.ts +8 -0
  53. package/packages/aws-adapter/src/serialize.ts +11 -1
  54. package/packages/aws-adapter/src/stats.ts +80 -0
  55. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  56. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  57. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  58. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  59. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  60. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  61. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  62. package/packages/aws-adapter/tests/connect.test.ts +174 -0
  63. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  64. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  65. package/packages/aws-adapter/tests/handlers.test.ts +302 -53
  66. package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
  67. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  68. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +5 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +68 -5
  73. package/packages/cf-adapter/src/channel-do.ts +2 -2
  74. package/packages/cf-adapter/src/config-loader.ts +33 -0
  75. package/packages/cf-adapter/src/connection-do.ts +278 -85
  76. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  77. package/packages/cf-adapter/src/env.ts +54 -11
  78. package/packages/cf-adapter/src/index.ts +11 -8
  79. package/packages/cf-adapter/src/registry-do.ts +22 -3
  80. package/packages/cf-adapter/src/sharding.ts +1 -2
  81. package/packages/cf-adapter/src/stats.ts +65 -0
  82. package/packages/cf-adapter/tests/cf-harness.ts +12 -2
  83. package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
  84. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  85. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  86. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  87. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  88. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  89. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  90. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  91. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  92. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  93. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  94. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  95. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  96. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  97. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  98. package/packages/cf-adapter/tests/worker/main.ts +15 -8
  99. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  100. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  101. package/packages/cf-adapter/wrangler.test.toml +15 -0
  102. package/packages/in-memory-runtime/package.json +1 -1
  103. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  104. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  105. package/packages/irc-core/package.json +6 -1
  106. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  107. package/packages/irc-core/src/admission.ts +16 -15
  108. package/packages/irc-core/src/caps/capabilities.ts +24 -3
  109. package/packages/irc-core/src/cloak.ts +1 -1
  110. package/packages/irc-core/src/commands/cap.ts +8 -1
  111. package/packages/irc-core/src/commands/index.ts +17 -0
  112. package/packages/irc-core/src/commands/invite.ts +2 -4
  113. package/packages/irc-core/src/commands/ison.ts +61 -0
  114. package/packages/irc-core/src/commands/isupport.ts +6 -2
  115. package/packages/irc-core/src/commands/kick.ts +2 -4
  116. package/packages/irc-core/src/commands/kill.ts +127 -0
  117. package/packages/irc-core/src/commands/list.ts +1 -1
  118. package/packages/irc-core/src/commands/lusers.ts +204 -0
  119. package/packages/irc-core/src/commands/mode.ts +4 -8
  120. package/packages/irc-core/src/commands/names.ts +3 -5
  121. package/packages/irc-core/src/commands/part.ts +2 -4
  122. package/packages/irc-core/src/commands/quit.ts +12 -0
  123. package/packages/irc-core/src/commands/registration.ts +18 -12
  124. package/packages/irc-core/src/commands/rehash.ts +119 -0
  125. package/packages/irc-core/src/commands/sasl.ts +72 -9
  126. package/packages/irc-core/src/commands/server-info.ts +129 -0
  127. package/packages/irc-core/src/commands/setname.ts +109 -0
  128. package/packages/irc-core/src/commands/stats.ts +152 -0
  129. package/packages/irc-core/src/commands/topic.ts +2 -4
  130. package/packages/irc-core/src/commands/trace.ts +137 -0
  131. package/packages/irc-core/src/commands/userhost.ts +84 -0
  132. package/packages/irc-core/src/commands/wallops.ts +118 -0
  133. package/packages/irc-core/src/commands/whowas.ts +113 -0
  134. package/packages/irc-core/src/config.ts +65 -0
  135. package/packages/irc-core/src/credential-hashing.ts +124 -0
  136. package/packages/irc-core/src/effects.ts +33 -30
  137. package/packages/irc-core/src/index.ts +3 -0
  138. package/packages/irc-core/src/ports.ts +360 -12
  139. package/packages/irc-core/src/protocol/numerics.ts +48 -11
  140. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  141. package/packages/irc-core/src/types.ts +46 -2
  142. package/packages/irc-core/src/ws-framing.ts +132 -0
  143. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  144. package/packages/irc-core/tests/account-store.test.ts +45 -2
  145. package/packages/irc-core/tests/admission.test.ts +18 -0
  146. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  147. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  148. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  149. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  150. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  151. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  152. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  153. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  154. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  155. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  156. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  157. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  158. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  159. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  160. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  161. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  162. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  163. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  164. package/packages/irc-core/tests/config.test.ts +95 -1
  165. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  166. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  167. package/packages/irc-core/tests/effects.test.ts +14 -27
  168. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  169. package/packages/irc-core/tests/numerics.test.ts +102 -0
  170. package/packages/irc-core/tests/outbound.test.ts +51 -0
  171. package/packages/irc-core/tests/ports.test.ts +22 -0
  172. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  173. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  174. package/packages/irc-core/tests/types.test.ts +35 -1
  175. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  176. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  177. package/packages/irc-core/tsconfig.build.json +1 -1
  178. package/packages/irc-core/tsconfig.test.json +1 -1
  179. package/packages/irc-server/package.json +1 -1
  180. package/packages/irc-server/src/actor.ts +393 -16
  181. package/packages/irc-server/src/dispatch.ts +1 -3
  182. package/packages/irc-server/src/index.ts +10 -2
  183. package/packages/irc-server/src/routing.ts +15 -0
  184. package/packages/irc-server/src/runtime.ts +31 -0
  185. package/packages/irc-server/src/transport.ts +104 -0
  186. package/packages/irc-server/tests/actor.test.ts +1489 -4
  187. package/packages/irc-server/tests/dispatch.test.ts +37 -17
  188. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  189. package/packages/irc-server/tests/routing.test.ts +5 -0
  190. package/packages/irc-server/tests/runtime.test.ts +7 -0
  191. package/packages/irc-server/tests/transport.test.ts +230 -0
  192. package/packages/irc-test-support/package.json +1 -1
  193. package/packages/irc-test-support/src/harness.ts +44 -9
  194. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  195. package/packages/irc-test-support/src/index.ts +3 -0
  196. package/packages/irc-test-support/src/scenarios.ts +141 -3
  197. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  198. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  199. package/pnpm-workspace.yaml +10 -1
  200. package/tools/ci-hardening/package.json +1 -1
  201. package/tools/package.json +9 -0
  202. package/tools/seed-aws-accounts.ts +5 -6
  203. package/tools/seed-cf-accounts.ts +107 -0
  204. package/tools/tcp-ws-forwarder/package.json +1 -1
  205. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  206. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  207. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
  208. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  209. package/docs/ADR-002-location-of-authority.md +0 -82
  210. package/docs/ADR-003-durable-object-sharding.md +0 -93
  211. package/docs/ADR-004-dynamodb-schema.md +0 -96
  212. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  213. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  214. package/docs/ADR-007-deterministic-ports.md +0 -82
  215. package/docs/ADR-008-monorepo-tooling.md +0 -60
  216. package/docs/AWS-Adapter-Architecture.md +0 -496
  217. package/docs/AWS-Deployment.md +0 -1186
  218. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  219. package/docs/Home.md +0 -11
  220. package/docs/Observability.md +0 -87
  221. package/docs/PlanIRCv3Websocket.md +0 -489
  222. package/docs/PlanWebClient.md +0 -451
  223. package/docs/Release-Process.md +0 -443
@@ -1,13 +1,22 @@
1
1
  /**
2
2
  * Reusable IRC scenario runner — exported so adapter packages (cf-adapter,
3
3
  * aws-stack) can register their own {@link IrcHarnessFactory} and re-run
4
- * the exact same scenario suite under their native runtime.
4
+ * the exact same scenario suite under their native runtime AND transport.
5
5
  *
6
6
  * Each scenario is written once against the {@link IrcHarness} seam. The
7
7
  * runner takes a list of factories and produces one `describe` block per
8
8
  * factory, mirroring the parametrized matrix called for in PLAN §8
9
9
  * ("Runtime contract: vitest parametrized over IrcRuntime. Same scenarios
10
- * pass in-memory + CF + AWS.").
10
+ * pass in-memory + CF + AWS.") extended to cover the transport dimension
11
+ * ("Same scenarios pass in-memory+WS, in-memory+TCP, CF+WS, CF+TCP,
12
+ * AWS+WS, AWS+TCP.").
13
+ *
14
+ * The general scenario block runs against every factory regardless of
15
+ * transport. The TCP-framing block (chunk boundaries, slow-loris partial
16
+ * sends, mixed line endings) runs ONLY against factories declaring
17
+ * `transport: 'tcp'` — those cases are meaningless (and behave
18
+ * differently) under the WebSocket text-frame transport where every
19
+ * `feed()` is a complete unit.
11
20
  */
12
21
 
13
22
  import { describe, expect, it } from 'vitest';
@@ -183,7 +192,15 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
183
192
  await alice.waitForLine((l) => l.includes('JOIN #bar'));
184
193
  alice.received.length = 0;
185
194
  await alice.send('JOIN 0');
186
- 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'));
187
204
  const parts = alice.received.filter((l) => l.includes('PART'));
188
205
  expect(parts.length).toBe(2);
189
206
  expect(parts.some((l) => l.includes('#foo'))).toBe(true);
@@ -491,5 +508,126 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
491
508
  await harness.close();
492
509
  });
493
510
  });
511
+
512
+ // -------------------------------------------------------------------------
513
+ // TCP-framing scenarios.
514
+ //
515
+ // These cases exercise the stateful `\r\n` byte-stream transport
516
+ // (PLAN §9, ADR-009): chunk boundaries, partial-line buffering across
517
+ // chunks, slow-loris-style one-byte sends, bare `\n` tolerance, and
518
+ // multi-message chunks. They are meaningless under the WebSocket
519
+ // text-frame transport (where every feed() is a complete unit), so
520
+ // they only run against factories declaring `transport: 'tcp'`.
521
+ // -------------------------------------------------------------------------
522
+ if (factory.transport === 'tcp') {
523
+ describe(`TCP framing — ${factory.name}`, () => {
524
+ let harness: IrcHarness;
525
+
526
+ it('tcp 01: message split across two chunks produces one command', async () => {
527
+ harness = await factory.create();
528
+ const c = await harness.spawnClient({ nick: 'alice' });
529
+ await c.waitForNumeric('001');
530
+ c.received.length = 0;
531
+ // Feed the first half — incomplete, must buffer without dispatching.
532
+ await c.feed('PI');
533
+ // Nothing has terminated yet: no PONG, no 421.
534
+ expect(c.received.length).toBe(0);
535
+ await c.feed('NG :split\r\n');
536
+ await c.waitForLine((l) => l === 'PONG :split');
537
+ await harness.close();
538
+ });
539
+
540
+ it('tcp 02: partial line buffered across many one-byte chunks (slow-loris style)', async () => {
541
+ harness = await factory.create();
542
+ const c = await harness.spawnClient({ nick: 'alice' });
543
+ await c.waitForNumeric('001');
544
+ c.received.length = 0;
545
+ const line = 'PING :slow';
546
+ for (const ch of line) {
547
+ await c.feed(ch);
548
+ // No terminator yet — nothing dispatches.
549
+ expect(c.received.length).toBe(0);
550
+ }
551
+ await c.feed('\r\n');
552
+ await c.waitForLine((l) => l === 'PONG :slow');
553
+ await harness.close();
554
+ });
555
+
556
+ it('tcp 03: bare LF line ending is tolerated (legacy/mixed clients)', async () => {
557
+ harness = await factory.create();
558
+ const c = await harness.spawnClient({ nick: 'alice' });
559
+ await c.waitForNumeric('001');
560
+ c.received.length = 0;
561
+ await c.feed('PING :bare\n');
562
+ await c.waitForLine((l) => l === 'PONG :bare');
563
+ await harness.close();
564
+ });
565
+
566
+ it('tcp 04: multiple CRLF-terminated messages in one chunk dispatch in order', async () => {
567
+ harness = await factory.create();
568
+ const c = await harness.spawnClient({ nick: 'alice' });
569
+ await c.waitForNumeric('001');
570
+ c.received.length = 0;
571
+ await c.feed('PING :one\r\nPING :two\r\nPING :three\r\n');
572
+ await c.waitForLine((l) => l === 'PONG :three');
573
+ const pongs = c.received.filter((l) => l.startsWith('PONG :'));
574
+ expect(pongs).toEqual(['PONG :one', 'PONG :two', 'PONG :three']);
575
+ await harness.close();
576
+ });
577
+
578
+ it('tcp 05: mixed CRLF and LF terminators in one chunk both frame', async () => {
579
+ harness = await factory.create();
580
+ const c = await harness.spawnClient({ nick: 'alice' });
581
+ await c.waitForNumeric('001');
582
+ c.received.length = 0;
583
+ await c.feed('PING :crlf\r\nPING :lf\n');
584
+ await c.waitForLine((l) => l === 'PONG :lf');
585
+ const toks = c.received
586
+ .filter((l) => l.startsWith('PONG :'))
587
+ .map((l) => l.slice('PONG :'.length));
588
+ expect(toks).toEqual(['crlf', 'lf']);
589
+ await harness.close();
590
+ });
591
+
592
+ it('tcp 06: CRLF terminator split across chunks (CR in one, LF in next)', async () => {
593
+ harness = await factory.create();
594
+ const c = await harness.spawnClient({ nick: 'alice' });
595
+ await c.waitForNumeric('001');
596
+ c.received.length = 0;
597
+ await c.feed('PING :cr\r');
598
+ expect(c.received.length).toBe(0);
599
+ await c.feed('\n');
600
+ await c.waitForLine((l) => l === 'PONG :cr');
601
+ await harness.close();
602
+ });
603
+
604
+ it('tcp 07: an empty chunk is a no-op (no dispatch, no crash)', async () => {
605
+ harness = await factory.create();
606
+ const c = await harness.spawnClient({ nick: 'alice' });
607
+ await c.waitForNumeric('001');
608
+ c.received.length = 0;
609
+ await c.feed('');
610
+ expect(c.received.length).toBe(0);
611
+ // The connection is still usable after the empty chunk.
612
+ await c.feed('PING :after-empty\r\n');
613
+ await c.waitForLine((l) => l === 'PONG :after-empty');
614
+ await harness.close();
615
+ });
616
+
617
+ it('tcp 08: a complete line followed by a partial line buffers the tail', async () => {
618
+ harness = await factory.create();
619
+ const c = await harness.spawnClient({ nick: 'alice' });
620
+ await c.waitForNumeric('001');
621
+ c.received.length = 0;
622
+ await c.feed('PING :first\r\nPI');
623
+ await c.waitForLine((l) => l === 'PONG :first');
624
+ // The trailing partial 'PI' must not have dispatched yet.
625
+ expect(c.received.some((l) => l === 'PONG :')).toBe(false);
626
+ await c.feed('NG :second\r\n');
627
+ await c.waitForLine((l) => l === 'PONG :second');
628
+ await harness.close();
629
+ });
630
+ });
631
+ }
494
632
  }
495
633
  }
@@ -12,7 +12,8 @@ import { InMemoryHarness, inMemoryHarnessFactory } from '../src/index.js';
12
12
 
13
13
  describe('inMemoryHarnessFactory', () => {
14
14
  it('exposes a stable name for the describe.each matrix', () => {
15
- expect(inMemoryHarnessFactory.name).toBe('in-memory');
15
+ expect(inMemoryHarnessFactory.name).toBe('in-memory+ws');
16
+ expect(inMemoryHarnessFactory.transport).toBe('ws');
16
17
  });
17
18
 
18
19
  it('create() returns a fresh, independent InMemoryHarness each call', async () => {
@@ -1,19 +1,33 @@
1
1
  /**
2
- * Parametrized IRC scenarios — 032.
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
6
6
  * {@link IrcHarnessFactory} against the exact same suite under their
7
7
  * native vitest configuration (CF runs in `workerd`, AWS runs in node
8
- * with dynamodb-local, etc.). This file is the in-memory entry point
9
- * it registers only {@link inMemoryHarnessFactory}.
8
+ * with dynamodb-local, etc.). This file is the in-memory entry point.
10
9
  *
11
- * Adding a runtime to the in-memory suite = appending one entry to the
12
- * `FACTORIES` array (acceptance criterion: "adding a new runtime
13
- * requires only registering a factory").
10
+ * The transport dimension extends the matrix: every scenario runs
11
+ * against (runtime × transport). The in-memory runtime
12
+ * registers both transports `inMemoryWsHarnessFactory` (WebSocket
13
+ * text-frame framing) and `inMemoryTcpHarnessFactory` (raw TCP+TLS
14
+ * byte-stream framing) — proving the same scenario suite is green across
15
+ * both transports on the reference runtime. CF and AWS append their own
16
+ * factories (per transport) to their package suites.
17
+ *
18
+ * Adding a runtime OR a transport to the in-memory suite = appending one
19
+ * entry to the `FACTORIES` array (acceptance criterion: "adding a new
20
+ * transport requires only registering a factory").
14
21
  */
15
22
 
16
- import { inMemoryHarnessFactory, runIrcScenarios } from '../src/index.js';
23
+ import {
24
+ inMemoryTcpHarnessFactory,
25
+ inMemoryWsHarnessFactory,
26
+ runIrcScenarios,
27
+ } from '../src/index.js';
17
28
 
18
- /** The runtime matrix. Adding a runtime = appending one entry here. */
19
- runIrcScenarios([inMemoryHarnessFactory]);
29
+ /**
30
+ * The (runtime × transport) matrix. Adding a runtime or transport =
31
+ * appending one entry here.
32
+ */
33
+ runIrcScenarios([inMemoryWsHarnessFactory, inMemoryTcpHarnessFactory]);
@@ -1,5 +1,14 @@
1
- linkedWorkspacePackages: true
2
1
  packages:
3
2
  - "packages/*"
4
3
  - "apps/*"
4
+ - "tools"
5
5
  - "tools/*"
6
+ linkWorkspacePackages: true
7
+ allowBuilds:
8
+ '@biomejs/biome': true
9
+ cpu-features: true
10
+ esbuild: true
11
+ protobufjs: true
12
+ sharp: true
13
+ ssh2: true
14
+ workerd: true
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/ci-hardening",
3
- "version": "0.2.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",
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@serverless-ircd/tools",
3
+ "private": true,
4
+ "type": "module",
5
+ "dependencies": {
6
+ "@serverless-ircd/aws-adapter": "workspace:*",
7
+ "@serverless-ircd/cf-adapter": "workspace:*"
8
+ }
9
+ }
@@ -17,12 +17,9 @@
17
17
  * Re-running with the same username overwrites the prior row.
18
18
  */
19
19
 
20
- import { parseArgs } from 'node:util';
21
20
  import { readFileSync } from 'node:fs';
22
- import {
23
- createDynamoDocumentClient,
24
- putAccountCredential,
25
- } from '@serverless-ircd/aws-adapter';
21
+ import { parseArgs } from 'node:util';
22
+ import { createDynamoDocumentClient, putAccountCredential } from '@serverless-ircd/aws-adapter';
26
23
 
27
24
  const { values } = parseArgs({
28
25
  options: {
@@ -35,7 +32,9 @@ const { values } = parseArgs({
35
32
  });
36
33
 
37
34
  if (values.table === undefined) {
38
- console.error('Usage: seed-aws-accounts --table <TableName> [--endpoint <url>] [--accounts user:pass ...] [--file <path>]');
35
+ console.error(
36
+ 'Usage: seed-aws-accounts --table <TableName> [--endpoint <url>] [--accounts user:pass ...] [--file <path>]',
37
+ );
39
38
  process.exit(1);
40
39
  }
41
40
 
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Seed SASL PLAIN accounts into a Cloudflare D1 `accounts` table.
4
+ *
5
+ * Usage:
6
+ * node --import tsx tools/seed-cf-accounts.ts \
7
+ * --database serverless-ircd-accounts-staging \
8
+ * --env staging \
9
+ * --remote \
10
+ * --accounts alice:s3cret bob:password2
11
+ *
12
+ * Or read from a file (newline-delimited `username:password`):
13
+ * node --import tsx tools/seed-cf-accounts.ts \
14
+ * --database serverless-ircd-accounts-staging \
15
+ * --env staging \
16
+ * --remote \
17
+ * --file accounts.txt
18
+ *
19
+ * The script hashes each password with scrypt (never writes plaintext) and
20
+ * executes the resulting `INSERT OR REPLACE` via `wrangler d1 execute`.
21
+ * Re-running with the same username overwrites the prior row. The `accounts`
22
+ * table is created (IF NOT EXISTS) on the first run.
23
+ *
24
+ * Flags:
25
+ * --database D1 database name (as declared in wrangler.toml). Required.
26
+ * --env Wrangler environment (e.g. `staging`). Optional.
27
+ * --remote Target the remote D1 (default: local preview).
28
+ * --local Target local D1 (explicit; default).
29
+ * --accounts One or more `username:password` pairs.
30
+ * --file Path to a newline-delimited `username:password` file.
31
+ */
32
+
33
+ import { execFileSync } from 'node:child_process';
34
+ import { readFileSync } from 'node:fs';
35
+ import { parseArgs } from 'node:util';
36
+ import {
37
+ CREATE_ACCOUNTS_TABLE_SQL,
38
+ buildAccountPutStatement,
39
+ } from '@serverless-ircd/cf-adapter/accounts';
40
+
41
+ const { values } = parseArgs({
42
+ options: {
43
+ database: { type: 'string' },
44
+ env: { type: 'string', default: undefined },
45
+ remote: { type: 'boolean', default: false },
46
+ local: { type: 'boolean', default: false },
47
+ accounts: { type: 'string', multiple: true, default: [] },
48
+ file: { type: 'string', default: undefined },
49
+ },
50
+ });
51
+
52
+ if (values.database === undefined) {
53
+ console.error(
54
+ 'Usage: seed-cf-accounts --database <d1-name> [--env staging] [--remote] [--accounts user:pass ...] [--file <path>]',
55
+ );
56
+ process.exit(1);
57
+ }
58
+
59
+ const pairs: Array<{ username: string; password: string }> = [];
60
+
61
+ for (const pair of values.accounts) {
62
+ const sep = pair.indexOf(':');
63
+ if (sep <= 0) {
64
+ console.error(`Skipping malformed entry (no colon): ${pair}`);
65
+ continue;
66
+ }
67
+ pairs.push({ username: pair.slice(0, sep), password: pair.slice(sep + 1) });
68
+ }
69
+
70
+ if (values.file !== undefined) {
71
+ const content = readFileSync(values.file, 'utf-8');
72
+ for (const line of content.split('\n')) {
73
+ const trimmed = line.trim();
74
+ if (trimmed.length === 0) continue;
75
+ const sep = trimmed.indexOf(':');
76
+ if (sep <= 0) continue;
77
+ pairs.push({ username: trimmed.slice(0, sep), password: trimmed.slice(sep + 1) });
78
+ }
79
+ }
80
+
81
+ if (pairs.length === 0) {
82
+ console.error('No accounts to seed. Use --accounts or --file.');
83
+ process.exit(1);
84
+ }
85
+
86
+ /** Runs `wrangler d1 execute` with the supplied SQL command. */
87
+ function runD1Execute(sql: string): void {
88
+ const args = ['d1', 'execute', values.database as string, '--command', sql];
89
+ if (values.env !== undefined) {
90
+ args.push('--env', values.env);
91
+ }
92
+ if (values.remote) {
93
+ args.push('--remote');
94
+ }
95
+ execFileSync('npx', ['wrangler', ...args], { stdio: 'inherit' });
96
+ }
97
+
98
+ // Ensure the table exists (idempotent).
99
+ runD1Execute(CREATE_ACCOUNTS_TABLE_SQL);
100
+
101
+ for (const { username, password } of pairs) {
102
+ const { entry, sql } = buildAccountPutStatement(username, password);
103
+ runD1Execute(sql);
104
+ console.log(`Seeded ${username} (algorithm=${entry.algorithm})`);
105
+ }
106
+
107
+ console.log(`Done: ${pairs.length} account(s) written to D1 database ${values.database}.`);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/tcp-ws-forwarder",
3
- "version": "0.2.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
+ });