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
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Cloudflare Workers-flavoured {@link ServerStats} backend.
3
+ *
4
+ * Aggregates the network-wide counts the `LUSERS` / `STATS` reducers need
5
+ * by fanning out across the existing CF RPC surface: `listChannels` (via
6
+ * the {@link ChannelRegistryDO}) plus per-channel `listMembers` and
7
+ * per-member `getConnState`. Classification (oper / invisible / unknown)
8
+ * is delegated to the shared {@link computeStatsSnapshot} helper in
9
+ * irc-core so the classification rules live in exactly one place.
10
+ *
11
+ * **Limitation:** the CF runtime does not currently expose an
12
+ * "enumerate all connections" RPC, so a connection that has not joined
13
+ * any channel is not counted. This matches the ticket's "aggregate via
14
+ * listChannels / connection-enumeration RPCs" guidance; a future
15
+ * dedicated stats DO would close the gap (every connection would be
16
+ * counted regardless of channel membership).
17
+ *
18
+ * Constructed per `LUSERS` / `STATS` invocation alongside the
19
+ * {@link CfRuntime}; cheap to build, no caching. `uptimeStartedAt` is
20
+ * supplied by the caller (typically the worker's startup timestamp).
21
+ */
22
+
23
+ import type {
24
+ ChanName,
25
+ ConnId,
26
+ ConnectionState,
27
+ ServerStatsSnapshot,
28
+ } from '@serverless-ircd/irc-core';
29
+ import { computeStatsSnapshot } from '@serverless-ircd/irc-core';
30
+
31
+ /**
32
+ * The minimal slice of {@link CfRuntime} (or any adapter runtime) that
33
+ * {@link CfStats} needs. Defined structurally so unit tests can pass a
34
+ * stub without spinning up workerd / miniflare.
35
+ */
36
+ export interface CfStatsRuntime {
37
+ listChannels(): Promise<ReadonlyArray<{ nameLower: string }>>;
38
+ getChannelConnections(name: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>>;
39
+ }
40
+
41
+ export class CfStats {
42
+ private readonly runtime: CfStatsRuntime;
43
+ private readonly uptimeStartedAt: number;
44
+
45
+ constructor(runtime: CfStatsRuntime, uptimeStartedAt: number) {
46
+ this.runtime = runtime;
47
+ this.uptimeStartedAt = uptimeStartedAt;
48
+ }
49
+
50
+ async getStats(): Promise<ServerStatsSnapshot> {
51
+ const channels = await this.runtime.listChannels();
52
+ // Fan out: gather every channel's members into one deduplicated map.
53
+ // A user in N channels is counted once.
54
+ const unique = new Map<ConnId, ConnectionState>();
55
+ await Promise.all(
56
+ channels.map(async (chan) => {
57
+ const members = await this.runtime.getChannelConnections(chan.nameLower as ChanName);
58
+ for (const [id, state] of members) {
59
+ unique.set(id, state);
60
+ }
61
+ }),
62
+ );
63
+ return computeStatsSnapshot(unique.values(), channels.length, this.uptimeStartedAt);
64
+ }
65
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * CF-side {@link IrcHarnessFactory} — registers the parametrized IRC
3
- * scenario suite (032) against the Cloudflare adapter.
3
+ * scenario suite from `@serverless-ircd/irc-test-support` against the Cloudflare adapter.
4
4
  *
5
5
  * Each {@link CfHarness} instance is hermetic at the vitest-pool-workers
6
6
  * level: every test gets an isolated storage namespace
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Parametrized IRC scenarios against the Cloudflare runtime — 036.
2
+ * Parametrized IRC scenarios against the Cloudflare runtime.
3
3
  *
4
4
  * Reuses the scenario runner from `@serverless-ircd/irc-test-support`
5
- * (032) and registers the CF harness factory built on top of the
5
+ * and registers the CF harness factory built on top of the
6
6
  * real ConnectionDO / RegistryDO / ChannelDO worker bindings. Each
7
7
  * scenario spawns one or more real WebSockets and drives the full
8
8
  * bytes → actor → CfRuntime pipeline inside `workerd`.
@@ -15,13 +15,13 @@
15
15
  * against the in-memory runtime; CF-specific failures stem from the
16
16
  * ConnectionDO's `PassthroughChannelAccess` not yet fetching
17
17
  * authoritative roster state from ChannelDO before running channel
18
- * reducers. That wiring lands in a follow-up to keep this ticket
18
+ * reducers. That wiring lands in a follow-up to keep this work
19
19
  * focused on the CfRuntime class itself):
20
20
  * - Multi-client channel reads (NAMES of peers, KICK roster refresh)
21
21
  * see only the local connection until the actor's channel access
22
22
  * is upgraded to read from ChannelDO.
23
23
  * - ChannelDO.getChannelSnapshot has an unrelated bug in the parallel
24
- * ChannelDO ticket (035) that returns a malformed snapshot;
24
+ * ChannelDO work that returns a malformed snapshot;
25
25
  * that bug is not introduced here.
26
26
  */
27
27
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CfRuntime — 036.
2
+ * CfRuntime.
3
3
  *
4
4
  * Verifies that every {@link IrcRuntime} method on {@link CfRuntime} issues
5
5
  * the correct DO `stub()` call:
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Two envs are used:
17
17
  * - `env` — production bindings (recording stubs for the
18
- * 033 unit suite; swapped to `_REAL` for
18
+ * ConnectionDO unit suite; swapped to `_REAL` for
19
19
  * the CfRuntime uniqueness + end-to-end tests).
20
20
  * - `envWithRealDOs()` — a shim that re-exposes the real RegistryDO +
21
21
  * ChannelDO under the production binding names,
@@ -616,3 +616,39 @@ describe('CfRuntime — IrcRuntime structural conformance', () => {
616
616
  expect(handlers.sent).toEqual([':server 001 alice :Welcome']);
617
617
  });
618
618
  });
