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
@@ -0,0 +1,37 @@
1
+ /**
2
+ * CF `webSocketMessage` channel-registration: wire ChannelDO
3
+ * registration OR remove the dead loop.
4
+ *
5
+ * Spike conclusion: the dead loop that
6
+ * iterated `state.joinedChannels` to "register" the ConnectionDO with
7
+ * each ChannelDO is redundant. Channel membership — and therefore
8
+ * broadcast fan-out registration — is driven solely by the
9
+ * `ApplyChannelDelta` effect the joinReducer emits. The CfRuntime
10
+ * forwards that effect to the authoritative ChannelDO
11
+ * (`cf-runtime.ts` → `channel-do.ts#applyChannelDelta`), whose roster
12
+ * is the single source of truth used by `ChannelDO.broadcast`. The
13
+ * `connection-do.test.ts` "QUIT fanout to each joined channel" test
14
+ * proves this end-to-end (alice JOINs, closes, and bob observes the
15
+ * QUIT that only the ChannelDO roster could have routed).
16
+ *
17
+ * Because the removed loop was a no-op (`void chan;`), there is no
18
+ * behaviour to assert — these are source-level regression guards that
19
+ * prevent the redundant registration loop from creeping back in.
20
+ */
21
+
22
+ import { describe, expect, it } from 'vitest';
23
+ import connectionDoSource from '../src/connection-do.ts?raw';
24
+
25
+ describe('ConnectionDO — no redundant channel-registration loop', () => {
26
+ it('webSocketMessage contains no dead `void chan` no-op registration body', () => {
27
+ // The joinReducer's `ApplyChannelDelta` effect is the sole channel
28
+ // registration path; a per-ConnectionDO loop here is dead code.
29
+ expect(connectionDoSource).not.toContain('void chan');
30
+ });
31
+
32
+ it('documents that no explicit per-ConnectionDO registration step is needed', () => {
33
+ // The absence of the loop must be explained so it is not mistaken
34
+ // for a missing feature.
35
+ expect(connectionDoSource).toContain('No per-ConnectionDO channel-registration step is needed');
36
+ });
37
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * CF outbound batching: implement OR drop the reserved optimization.
3
+ *
4
+ * Spike conclusion: the speculative per-microtask
5
+ * outbound drain that was reserved as a commented-out field in
6
+ * `ConnectionDO` is dropped. Per-call batching is already implemented —
7
+ * `ConnectionDO.deliver()` joins every line of a ChannelDO broadcast into
8
+ * a single `WebSocket.send()`, and the actor `send` handler
9
+ * (`buildActor`) joins every line of one frame's response the same way.
10
+ * What the reservation speculated about was *cross-call* coalescing —
11
+ * queueing lines emitted by independent `deliver` / `send` invocations
12
+ * within the same microtask and flushing them in one write. That adds a
13
+ * drain queue, a `queueMicrotask` scheduler, and ordering hazards between
14
+ * independent fan-out calls, in exchange for no measured benefit: nothing
15
+ * in the load-test report or the ChannelDO fan-out path shows WS
16
+ * round-trips as a bottleneck. The AWS adapter batches at a
17
+ * different layer (per-invocation `outbound: string[]` accumulated then
18
+ * posted once), so there is no shared abstraction worth extracting.
19
+ *
20
+ * Because the removed reservation was a comment with no runtime
21
+ * behaviour, these are source-level regression guards (mirroring the
22
+ * pattern used for other dropped reservations) that prevent the
23
+ * speculative field from creeping back in and keep the deferral
24
+ * documented at the point a reader would otherwise expect the batching
25
+ * hook.
26
+ */
27
+
28
+ import { describe, expect, it } from 'vitest';
29
+ import connectionDoSource from '../src/connection-do.ts?raw';
30
+
31
+ describe('ConnectionDO — no reserved outbound-batching field', () => {
32
+ it('does not contain the deferred "Reserved for a future batching optimization" comment', () => {
33
+ // The reservation is dropped; per-call batching in `deliver()` and
34
+ // the actor `send` handler is the implemented strategy.
35
+ expect(connectionDoSource).not.toContain('Reserved for a future batching optimization');
36
+ });
37
+
38
+ it('does not reintroduce a speculative outbound-drain field declaration', () => {
39
+ // Guards against any variant of the commented-out reservation. The
40
+ // implemented batching is per-call (join lines then `send` once);
41
+ // there is no instance-level drain queue.
42
+ expect(connectionDoSource).not.toContain('Outstanding outbound lines');
43
+ expect(connectionDoSource).not.toContain('drop the reservation');
44
+ });
45
+
46
+ it('documents the deferral decision at the point the reservation used to live', () => {
47
+ // The absence of the field must be explained so it is not mistaken
48
+ // for a missing feature, and a reader is pointed at the perf ADR.
49
+ expect(connectionDoSource).toContain('batched per call');
50
+ expect(connectionDoSource).toContain('ADR-003');
51
+ });
52
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * End-to-end SASL PLAIN through ConnectionDO with D1-backed accounts.
3
+ *
4
+ * Verifies the full precedence matrix:
5
+ * - D1 has rows → table is authoritative (900/903 for seeded account,
6
+ * 904 for the env-seed account which the table shadows).
7
+ * - D1 empty → the `SASL_ACCOUNTS` env-var seed still works (fallback).
8
+ *
9
+ * Each test file gets its own isolated D1 database from miniflare, so the
10
+ * table state set up here does not leak into `connection-do.test.ts`.
11
+ */
12
+
13
+ import { env } from 'cloudflare:test';
14
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
+ import { CREATE_ACCOUNTS_TABLE_SQL, putAccountCredential } from '../src/d1-account-store';
16
+
17
+ declare global {
18
+ namespace Cloudflare {
19
+ interface Env {
20
+ CONNECTION_DO: DurableObjectNamespace;
21
+ REGISTRY_DO: DurableObjectNamespace;
22
+ CHANNEL_DO: DurableObjectNamespace;
23
+ ACCOUNTS_DB: D1Database;
24
+ }
25
+ }
26
+ }
27
+
28
+ class IrcWsClient {
29
+ readonly received: string[] = [];
30
+ readonly ws: WebSocket;
31
+
32
+ constructor(ws: WebSocket) {
33
+ this.ws = ws;
34
+ ws.addEventListener('message', (ev: MessageEvent) => {
35
+ const d = ev.data as string;
36
+ for (const line of d.split('\r\n')) {
37
+ if (line.length > 0) this.received.push(line);
38
+ }
39
+ });
40
+ }
41
+
42
+ send(line: string): void {
43
+ this.ws.send(line);
44
+ }
45
+
46
+ waitForMessage(predicate: (line: string) => boolean, timeoutMs = 2000): Promise<string> {
47
+ return new Promise<string>((resolve, reject) => {
48
+ const start = Date.now();
49
+ const tick = (): void => {
50
+ const hit = this.received.find(predicate);
51
+ if (hit !== undefined) {
52
+ resolve(hit);
53
+ return;
54
+ }
55
+ if (Date.now() - start > timeoutMs) {
56
+ reject(new Error(`timeout waiting for message; got: ${JSON.stringify(this.received)}`));
57
+ return;
58
+ }
59
+ setTimeout(tick, 20);
60
+ };
61
+ tick();
62
+ });
63
+ }
64
+
65
+ close(): Promise<void> {
66
+ return new Promise<void>((resolve) => {
67
+ this.ws.addEventListener('close', () => resolve());
68
+ this.ws.close();
69
+ });
70
+ }
71
+ }
72
+
73
+ async function connect(connId: string): Promise<IrcWsClient> {
74
+ const doId = env.CONNECTION_DO.idFromName(connId);
75
+ const stub = env.CONNECTION_DO.get(doId);
76
+ const response = await stub.fetch('https://do/upgrade', {
77
+ headers: { Upgrade: 'websocket' },
78
+ });
79
+ const ws = response.webSocket;
80
+ if (ws === undefined || ws === null) {
81
+ throw new Error('ConnectionDO.fetch did not return a WebSocket');
82
+ }
83
+ ws.accept();
84
+ return new Promise<IrcWsClient>((resolve) => {
85
+ setTimeout(() => resolve(new IrcWsClient(ws as WebSocket)), 0);
86
+ });
87
+ }
88
+
89
+ /** base64(`\0authcid\0password`) — the PLAIN payload format. */
90
+ function plainB64(authcid: string, password: string): string {
91
+ const raw = `\0${authcid}\0${password}`;
92
+ let latin1 = '';
93
+ for (let i = 0; i < raw.length; i++) latin1 += String.fromCharCode(raw.charCodeAt(i));
94
+ return (globalThis as unknown as { btoa: (s: string) => string }).btoa(latin1);
95
+ }
96
+
97
+ /** Negotiates `CAP sasl` and sends an AUTHENTICATE PLAIN payload. */
98
+ async function authenticatePlain(
99
+ client: IrcWsClient,
100
+ authcid: string,
101
+ password: string,
102
+ ): Promise<void> {
103
+ client.send('CAP REQ :sasl\r\n');
104
+ await client.waitForMessage((l) => / CAP \S+ ACK :sasl/.test(l));
105
+ client.send('AUTHENTICATE PLAIN\r\n');
106
+ await client.waitForMessage((l) => l === 'AUTHENTICATE +');
107
+ client.send(`AUTHENTICATE ${plainB64(authcid, password)}\r\n`);
108
+ }
109
+
110
+ describe('ConnectionDO — SASL PLAIN with D1-backed accounts', () => {
111
+ beforeEach(async () => {
112
+ await env.ACCOUNTS_DB.exec(CREATE_ACCOUNTS_TABLE_SQL);
113
+ });
114
+ afterEach(async () => {
115
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
116
+ });
117
+
118
+ it('completes SASL PLAIN with 900/903 for a D1-seeded account', async () => {
119
+ await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
120
+ const client = await connect('conn-d1-sasl-1');
121
+ await authenticatePlain(client, 'd1-alice', 'd1-s3cret');
122
+ await client.waitForMessage((l) => l.includes(' 900 '));
123
+ const success = await client.waitForMessage((l) => l.includes(' 903 '));
124
+ expect(success).toContain(' 903 ');
125
+ await client.close();
126
+ });
127
+
128
+ it('rejects SASL PLAIN with 904 for a wrong password (D1 account)', async () => {
129
+ await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
130
+ const client = await connect('conn-d1-sasl-2');
131
+ await authenticatePlain(client, 'd1-alice', 'wrong');
132
+ const fail = await client.waitForMessage((l) => l.includes(' 904 '));
133
+ expect(fail).toContain(' 904 ');
134
+ await client.close();
135
+ });
136
+
137
+ it('table shadows the env-seed account (table wins)', async () => {
138
+ // SASL_ACCOUNTS env seeds "cf-sasl:s3cret" but D1 has no such row.
139
+ await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
140
+ const client = await connect('conn-d1-sasl-3');
141
+ // env-seed account does NOT verify (table is authoritative).
142
+ await authenticatePlain(client, 'cf-sasl', 's3cret');
143
+ const fail = await client.waitForMessage((l) => l.includes(' 904 '));
144
+ expect(fail).toContain(' 904 ');
145
+ await client.close();
146
+ });
147
+ });
148
+
149
+ describe('ConnectionDO — SASL PLAIN in-memory fallback (D1 empty)', () => {
150
+ // The table exists but has zero rows → resolver falls back to SASL_ACCOUNTS.
151
+ beforeEach(async () => {
152
+ await env.ACCOUNTS_DB.exec(CREATE_ACCOUNTS_TABLE_SQL);
153
+ });
154
+ afterEach(async () => {
155
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
156
+ });
157
+
158
+ it('falls back to SASL_ACCOUNTS seed when D1 is empty', async () => {
159
+ const client = await connect('conn-d1-sasl-fallback');
160
+ await authenticatePlain(client, 'cf-sasl', 's3cret');
161
+ await client.waitForMessage((l) => l.includes(' 900 '));
162
+ const success = await client.waitForMessage((l) => l.includes(' 903 '));
163
+ expect(success).toContain(' 903 ');
164
+ await client.close();
165
+ });
166
+ });
@@ -0,0 +1,289 @@
1
+ /**
2
+ * CF adapter — IRCv3 WebSocket spec-compliance (end-to-end contract).
3
+ *
4
+ * A single integration suite driving the real `ConnectionDO`
5
+ * through `cloudflare:test` (miniflare) that asserts the FULL IRCv3
6
+ * WebSocket spec contract end-to-end against a negotiated `text.ircv3.net`
7
+ * connection (with a legacy fallback for the differential cases):
8
+ *
9
+ * - `Sec-WebSocket-Protocol` echo (`text.ircv3.net` / `binary.ircv3.net`)
10
+ * - one IRC message per WebSocket message, BOTH directions:
11
+ * outbound registration replies arrive as N separate WS messages
12
+ * with no trailing CR-LF; bare inbound frames (no trailing CR-LF)
13
+ * are accepted; an embedded CR-LF is NOT split into two commands.
14
+ * - the 510-byte message budget: a 511-byte message is rejected with
15
+ * RFC 6455 close code 1009 (Message Too Big); a 510-byte message is
16
+ * accepted.
17
+ * - legacy fallback: a connection offering no subprotocol still
18
+ * registers and still splits `\r\n`-joined frames.
19
+ *
20
+ * Hermetic against Miniflare; runs in the normal `pnpm test` suite (no
21
+ * `--e2e` flag). The per-connection negotiation cases live in
22
+ * `connection-do-ws-subprotocol.test.ts`; this file is the
23
+ * consolidated end-to-end contract.
24
+ */
25
+
26
+ import { env } from 'cloudflare:test';
27
+ import { MAX_WS_MESSAGE_BYTES } from '@serverless-ircd/irc-core';
28
+ import { describe, expect, it } from 'vitest';
29
+
30
+ declare global {
31
+ namespace Cloudflare {
32
+ interface Env {
33
+ CONNECTION_DO: DurableObjectNamespace;
34
+ REGISTRY_DO: DurableObjectNamespace;
35
+ CHANNEL_DO: DurableObjectNamespace;
36
+ }
37
+ }
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Helpers — a WebSocket client that records raw WS messages (not pre-split).
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** Poll interval (ms) for the waitFor* helpers. */
45
+ const POLL_MS = 10;
46
+ /** Default timeout (ms) for the waitFor* helpers. */
47
+ const DEFAULT_TIMEOUT_MS = 2000;
48
+
49
+ class SpecWsClient {
50
+ /** Raw WebSocket messages exactly as delivered (one entry per WS message). */
51
+ readonly messages: string[] = [];
52
+ /** Lines split out of `messages` (tolerant of legacy `\r\n` framing). */
53
+ readonly lines: string[] = [];
54
+ readonly ws: WebSocket;
55
+ closedCode: number | undefined;
56
+
57
+ constructor(ws: WebSocket) {
58
+ this.ws = ws;
59
+ ws.addEventListener('message', (ev: MessageEvent) => {
60
+ const data = typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data);
61
+ this.messages.push(data);
62
+ for (const line of data.split('\r\n')) {
63
+ if (line.length > 0) this.lines.push(line);
64
+ }
65
+ });
66
+ ws.addEventListener('close', (ev: CloseEvent) => {
67
+ this.closedCode = ev.code;
68
+ });
69
+ }
70
+
71
+ /** Sends one IRC line as a single WS message WITH a trailing CR-LF. */
72
+ send(line: string): void {
73
+ this.ws.send(`${line}\r\n`);
74
+ }
75
+
76
+ /**
77
+ * Sends `frame` verbatim as a single WS message — no trailing CR-LF is
78
+ * appended. This is how an IRCv3 spec-compliant client sends each IRC
79
+ * message (one per WS message, no terminator on the wire).
80
+ */
81
+ sendRaw(frame: string): void {
82
+ this.ws.send(frame);
83
+ }
84
+
85
+ async waitForLine(
86
+ predicate: (line: string) => boolean,
87
+ timeoutMs = DEFAULT_TIMEOUT_MS,
88
+ ): Promise<string> {
89
+ const deadline = Date.now() + timeoutMs;
90
+ for (const l of this.lines) if (predicate(l)) return l;
91
+ while (Date.now() < deadline) {
92
+ await new Promise((r) => setTimeout(r, POLL_MS));
93
+ for (const l of this.lines) if (predicate(l)) return l;
94
+ }
95
+ throw new Error(`waitForLine timed out; lines: ${JSON.stringify(this.lines)}`);
96
+ }
97
+
98
+ async waitForClose(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<number | undefined> {
99
+ if (this.closedCode !== undefined) return this.closedCode;
100
+ const deadline = Date.now() + timeoutMs;
101
+ while (Date.now() < deadline) {
102
+ await new Promise((r) => setTimeout(r, POLL_MS));
103
+ if (this.closedCode !== undefined) return this.closedCode;
104
+ }
105
+ throw new Error(`waitForClose timed out; messages: ${JSON.stringify(this.messages)}`);
106
+ }
107
+
108
+ /** Waits until at least `n` raw WS messages satisfy `predicate`. */
109
+ async waitForMessages(
110
+ predicate: (msg: string) => boolean,
111
+ n: number,
112
+ timeoutMs = DEFAULT_TIMEOUT_MS,
113
+ ): Promise<string[]> {
114
+ const deadline = Date.now() + timeoutMs;
115
+ const matching = (): string[] => this.messages.filter(predicate);
116
+ let hit = matching();
117
+ if (hit.length >= n) return hit;
118
+ while (Date.now() < deadline) {
119
+ await new Promise((r) => setTimeout(r, POLL_MS));
120
+ hit = matching();
121
+ if (hit.length >= n) return hit;
122
+ }
123
+ throw new Error(
124
+ `waitForMessages(n=${n}) timed out; matching ${hit.length}: ${JSON.stringify(hit)}`,
125
+ );
126
+ }
127
+ }
128
+
129
+ /** Counts raw WS messages whose command token is `cmd` (e.g. 'PONG'). */
130
+ function countCommand(messages: readonly string[], cmd: string): number {
131
+ const upper = cmd.toUpperCase();
132
+ let n = 0;
133
+ for (const msg of messages) {
134
+ const parts = msg.split(' ');
135
+ const start = parts[0]?.startsWith(':') ? 1 : 0;
136
+ if (parts[start]?.toUpperCase() === upper) n++;
137
+ }
138
+ return n;
139
+ }
140
+
141
+ /**
142
+ * Opens a WebSocket to a fresh ConnectionDO, optionally offering a
143
+ * `Sec-WebSocket-Protocol` list. Returns the upgrade Response (so the
144
+ * caller can assert on the echoed header) plus a ready-to-use client.
145
+ */
146
+ async function openUpgrade(
147
+ connId: string,
148
+ protocols?: string,
149
+ ): Promise<{ response: Response; client: SpecWsClient }> {
150
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(connId));
151
+ const headers: Record<string, string> = { Upgrade: 'websocket' };
152
+ if (protocols !== undefined) {
153
+ headers['Sec-WebSocket-Protocol'] = protocols;
154
+ }
155
+ const response = await stub.fetch('https://do/upgrade', { headers });
156
+ const ws = response.webSocket;
157
+ if (ws === undefined || ws === null) {
158
+ throw new Error('ConnectionDO.fetch did not return a WebSocket');
159
+ }
160
+ ws.accept();
161
+ const client = await new Promise<SpecWsClient>((resolve) => {
162
+ setTimeout(() => resolve(new SpecWsClient(ws as WebSocket)), 0);
163
+ });
164
+ return { response, client };
165
+ }
166
+
167
+ /** Registers a client using bare spec-style frames (no trailing CR-LF). */
168
+ async function registerBare(client: SpecWsClient, nick: string): Promise<void> {
169
+ client.sendRaw(`NICK ${nick}`);
170
+ client.sendRaw(`USER ${nick} 0 * :${nick}`);
171
+ await client.waitForLine((l) => l.includes(' 001 '));
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Spec contract
176
+ // ---------------------------------------------------------------------------
177
+
178
+ describe('ConnectionDO — IRCv3 WebSocket spec contract (end-to-end)', () => {
179
+ // --- Sec-WebSocket-Protocol echo ----------------------------------------
180
+
181
+ it('echoes text.ircv3.net and binary.ircv3.net back in the 101 response', async () => {
182
+ const text = await openUpgrade('conn-contract-echo-text', 'text.ircv3.net');
183
+ expect(text.response.status).toBe(101);
184
+ expect(text.response.headers.get('Sec-WebSocket-Protocol')).toBe('text.ircv3.net');
185
+ text.client.ws.close();
186
+
187
+ const bin = await openUpgrade('conn-contract-echo-bin', 'binary.ircv3.net');
188
+ expect(bin.response.status).toBe(101);
189
+ expect(bin.response.headers.get('Sec-WebSocket-Protocol')).toBe('binary.ircv3.net');
190
+ bin.client.ws.close();
191
+ });
192
+
193
+ // --- Outbound: one IRC line per WS message, no trailing CR-LF -----------
194
+
195
+ it('delivers each outbound IRC line as its own WS message with no trailing CR-LF (spec-text)', async () => {
196
+ const { client } = await openUpgrade('conn-contract-outbound', 'text.ircv3.net');
197
+ client.sendRaw('NICK out-bob');
198
+ client.sendRaw('USER out-bob 0 * :out-bob');
199
+ await client.waitForLine((l) => l.includes(' 001 '));
200
+
201
+ // A successful registration emits several numerics (001..005, MOTD).
202
+ expect(client.messages.length).toBeGreaterThan(1);
203
+ for (const msg of client.messages) {
204
+ expect(msg.endsWith('\r\n')).toBe(false);
205
+ }
206
+ client.ws.close();
207
+ });
208
+
209
+ // --- Inbound: one IRC message per WS message ----------------------------
210
+
211
+ it('accepts bare single-line inbound frames (no trailing CR-LF) and completes registration (spec-text)', async () => {
212
+ const { client } = await openUpgrade('conn-contract-inbound-bare', 'text.ircv3.net');
213
+ // Each command is its own WS message with NO trailing CR-LF — the
214
+ // spec-mandated client shape. Registration must still complete.
215
+ await registerBare(client, 'bare-alice');
216
+ expect(client.lines.some((l) => l.includes(' 001 '))).toBe(true);
217
+ client.ws.close();
218
+ });
219
+
220
+ it('treats one WS message as exactly one IRC command (embedded CR-LF is not split) in spec-text mode', async () => {
221
+ const { client } = await openUpgrade('conn-contract-nosplit', 'text.ircv3.net');
222
+ await registerBare(client, 'nosplit-alice');
223
+ client.messages.length = 0;
224
+
225
+ // One WS message that LOOKS like two PING lines. In spec mode the frame
226
+ // is a single IRC message, so exactly ONE PONG is emitted.
227
+ client.sendRaw('PING one\r\nPING two');
228
+ await client.waitForMessages((m) => countCommand([m], 'PONG') === 1, 1);
229
+ // Give a grace window for a (incorrect) second PONG to arrive.
230
+ await new Promise((r) => setTimeout(r, 100));
231
+ expect(countCommand(client.messages, 'PONG')).toBe(1);
232
+ client.ws.close();
233
+ });
234
+
235
+ it('splits a joined CR-LF frame into multiple commands in legacy mode', async () => {
236
+ const { client } = await openUpgrade('conn-contract-legacy-split');
237
+ await registerBare(client, 'legsplit-bob');
238
+ client.messages.length = 0;
239
+
240
+ // Same joined frame, but legacy framing splits on CR-LF → TWO PONGs.
241
+ client.sendRaw('PING one\r\nPING two');
242
+ await client.waitForMessages((m) => countCommand([m], 'PONG') === 1, 2);
243
+ expect(countCommand(client.messages, 'PONG')).toBe(2);
244
+ client.ws.close();
245
+ });
246
+
247
+ // --- 510-byte message budget -------------------------------------------
248
+
249
+ it(`accepts a ${MAX_WS_MESSAGE_BYTES}-byte message and rejects a ${MAX_WS_MESSAGE_BYTES + 1}-byte message with close 1009 (spec-text)`, async () => {
250
+ // Accepted: exactly the budget. A PING whose token pads the frame to
251
+ // MAX_WS_MESSAGE_BYTES bytes parses and is answered with a PONG.
252
+ const okFrame = `PING :${'a'.repeat(MAX_WS_MESSAGE_BYTES - 'PING :'.length)}`;
253
+ expect(okFrame.length).toBe(MAX_WS_MESSAGE_BYTES);
254
+ const ok = await openUpgrade('conn-contract-budget-ok', 'text.ircv3.net');
255
+ ok.client.sendRaw(okFrame);
256
+ await ok.client.waitForLine((l) => l.startsWith('PONG') || l.includes(' PONG '));
257
+ expect(ok.client.closedCode).toBeUndefined();
258
+ ok.client.ws.close();
259
+
260
+ // Rejected: one byte over the budget → RFC 6455 1009 (Message Too Big).
261
+ const bigFrame = `PING :${'a'.repeat(MAX_WS_MESSAGE_BYTES + 1 - 'PING :'.length)}`;
262
+ expect(bigFrame.length).toBe(MAX_WS_MESSAGE_BYTES + 1);
263
+ const big = await openUpgrade('conn-contract-budget-big', 'text.ircv3.net');
264
+ big.client.sendRaw(bigFrame);
265
+ const code = await big.client.waitForClose();
266
+ expect(code).toBe(1009);
267
+
268
+ // The same budget applies on a binary.ircv3.net connection (sent as a
269
+ // binary frame): oversize → close 1009.
270
+ const bin = await openUpgrade('conn-contract-budget-bin', 'binary.ircv3.net');
271
+ bin.client.ws.send(new TextEncoder().encode(bigFrame));
272
+ const binCode = await bin.client.waitForClose();
273
+ expect(binCode).toBe(1009);
274
+ });
275
+
276
+ // --- Legacy fallback ----------------------------------------------------
277
+
278
+ it('still serves a legacy (no subprotocol offer) connection end-to-end', async () => {
279
+ const { response, client } = await openUpgrade('conn-contract-legacy');
280
+ expect(response.headers.get('Sec-WebSocket-Protocol')).toBeNull();
281
+ // Legacy clients join several commands into one CR-LF frame; the server
282
+ // splits and processes both → registration completes.
283
+ client.send('NICK legacy-carol');
284
+ client.send('USER legacy-carol 0 * :legacy-carol');
285
+ await client.waitForLine((l) => l.includes(' 001 '));
286
+ expect(client.lines.some((l) => l.includes(' 001 '))).toBe(true);
287
+ client.ws.close();
288
+ });
289
+ });