619
+
620
+ describe('CfRuntime — reloadConfig (REHASH)', () => {
621
+ it('re-reads the bound Workers env and reparses the server config', async () => {
622
+ const realEnv = envWithRealDOs();
623
+ const rt = new CfRuntime(realEnv, 'conn-rehash', recordingHandlers());
624
+
625
+ const cfg = await rt.reloadConfig();
626
+
627
+ expect(cfg.serverName).toBe('irc.example.com');
628
+ expect(cfg.networkName).toBe('ExampleNet');
629
+ });
630
+
631
+ it('reflects a rotated env value on the next reload (no caching)', async () => {
632
+ const rotatingEnv = {
633
+ ...envWithRealDOs(),
634
+ OPER_USER: 'admin',
635
+ OPER_PASSWORD: 'old',
636
+ };
637
+ const rt = new CfRuntime(rotatingEnv, 'conn-rehash-2', recordingHandlers());
638
+
639
+ const first = await rt.reloadConfig();
640
+ expect(first.operCreds).toEqual([{ user: 'admin', password: 'old' }]);
641
+
642
+ // Rotate the env (simulate a re-deployed secret) and reload again.
643
+ rotatingEnv.OPER_PASSWORD = 'rotated';
644
+ const second = await rt.reloadConfig();
645
+ expect(second.operCreds).toEqual([{ user: 'admin', password: 'rotated' }]);
646
+ });
647
+
648
+ it('propagates a schema-validation failure so the actor applies the graceful 382', async () => {
649
+ const badEnv: Env = { ...envWithRealDOs(), NETWORK_NAME: '' };
650
+ const rt = new CfRuntime(badEnv, 'conn-rehash-bad', recordingHandlers());
651
+
652
+ await expect(rt.reloadConfig()).rejects.toThrow(/networkName/u);
653
+ });
654
+ });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ChannelDO — roster + modes + fanout via ConnectionDO stubs.
3
3
  *
4
- * TDD outline (from tickets.md):
4
+ * TDD outline:
5
5
  * "Red on 'two members of a channel both receive PRIVMSG'; green;
6
6
  * then gone-connection sweep."
7
7
  *
@@ -452,7 +452,7 @@ describe('ChannelDO — instance keying', () => {
452
452
  });
453
453
 
454
454
  // ---------------------------------------------------------------------------
455
- // Scale smoke test — exercises the 1k-member ceiling from the ticket.
455
+ // Scale smoke test — exercises the 1k-member ceiling.
456
456
  // ---------------------------------------------------------------------------
457
457
 
458
458
  describe('ChannelDO — scale', () => {
@@ -1,8 +1,8 @@
1
1
  /**
2
- * 085 — CF `webSocketMessage` channel-registration: wire ChannelDO
2
+ * CF `webSocketMessage` channel-registration: wire ChannelDO
3
3
  * registration OR remove the dead loop.
4
4
  *
5
- * Spike conclusion (documented in the ticket): the dead loop that
5
+ * Spike conclusion: the dead loop that
6
6
  * iterated `state.joinedChannels` to "register" the ConnectionDO with
7
7
  * each ChannelDO is redundant. Channel membership — and therefore
8
8
  * broadcast fan-out registration — is driven solely by the
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 087 — CF outbound batching: implement OR drop the reserved optimization.
2
+ * CF outbound batching: implement OR drop the reserved optimization.
3
3
  *
4
- * Spike conclusion (documented in the ticket): the speculative per-microtask
4
+ * Spike conclusion: the speculative per-microtask
5
5
  * outbound drain that was reserved as a commented-out field in
6
6
  * `ConnectionDO` is dropped. Per-call batching is already implemented —
7
7
  * `ConnectionDO.deliver()` joins every line of a ChannelDO broadcast into
@@ -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
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * CF adapter — IRCv3 WebSocket subprotocol negotiation + per-message framing.
3
+ *
4
+ * Drives the real `ConnectionDO` through `cloudflare:test` (miniflare) so
5
+ * the `Sec-WebSocket-Protocol` round-trip, hibernation tags, and frame
6
+ * framing behave exactly as in production. Each test gets its own
7
+ * isolated storage namespace.
8
+ */
9
+
10
+ import { env } from 'cloudflare:test';
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ declare global {
14
+ namespace Cloudflare {
15
+ interface Env {
16
+ CONNECTION_DO: DurableObjectNamespace;
17
+ REGISTRY_DO: DurableObjectNamespace;
18
+ CHANNEL_DO: DurableObjectNamespace;
19
+ }
20
+ }
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Helpers — WebSocket client that records raw WS messages (not split lines).
25
+ // ---------------------------------------------------------------------------
26
+
27
+ class SpecWsClient {
28
+ /** Raw WebSocket messages exactly as delivered (one entry per WS message). */
29
+ readonly messages: string[] = [];
30
+ /** Lines split out of `messages` (tolerant of legacy `\r\n` framing). */
31
+ readonly lines: string[] = [];
32
+ readonly ws: WebSocket;
33
+ closedCode: number | undefined;
34
+
35
+ constructor(ws: WebSocket) {
36
+ this.ws = ws;
37
+ ws.addEventListener('message', (ev: MessageEvent) => {
38
+ const data = typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data);
39
+ this.messages.push(data);
40
+ for (const line of data.split('\r\n')) {
41
+ if (line.length > 0) this.lines.push(line);
42
+ }
43
+ });
44
+ ws.addEventListener('close', (ev: CloseEvent) => {
45
+ this.closedCode = ev.code;
46
+ });
47
+ }
48
+
49
+ send(line: string): void {
50
+ this.ws.send(`${line}\r\n`);
51
+ }
52
+
53
+ waitForLine(predicate: (line: string) => boolean, timeoutMs = 2000): Promise<string> {
54
+ return new Promise<string>((resolve, reject) => {
55
+ const start = Date.now();
56
+ const tick = (): void => {
57
+ const hit = this.lines.find(predicate);
58
+ if (hit !== undefined) {
59
+ resolve(hit);
60
+ return;
61
+ }
62
+ if (Date.now() - start > timeoutMs) {
63
+ reject(new Error(`timeout waiting for line; got: ${JSON.stringify(this.lines)}`));
64
+ return;
65
+ }
66
+ setTimeout(tick, 20);
67
+ };
68
+ tick();
69
+ });
70
+ }
71
+
72
+ waitForClose(timeoutMs = 2000): Promise<number | undefined> {
73
+ return new Promise<number | undefined>((resolve, reject) => {
74
+ if (this.closedCode !== undefined) {
75
+ resolve(this.closedCode);
76
+ return;
77
+ }
78
+ const start = Date.now();
79
+ const tick = (): void => {
80
+ if (this.closedCode !== undefined) {
81
+ resolve(this.closedCode);
82
+ return;
83
+ }
84
+ if (Date.now() - start > timeoutMs) {
85
+ reject(
86
+ new Error(`timeout waiting for close; messages: ${JSON.stringify(this.messages)}`),
87
+ );
88
+ return;
89
+ }
90
+ setTimeout(tick, 20);
91
+ };
92
+ tick();
93
+ });
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Opens a WebSocket to a fresh ConnectionDO, optionally offering a
99
+ * `Sec-WebSocket-Protocol` list. Returns the upgrade Response so the
100
+ * caller can assert on the echoed header, plus a ready-to-use client.
101
+ */
102
+ async function openUpgrade(
103
+ connId: string,
104
+ protocols?: string,
105
+ ): Promise<{ response: Response; client: SpecWsClient }> {
106
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(connId));
107
+ const headers: Record<string, string> = { Upgrade: 'websocket' };
108
+ if (protocols !== undefined) {
109
+ headers['Sec-WebSocket-Protocol'] = protocols;
110
+ }
111
+ const response = await stub.fetch('https://do/upgrade', { headers });
112
+ const ws = response.webSocket;
113
+ if (ws === undefined || ws === null) {
114
+ throw new Error('ConnectionDO.fetch did not return a WebSocket');
115
+ }
116
+ ws.accept();
117
+ const client = await new Promise<SpecWsClient>((resolve) => {
118
+ setTimeout(() => resolve(new SpecWsClient(ws as WebSocket)), 0);
119
+ });
120
+ return { response, client };
121
+ }
122
+
123
+ async function register(client: SpecWsClient, nick: string): Promise<void> {
124
+ client.send(`NICK ${nick}`);
125
+ client.send(`USER ${nick} 0 * :${nick}`);
126
+ await client.waitForLine((l) => l.includes(' 001 '));
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Acceptance criteria
131
+ // ---------------------------------------------------------------------------
132
+
133
+ describe('ConnectionDO — IRCv3 WebSocket subprotocol negotiation', () => {
134
+ it('echoes text.ircv3.net back in the 101 response Sec-WebSocket-Protocol header', async () => {
135
+ const { response, client } = await openUpgrade('conn-subproto-text', 'text.ircv3.net');
136
+ expect(response.status).toBe(101);
137
+ expect(response.headers.get('Sec-WebSocket-Protocol')).toBe('text.ircv3.net');
138
+ client.ws.close();
139
+ });
140
+
141
+ it('selects the first-listed supported protocol when both are offered', async () => {
142
+ const a = await openUpgrade('conn-subproto-order-a', 'text.ircv3.net, binary.ircv3.net');
143
+ expect(a.response.headers.get('Sec-WebSocket-Protocol')).toBe('text.ircv3.net');
144
+ a.client.ws.close();
145
+
146
+ const b = await openUpgrade('conn-subproto-order-b', 'binary.ircv3.net, text.ircv3.net');
147
+ expect(b.response.headers.get('Sec-WebSocket-Protocol')).toBe('binary.ircv3.net');
148
+ b.client.ws.close();
149
+ });
150
+
151
+ it('omits Sec-WebSocket-Protocol when the client offers no supported protocol', async () => {
152
+ const { response, client } = await openUpgrade('conn-subproto-unsupported', 'bogus.proto');
153
+ expect(response.status).toBe(101);
154
+ expect(response.headers.get('Sec-WebSocket-Protocol')).toBeNull();
155
+ client.ws.close();
156
+ });
157
+
158
+ it('connects without a subprotocol offer (legacy mode) and registers', async () => {
159
+ const { client } = await openUpgrade('conn-subproto-legacy');
160
+ await register(client, 'legacy-alice');
161
+ expect(client.lines.some((l) => l.includes(' 001 '))).toBe(true);
162
+ client.ws.close();
163
+ });
164
+
165
+ it('delivers registration replies as N separate WS messages with no trailing CRLF (spec-text)', async () => {
166
+ const { client } = await openUpgrade('conn-subproto-permsg', 'text.ircv3.net');
167
+ client.send('NICK spec-bob');
168
+ client.send('USER spec-bob 0 * :spec-bob');
169
+ await client.waitForLine((l) => l.includes(' 001 '));
170
+
171
+ expect(client.messages.length).toBeGreaterThan(1);
172
+ for (const msg of client.messages) {
173
+ expect(msg.endsWith('\r\n')).toBe(false);
174
+ }
175
+ client.ws.close();
176
+ });
177
+
178
+ it('closes with code 1003 when a binary frame arrives on a spec-text connection', async () => {
179
+ const { client } = await openUpgrade('conn-subproto-bin-on-text', 'text.ircv3.net');
180
+ client.ws.send(new Uint8Array([0x4e, 0x49, 0x43, 0x4b]).buffer);
181
+ const code = await client.waitForClose();
182
+ expect(code).toBe(1003);
183
+ });
184
+ });