serverless-ircd 0.1.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 (165) hide show
  1. package/.github/workflows/ci.yml +47 -0
  2. package/.github/workflows/deploy-cf.yml +97 -0
  3. package/LICENSE +29 -0
  4. package/README.md +323 -0
  5. package/apps/cf-worker/package.json +37 -0
  6. package/apps/cf-worker/scripts/smoke.mjs +175 -0
  7. package/apps/cf-worker/src/worker.ts +59 -0
  8. package/apps/cf-worker/tests/smoke.test.ts +150 -0
  9. package/apps/cf-worker/tsconfig.build.json +17 -0
  10. package/apps/cf-worker/tsconfig.test.json +18 -0
  11. package/apps/cf-worker/vitest.config.ts +31 -0
  12. package/apps/cf-worker/wrangler.test.toml +33 -0
  13. package/apps/cf-worker/wrangler.toml +98 -0
  14. package/apps/local-cli/package.json +35 -0
  15. package/apps/local-cli/src/line-scanner.ts +60 -0
  16. package/apps/local-cli/src/main.ts +145 -0
  17. package/apps/local-cli/src/motd-file.ts +45 -0
  18. package/apps/local-cli/src/server.ts +409 -0
  19. package/apps/local-cli/tests/e2e.test.ts +346 -0
  20. package/apps/local-cli/tests/id-factory.test.ts +25 -0
  21. package/apps/local-cli/tests/line-scanner.test.ts +81 -0
  22. package/apps/local-cli/tests/motd-file.test.ts +71 -0
  23. package/apps/local-cli/tests/tcp.test.ts +358 -0
  24. package/apps/local-cli/tsconfig.build.json +17 -0
  25. package/apps/local-cli/tsconfig.test.json +15 -0
  26. package/apps/local-cli/vitest.config.ts +24 -0
  27. package/biome.json +52 -0
  28. package/package.json +35 -0
  29. package/packages/cf-adapter/package.json +38 -0
  30. package/packages/cf-adapter/src/cf-runtime.ts +308 -0
  31. package/packages/cf-adapter/src/channel-do.ts +374 -0
  32. package/packages/cf-adapter/src/connection-do.ts +542 -0
  33. package/packages/cf-adapter/src/env.ts +67 -0
  34. package/packages/cf-adapter/src/index.ts +38 -0
  35. package/packages/cf-adapter/src/registry-do.ts +130 -0
  36. package/packages/cf-adapter/src/serialize.ts +142 -0
  37. package/packages/cf-adapter/src/sharding.ts +101 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +264 -0
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
  41. package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
  42. package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
  43. package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
  44. package/packages/cf-adapter/tests/integration/harness.ts +102 -0
  45. package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
  46. package/packages/cf-adapter/tests/integration/index.ts +17 -0
  47. package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
  48. package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
  49. package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
  50. package/packages/cf-adapter/tests/sharding.test.ts +100 -0
  51. package/packages/cf-adapter/tests/worker/main.ts +33 -0
  52. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
  53. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
  54. package/packages/cf-adapter/tsconfig.build.json +16 -0
  55. package/packages/cf-adapter/tsconfig.test.json +18 -0
  56. package/packages/cf-adapter/vitest.config.ts +32 -0
  57. package/packages/cf-adapter/wrangler.test.toml +50 -0
  58. package/packages/in-memory-runtime/package.json +29 -0
  59. package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
  60. package/packages/in-memory-runtime/src/index.ts +9 -0
  61. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
  62. package/packages/in-memory-runtime/tsconfig.build.json +16 -0
  63. package/packages/in-memory-runtime/tsconfig.test.json +14 -0
  64. package/packages/in-memory-runtime/vitest.config.ts +21 -0
  65. package/packages/irc-core/package.json +29 -0
  66. package/packages/irc-core/src/caps/capabilities.ts +96 -0
  67. package/packages/irc-core/src/caps/index.ts +1 -0
  68. package/packages/irc-core/src/commands/away.ts +82 -0
  69. package/packages/irc-core/src/commands/cap.ts +257 -0
  70. package/packages/irc-core/src/commands/chghost.ts +30 -0
  71. package/packages/irc-core/src/commands/index.ts +40 -0
  72. package/packages/irc-core/src/commands/invite.ts +156 -0
  73. package/packages/irc-core/src/commands/isupport.ts +133 -0
  74. package/packages/irc-core/src/commands/join.ts +309 -0
  75. package/packages/irc-core/src/commands/kick.ts +162 -0
  76. package/packages/irc-core/src/commands/list.ts +126 -0
  77. package/packages/irc-core/src/commands/mode.ts +655 -0
  78. package/packages/irc-core/src/commands/motd.ts +146 -0
  79. package/packages/irc-core/src/commands/names.ts +164 -0
  80. package/packages/irc-core/src/commands/part.ts +118 -0
  81. package/packages/irc-core/src/commands/ping.ts +47 -0
  82. package/packages/irc-core/src/commands/privmsg.ts +256 -0
  83. package/packages/irc-core/src/commands/quit.ts +70 -0
  84. package/packages/irc-core/src/commands/registration.ts +251 -0
  85. package/packages/irc-core/src/commands/topic.ts +171 -0
  86. package/packages/irc-core/src/commands/who.ts +169 -0
  87. package/packages/irc-core/src/commands/whois.ts +165 -0
  88. package/packages/irc-core/src/effects.ts +184 -0
  89. package/packages/irc-core/src/index.ts +20 -0
  90. package/packages/irc-core/src/ports.ts +153 -0
  91. package/packages/irc-core/src/protocol/batch.ts +85 -0
  92. package/packages/irc-core/src/protocol/index.ts +18 -0
  93. package/packages/irc-core/src/protocol/messages.ts +36 -0
  94. package/packages/irc-core/src/protocol/numerics.ts +155 -0
  95. package/packages/irc-core/src/protocol/outbound.ts +145 -0
  96. package/packages/irc-core/src/protocol/parser.ts +175 -0
  97. package/packages/irc-core/src/protocol/serializer.ts +71 -0
  98. package/packages/irc-core/src/state/channel.ts +293 -0
  99. package/packages/irc-core/src/state/connection.ts +153 -0
  100. package/packages/irc-core/src/state/index.ts +25 -0
  101. package/packages/irc-core/src/state/registry.ts +22 -0
  102. package/packages/irc-core/src/types.ts +83 -0
  103. package/packages/irc-core/tests/batch.test.ts +79 -0
  104. package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
  105. package/packages/irc-core/tests/commands/away.test.ts +233 -0
  106. package/packages/irc-core/tests/commands/batch.test.ts +178 -0
  107. package/packages/irc-core/tests/commands/cap.test.ts +499 -0
  108. package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
  109. package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
  110. package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
  111. package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
  112. package/packages/irc-core/tests/commands/invite.test.ts +321 -0
  113. package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
  114. package/packages/irc-core/tests/commands/join.test.ts +687 -0
  115. package/packages/irc-core/tests/commands/kick.test.ts +316 -0
  116. package/packages/irc-core/tests/commands/list.test.ts +452 -0
  117. package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
  118. package/packages/irc-core/tests/commands/motd.test.ts +322 -0
  119. package/packages/irc-core/tests/commands/names.test.ts +342 -0
  120. package/packages/irc-core/tests/commands/part.test.ts +265 -0
  121. package/packages/irc-core/tests/commands/ping.test.ts +144 -0
  122. package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
  123. package/packages/irc-core/tests/commands/quit.test.ts +220 -0
  124. package/packages/irc-core/tests/commands/registration.test.ts +599 -0
  125. package/packages/irc-core/tests/commands/topic.test.ts +337 -0
  126. package/packages/irc-core/tests/commands/who.test.ts +441 -0
  127. package/packages/irc-core/tests/commands/whois.test.ts +422 -0
  128. package/packages/irc-core/tests/effects.test.ts +147 -0
  129. package/packages/irc-core/tests/message-tags.test.ts +182 -0
  130. package/packages/irc-core/tests/numerics.test.ts +98 -0
  131. package/packages/irc-core/tests/outbound.test.ts +232 -0
  132. package/packages/irc-core/tests/parser.test.ts +162 -0
  133. package/packages/irc-core/tests/ports.test.ts +101 -0
  134. package/packages/irc-core/tests/serializer.test.ts +164 -0
  135. package/packages/irc-core/tests/state/channel.test.ts +351 -0
  136. package/packages/irc-core/tests/state/connection.test.ts +122 -0
  137. package/packages/irc-core/tests/state/registry.test.ts +20 -0
  138. package/packages/irc-core/tests/types.test.ts +127 -0
  139. package/packages/irc-core/tsconfig.build.json +12 -0
  140. package/packages/irc-core/tsconfig.test.json +10 -0
  141. package/packages/irc-core/vitest.config.ts +21 -0
  142. package/packages/irc-server/package.json +28 -0
  143. package/packages/irc-server/src/actor.ts +449 -0
  144. package/packages/irc-server/src/dispatch.ts +120 -0
  145. package/packages/irc-server/src/index.ts +11 -0
  146. package/packages/irc-server/src/runtime.ts +61 -0
  147. package/packages/irc-server/tests/actor.test.ts +816 -0
  148. package/packages/irc-server/tests/dispatch.test.ts +512 -0
  149. package/packages/irc-server/tests/runtime.test.ts +52 -0
  150. package/packages/irc-server/tsconfig.build.json +13 -0
  151. package/packages/irc-server/tsconfig.test.json +11 -0
  152. package/packages/irc-server/vitest.config.ts +21 -0
  153. package/pnpm-workspace.yaml +4 -0
  154. package/tools/tcp-ws-forwarder/package.json +32 -0
  155. package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
  156. package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
  157. package/tools/tcp-ws-forwarder/src/main.ts +114 -0
  158. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
  159. package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
  160. package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
  161. package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
  162. package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
  163. package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
  164. package/tsconfig.base.json +25 -0
  165. package/turbo.json +29 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Parametrized IRC scenarios against the Cloudflare runtime — 036.
3
+ *
4
+ * Reuses the scenario runner from `./integration/scenarios.js`
5
+ * (032) and registers the CF harness factory built on top of the
6
+ * real ConnectionDO / RegistryDO / ChannelDO worker bindings. Each
7
+ * scenario spawns one or more real WebSockets and drives the full
8
+ * bytes → actor → CfRuntime pipeline inside `workerd`.
9
+ *
10
+ * Test isolation: `vitest.config.ts` sets `isolatedStorage: true`, so
11
+ * every test gets a fresh DO storage namespace even though they all
12
+ * address the same `wrangler.test.toml` bindings.
13
+ *
14
+ * Known-issue notebook (these scenarios are the same ones that pass
15
+ * against the in-memory runtime; CF-specific failures stem from the
16
+ * ConnectionDO's `PassthroughChannelAccess` not yet fetching
17
+ * authoritative roster state from ChannelDO before running channel
18
+ * reducers. That wiring lands in a follow-up to keep this ticket
19
+ * focused on the CfRuntime class itself):
20
+ * - Multi-client channel reads (NAMES of peers, KICK roster refresh)
21
+ * see only the local connection until the actor's channel access
22
+ * is upgraded to read from ChannelDO.
23
+ * - ChannelDO.getChannelSnapshot has an unrelated bug in the parallel
24
+ * ChannelDO ticket (035) that returns a malformed snapshot;
25
+ * that bug is not introduced here.
26
+ */
27
+
28
+ import { env, reset } from 'cloudflare:test';
29
+ import { afterAll, afterEach, beforeAll } from 'vitest';
30
+ import { makeCfHarnessFactory } from './cf-harness';
31
+ import { runIrcScenarios } from './integration/scenarios.js';
32
+
33
+ declare global {
34
+ namespace Cloudflare {
35
+ interface Env {
36
+ CONNECTION_DO: DurableObjectNamespace;
37
+ REGISTRY_DO: DurableObjectNamespace;
38
+ CHANNEL_DO: DurableObjectNamespace;
39
+ }
40
+ }
41
+ }
42
+
43
+ let factory: ReturnType<typeof makeCfHarnessFactory> | undefined;
44
+
45
+ beforeAll(() => {
46
+ factory = makeCfHarnessFactory({ env });
47
+ });
48
+
49
+ // DO storage persists across tests in the same file; reset between each so
50
+ // nick reservations, connection state, and channel rosters from one scenario
51
+ // never leak into the next.
52
+ afterEach(async () => {
53
+ await reset();
54
+ });
55
+
56
+ afterAll(async () => {
57
+ await reset();
58
+ });
59
+
60
+ // Registered lazily inside beforeAll because vitest collects `describe`
61
+ // trees at module load; we build the factory on first run so the env is
62
+ // guaranteed to be initialized.
63
+ runIrcScenarios([
64
+ {
65
+ name: 'cf',
66
+ async create() {
67
+ if (factory === undefined) {
68
+ factory = makeCfHarnessFactory({ env });
69
+ }
70
+ return factory.create();
71
+ },
72
+ },
73
+ ]);
@@ -0,0 +1,527 @@
1
+ /**
2
+ * CfRuntime — 036.
3
+ *
4
+ * Verifies that every {@link IrcRuntime} method on {@link CfRuntime} issues
5
+ * the correct DO `stub()` call:
6
+ *
7
+ * - Nick registry ops route to `REGISTRY_DO` sharded by `registryKeyForNick`.
8
+ * - Channel ops route to `CHANNEL_DO` keyed by lowercased channel name.
9
+ * - Cross-connection send / getConnectionInfo route to `CONNECTION_DO` RPC.
10
+ * - Local-conn shortcuts (`to === connId`, snapshot via handlers) hit the
11
+ * injected per-connection handlers instead of RPC-to-self.
12
+ *
13
+ * Tests run inside real workerd via miniflare so DO RPC, storage, and
14
+ * hibernation behave as in production. Each test gets isolated storage.
15
+ *
16
+ * Two envs are used:
17
+ * - `env` — production bindings (recording stubs for the
18
+ * 033 unit suite; swapped to `_REAL` for
19
+ * the CfRuntime uniqueness + end-to-end tests).
20
+ * - `envWithRealDOs()` — a shim that re-exposes the real RegistryDO +
21
+ * ChannelDO under the production binding names,
22
+ * so CfRuntime exercises the real sharded path.
23
+ */
24
+
25
+ import { env, reset, runInDurableObject } from 'cloudflare:test';
26
+ import {
27
+ type ChanSnapshot,
28
+ type ConnectionState,
29
+ type RawLine,
30
+ createConnection,
31
+ } from '@serverless-ircd/irc-core';
32
+ import { afterAll, afterEach, describe, expect, it } from 'vitest';
33
+ import { type CfConnectionHandlers, CfRuntime } from '../src/cf-runtime';
34
+ import type { Env, RegistryRpc } from '../src/env';
35
+ import { registryKeyForNick } from '../src/sharding';
36
+
37
+ declare global {
38
+ namespace Cloudflare {
39
+ interface Env {
40
+ CONNECTION_DO: DurableObjectNamespace;
41
+ REGISTRY_DO: DurableObjectNamespace;
42
+ REGISTRY_DO_REAL: DurableObjectNamespace;
43
+ REGISTRY_DO_STUB: DurableObjectNamespace;
44
+ CHANNEL_DO: DurableObjectNamespace;
45
+ CHANNEL_DO_REAL: DurableObjectNamespace;
46
+ CHANNEL_DO_STUB: DurableObjectNamespace;
47
+ }
48
+ }
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Env shims
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Returns an env shim where REGISTRY_DO and CHANNEL_DO point at the real
57
+ * DO classes. Use this to verify CfRuntime against production-correct
58
+ * semantics (real nick uniqueness, real channel state).
59
+ */
60
+ function envWithRealDOs(): Env {
61
+ return {
62
+ CONNECTION_DO: env.CONNECTION_DO,
63
+ REGISTRY_DO: env.REGISTRY_DO_REAL,
64
+ CHANNEL_DO: env.CHANNEL_DO_REAL,
65
+ SERVER_NAME: 'irc.example.com',
66
+ NETWORK_NAME: 'ExampleNet',
67
+ MOTD_LINES: '',
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Returns an env shim where CHANNEL_DO points at the recording stub.
73
+ * Used for tests that assert on the *shape* of the RPC call rather than
74
+ * the end-to-end fanout (which the real ChannelDO owns).
75
+ */
76
+ function envWithChannelStub(): Env {
77
+ return {
78
+ CONNECTION_DO: env.CONNECTION_DO,
79
+ REGISTRY_DO: env.REGISTRY_DO_REAL,
80
+ CHANNEL_DO: env.CHANNEL_DO_STUB,
81
+ SERVER_NAME: 'irc.example.com',
82
+ NETWORK_NAME: 'ExampleNet',
83
+ MOTD_LINES: '',
84
+ };
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Helpers
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /** Lines-only handlers used by every test; the asserts inspect `sent`. */
92
+ function recordingHandlers(
93
+ snapshot?: () => ConnectionState | undefined,
94
+ ): CfConnectionHandlers & { sent: string[]; disconnects: string[] } {
95
+ const sent: string[] = [];
96
+ const disconnects: string[] = [];
97
+ return {
98
+ sent,
99
+ disconnects,
100
+ send: (lines: RawLine[]): void => {
101
+ for (const l of lines) sent.push(l.text);
102
+ },
103
+ disconnect: (reason?: string): void => {
104
+ disconnects.push(reason ?? '');
105
+ },
106
+ snapshot: snapshot ?? ((): undefined => undefined),
107
+ };
108
+ }
109
+
110
+ /** Returns a registry DO RPC stub for the shard owning `nick`. */
111
+ function registryForNick(targetEnv: Env, nick: string): RegistryRpc {
112
+ const key = registryKeyForNick(nick);
113
+ const stub = targetEnv.REGISTRY_DO.get(targetEnv.REGISTRY_DO.idFromName(key));
114
+ return stub as unknown as RegistryRpc;
115
+ }
116
+
117
+ /** Returns the RecordingChannelDO call log for the lowercased channel name. */
118
+ async function channelCallLog(chanLower: string): Promise<{ method: string; args: unknown[] }[]> {
119
+ const stub = env.CHANNEL_DO_STUB.get(env.CHANNEL_DO_STUB.idFromName(chanLower));
120
+ return runInDurableObject(stub, async (instance: unknown) => {
121
+ const doi = instance as {
122
+ recordedCalls?: () => Promise<{ method: string; args: unknown[] }[]>;
123
+ };
124
+ return (await doi.recordedCalls?.()) ?? [];
125
+ });
126
+ }
127
+
128
+ async function waitForChannelCall(
129
+ chanLower: string,
130
+ method: string,
131
+ timeoutMs = 2000,
132
+ ): Promise<{ method: string; args: unknown[] }[]> {
133
+ const start = Date.now();
134
+ while (Date.now() - start < timeoutMs) {
135
+ const all = await channelCallLog(chanLower);
136
+ const matching = all.filter((c) => c.method === method);
137
+ if (matching.length > 0) return matching;
138
+ await new Promise((r) => setTimeout(r, 20));
139
+ }
140
+ return [];
141
+ }
142
+
143
+ /**
144
+ * Opens a WebSocket to a fresh ConnectionDO named `connId`, so the
145
+ * hibernatable socket exists for `deliver` RPC to write to.
146
+ */
147
+ async function openConnection(connId: string): Promise<WebSocket> {
148
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(connId));
149
+ const response = await stub.fetch('https://do/upgrade', {
150
+ headers: { Upgrade: 'websocket' },
151
+ });
152
+ const ws = response.webSocket;
153
+ if (ws === undefined || ws === null) {
154
+ throw new Error('ConnectionDO.fetch did not return a WebSocket');
155
+ }
156
+ ws.accept();
157
+ return new Promise<WebSocket>((resolve) => {
158
+ setTimeout(() => resolve(ws as WebSocket), 0);
159
+ });
160
+ }
161
+
162
+ /** Drains received WebSocket frames into a string array. */
163
+ function drainSocket(ws: WebSocket): string[] {
164
+ const received: string[] = [];
165
+ ws.addEventListener('message', (ev: MessageEvent) => {
166
+ const d = ev.data as string;
167
+ for (const line of d.split('\r\n')) {
168
+ if (line.length > 0) received.push(line);
169
+ }
170
+ });
171
+ return received;
172
+ }
173
+
174
+ /** Resolves the hex id of a ConnectionDO instance (its canonical ConnId). */
175
+ async function getConnectionHexId(connId: string): Promise<string> {
176
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(connId));
177
+ return runInDurableObject(stub, async (instance: unknown) => {
178
+ const doi = instance as { __peekHexId?: () => string };
179
+ return doi.__peekHexId?.() ?? connId;
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Manually reserves `nick` for `connHexId` in the *real* registry, so
185
+ * CfRuntime.sendToNick can resolve the owner without having to drive the
186
+ * full registration handshake through the actor.
187
+ */
188
+ async function reserveNickInRealRegistry(nick: string, connHexId: string): Promise<void> {
189
+ await registryForNick(envWithRealDOs(), nick).reserveNick(nick, connHexId);
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Tests — registry routing (sharded by nick, real RegistryDO)
194
+ // ---------------------------------------------------------------------------
195
+
196
+ // DO storage persists across tests in the same file; reset between each so
197
+ // nick reservations and connection state from one test never leak into the
198
+ // next.
199
+ afterEach(async () => {
200
+ await reset();
201
+ });
202
+
203
+ afterAll(async () => {
204
+ await reset();
205
+ });
206
+
207
+ describe('CfRuntime — nick registry routes through sharded RegistryDO', () => {
208
+ it('reserveNick routes to the shard owning the nick and stores the owner', async () => {
209
+ const realEnv = envWithRealDOs();
210
+ const handlers = recordingHandlers();
211
+ const rt = new CfRuntime(realEnv, 'conn-local-1', handlers);
212
+
213
+ const result = await rt.reserveNick('alice', 'conn-local-1');
214
+ expect(result).toEqual({ ok: true });
215
+
216
+ // Cross-check via the real registry RPC.
217
+ expect(await registryForNick(realEnv, 'alice').lookupNick('alice')).toBe('conn-local-1');
218
+ });
219
+
220
+ it('two concurrent reservations for the same nick from different runtimes — one wins', async () => {
221
+ const realEnv = envWithRealDOs();
222
+ const rt1 = new CfRuntime(realEnv, 'conn-race-1', recordingHandlers());
223
+ const rt2 = new CfRuntime(realEnv, 'conn-race-2', recordingHandlers());
224
+
225
+ const [a, b] = await Promise.all([
226
+ rt1.reserveNick('race', 'conn-race-1'),
227
+ rt2.reserveNick('race', 'conn-race-2'),
228
+ ]);
229
+ expect([a, b].filter((r) => r.ok).length).toBe(1);
230
+ expect([a, b].filter((r) => !r.ok).length).toBe(1);
231
+ });
232
+
233
+ it('changeNick reserves the new nick and releases the old', async () => {
234
+ const realEnv = envWithRealDOs();
235
+ const rt = new CfRuntime(realEnv, 'conn-cn', recordingHandlers());
236
+ expect(await rt.reserveNick('oldnick', 'conn-cn')).toEqual({ ok: true });
237
+ expect(await rt.changeNick('conn-cn', 'oldnick', 'newnick')).toBe(true);
238
+
239
+ expect(await registryForNick(realEnv, 'oldnick').lookupNick('oldnick')).toBeNull();
240
+ expect(await registryForNick(realEnv, 'newnick').lookupNick('newnick')).toBe('conn-cn');
241
+ });
242
+
243
+ it('changeNick across two shards atomically reserves new then releases old', async () => {
244
+ const realEnv = envWithRealDOs();
245
+ const rt = new CfRuntime(realEnv, 'conn-cross-shard', recordingHandlers());
246
+ expect(await rt.reserveNick('shardA', 'conn-cross-shard')).toEqual({ ok: true });
247
+ expect(await rt.changeNick('conn-cross-shard', 'shardA', 'shardB')).toBe(true);
248
+ expect(await registryForNick(realEnv, 'shardA').lookupNick('shardA')).toBeNull();
249
+ expect(await registryForNick(realEnv, 'shardB').lookupNick('shardB')).toBe('conn-cross-shard');
250
+ });
251
+
252
+ it('releaseNick clears the mapping', async () => {
253
+ const realEnv = envWithRealDOs();
254
+ const rt = new CfRuntime(realEnv, 'conn-rel', recordingHandlers());
255
+ await rt.reserveNick('releaseme', 'conn-rel');
256
+ await rt.releaseNick('releaseme');
257
+ expect(await registryForNick(realEnv, 'releaseme').lookupNick('releaseme')).toBeNull();
258
+ });
259
+
260
+ it('lookupNick returns the owning conn id', async () => {
261
+ const realEnv = envWithRealDOs();
262
+ const rt = new CfRuntime(realEnv, 'conn-look', recordingHandlers());
263
+ await rt.reserveNick('lookme', 'conn-look');
264
+ expect(await rt.lookupNick('lookme')).toBe('conn-look');
265
+ expect(await rt.lookupNick('unknown-look')).toBeNull();
266
+ });
267
+ });
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Tests — channel routing (recording stub verifies call shape)
271
+ // ---------------------------------------------------------------------------
272
+
273
+ describe('CfRuntime — channel ops route through ChannelDO keyed by lowercase', () => {
274
+ it('broadcast forwards lines+except to the channel DO', async () => {
275
+ const stubEnv = envWithChannelStub();
276
+ const handlers = recordingHandlers();
277
+ const rt = new CfRuntime(stubEnv, 'conn-bcast', handlers);
278
+
279
+ await rt.broadcast('#Bcast', [{ text: ':alice!u@h PRIVMSG #Bcast :hi' }], 'conn-bcast');
280
+
281
+ const calls = await waitForChannelCall('#bcast', 'broadcast');
282
+ expect(calls.length).toBeGreaterThan(0);
283
+ const args = calls[0]?.args;
284
+ expect(args?.[0]).toEqual([':alice!u@h PRIVMSG #Bcast :hi']);
285
+ expect(args?.[1]).toBe('conn-bcast');
286
+ });
287
+
288
+ it('applyChannelDelta forwards the delta to the channel DO', async () => {
289
+ const stubEnv = envWithChannelStub();
290
+ const rt = new CfRuntime(stubEnv, 'conn-delta', recordingHandlers());
291
+ const delta = {
292
+ memberships: [{ type: 'add' as const, conn: 'conn-delta', nick: 'alice' }],
293
+ };
294
+ await rt.applyChannelDelta('#Delta', delta);
295
+
296
+ const calls = await waitForChannelCall('#delta', 'applyChannelDelta');
297
+ expect(calls.length).toBeGreaterThan(0);
298
+ expect(calls[0]?.args[0]).toEqual(delta);
299
+ });
300
+
301
+ it('getChannelSnapshot routes to the channel DO and returns its value', async () => {
302
+ const stubEnv = envWithChannelStub();
303
+ const rt = new CfRuntime(stubEnv, 'conn-snap', recordingHandlers());
304
+ // The RecordingChannelDO returns `null`; CfRuntime is a pass-through.
305
+ const snap = await rt.getChannelSnapshot('#snapchan');
306
+ expect(snap).toBeNull();
307
+ // Routing was made (recorded call).
308
+ const calls = await waitForChannelCall('#snapchan', 'getChannelSnapshot');
309
+ expect(calls.length).toBeGreaterThan(0);
310
+ });
311
+
312
+ it('broadcast with no except forwards undefined', async () => {
313
+ const stubEnv = envWithChannelStub();
314
+ const rt = new CfRuntime(stubEnv, 'conn-bcast-noex', recordingHandlers());
315
+ await rt.broadcast('#NoEx', [{ text: ':srv NOTICE #NoEx :all ears' }]);
316
+ const calls = await waitForChannelCall('#noex', 'broadcast');
317
+ expect(calls.length).toBeGreaterThan(0);
318
+ expect(calls[0]?.args[1]).toBeUndefined();
319
+ });
320
+
321
+ it('broadcast routes to a different DO instance per lowercased channel name', async () => {
322
+ const stubEnv = envWithChannelStub();
323
+ const rt = new CfRuntime(stubEnv, 'conn-isolation', recordingHandlers());
324
+ await rt.broadcast('#One', [{ text: ':srv NOTICE #One :first' }]);
325
+ await rt.broadcast('#Two', [{ text: ':srv NOTICE #Two :second' }]);
326
+ const one = await waitForChannelCall('#one', 'broadcast');
327
+ const two = await waitForChannelCall('#two', 'broadcast');
328
+ expect(one[0]?.args[0]).toEqual([':srv NOTICE #One :first']);
329
+ expect(two[0]?.args[0]).toEqual([':srv NOTICE #Two :second']);
330
+ });
331
+ });
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Tests — local connection shortcuts
335
+ // ---------------------------------------------------------------------------
336
+
337
+ describe('CfRuntime — local connection shortcut', () => {
338
+ it('send(to=this.connId, ...) uses the injected handlers, not RPC', async () => {
339
+ const stubEnv = envWithChannelStub();
340
+ const handlers = recordingHandlers();
341
+ const rt = new CfRuntime(stubEnv, 'conn-self', handlers);
342
+ await rt.send('conn-self', [{ text: ':server 001 alice :Welcome' }]);
343
+ expect(handlers.sent).toEqual([':server 001 alice :Welcome']);
344
+ });
345
+
346
+ it('disconnect(this.connId, ...) invokes the injected disconnect handler', async () => {
347
+ const stubEnv = envWithChannelStub();
348
+ const handlers = recordingHandlers();
349
+ const rt = new CfRuntime(stubEnv, 'conn-disc', handlers);
350
+ await rt.disconnect('conn-disc', 'test');
351
+ expect(handlers.disconnects).toEqual(['test']);
352
+ });
353
+
354
+ it('getConnectionInfo(this.connId) returns the snapshot from handlers', async () => {
355
+ const stubEnv = envWithChannelStub();
356
+ const state = createConnection({ id: 'conn-info', connectedSince: 0 });
357
+ state.nick = 'alice';
358
+ const handlers: CfConnectionHandlers = {
359
+ send: (): void => {},
360
+ disconnect: (): void => {},
361
+ snapshot: () => state,
362
+ };
363
+ const rt = new CfRuntime(stubEnv, 'conn-info', handlers);
364
+ const result = await rt.getConnectionInfo('conn-info');
365
+ expect(result?.nick).toBe('alice');
366
+ expect(result?.id).toBe('conn-info');
367
+ });
368
+
369
+ it('getConnectionInfo returns null when handlers.snapshot returns undefined', async () => {
370
+ const stubEnv = envWithChannelStub();
371
+ const rt = new CfRuntime(stubEnv, 'conn-null', recordingHandlers());
372
+ expect(await rt.getConnectionInfo('conn-null')).toBeNull();
373
+ });
374
+ });
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Tests — cross-connection RPC
378
+ // ---------------------------------------------------------------------------
379
+
380
+ describe('CfRuntime — cross-connection RPC via ConnectionDO', () => {
381
+ it('send(remoteConnId, ...) delivers via the remote ConnectionDO WebSocket', async () => {
382
+ const targetName = 'conn-x-send-target';
383
+ const ws = await openConnection(targetName);
384
+ const received = drainSocket(ws);
385
+
386
+ const targetHexId = await getConnectionHexId(targetName);
387
+
388
+ const realEnv = envWithRealDOs();
389
+ const rt = new CfRuntime(realEnv, 'conn-x-sender', recordingHandlers());
390
+ await rt.send(targetHexId, [{ text: ':server NOTICE target :hi from afar' }]);
391
+
392
+ await new Promise<void>((resolve, reject) => {
393
+ const deadline = Date.now() + 1500;
394
+ const tick = (): void => {
395
+ if (received.some((l) => l.includes('hi from afar'))) {
396
+ resolve();
397
+ return;
398
+ }
399
+ if (Date.now() > deadline) {
400
+ reject(new Error(`timed out; received=${JSON.stringify(received)}`));
401
+ return;
402
+ }
403
+ setTimeout(tick, 20);
404
+ };
405
+ tick();
406
+ });
407
+
408
+ ws.close();
409
+ });
410
+
411
+ it('getConnectionInfo(remoteConnId) returns the remote snapshot via RPC', async () => {
412
+ const targetName = 'conn-x-info-target';
413
+ const ws = await openConnection(targetName);
414
+ await new Promise<void>((r) => setTimeout(r, 20));
415
+ ws.send('NICK bob\r\n');
416
+ ws.send('USER bob 0 * :Bob\r\n');
417
+ await new Promise<void>((r) => setTimeout(r, 100));
418
+
419
+ const targetHexId = await getConnectionHexId(targetName);
420
+ const realEnv = envWithRealDOs();
421
+ const rt = new CfRuntime(realEnv, 'conn-x-info-caller', recordingHandlers());
422
+ const snap = await rt.getConnectionInfo(targetHexId);
423
+ expect(snap).not.toBeNull();
424
+ expect(snap?.nick).toBe('bob');
425
+
426
+ ws.close();
427
+ });
428
+
429
+ it('sendToNick resolves via the registry then forwards via deliver RPC', async () => {
430
+ const aliceName = 'conn-x-stn-alice';
431
+ const ws = await openConnection(aliceName);
432
+ const aliceReceived = drainSocket(ws);
433
+ const aliceHexId = await getConnectionHexId(aliceName);
434
+
435
+ // Manually register alice in the REAL registry so sendToNick can
436
+ // resolve the owner. (Driving the full NICK handshake through the
437
+ // actor would reserve in the recording stub instead.)
438
+ await reserveNickInRealRegistry('alice', aliceHexId);
439
+
440
+ const realEnv = envWithRealDOs();
441
+ const rt = new CfRuntime(realEnv, 'conn-x-stn-sender', recordingHandlers());
442
+ await rt.sendToNick('alice', 'conn-x-stn-sender', [{ text: ':bob!u@h PRIVMSG alice :hello' }]);
443
+
444
+ await new Promise<void>((resolve, reject) => {
445
+ const deadline = Date.now() + 1500;
446
+ const tick = (): void => {
447
+ if (aliceReceived.some((l) => l.includes('hello'))) {
448
+ resolve();
449
+ return;
450
+ }
451
+ if (Date.now() > deadline) {
452
+ reject(new Error(`timed out; received=${JSON.stringify(aliceReceived)}`));
453
+ return;
454
+ }
455
+ setTimeout(tick, 20);
456
+ };
457
+ tick();
458
+ });
459
+
460
+ ws.close();
461
+ });
462
+
463
+ it('sendToNick with notFoundLines delivers the fallback to the sender', async () => {
464
+ const realEnv = envWithRealDOs();
465
+ const handlers = recordingHandlers();
466
+ const sender = new CfRuntime(realEnv, 'conn-x-stn-sender2', handlers);
467
+ await sender.sendToNick(
468
+ 'ghost',
469
+ 'conn-x-stn-sender2',
470
+ [],
471
+ [{ text: ':server 401 sender ghost :No such nick' }],
472
+ );
473
+ expect(handlers.sent).toEqual([':server 401 sender ghost :No such nick']);
474
+ });
475
+
476
+ it('sendToNick without notFoundLines is a silent drop for unknown nicks', async () => {
477
+ const realEnv = envWithRealDOs();
478
+ const handlers = recordingHandlers();
479
+ const sender = new CfRuntime(realEnv, 'conn-x-stn-silent', handlers);
480
+ await sender.sendToNick('ghost', 'conn-x-stn-silent', [
481
+ { text: ':bob!u@h NOTICE ghost :psst' },
482
+ ]);
483
+ expect(handlers.sent).toEqual([]);
484
+ });
485
+ });
486
+
487
+ // ---------------------------------------------------------------------------
488
+ // Tests — implements IrcRuntime
489
+ // ---------------------------------------------------------------------------
490
+
491
+ describe('CfRuntime — IrcRuntime structural conformance', () => {
492
+ it('is a class that can be constructed with (env, connId, handlers)', () => {
493
+ const stubEnv = envWithChannelStub();
494
+ const rt = new CfRuntime(stubEnv, 'conn-shape', recordingHandlers());
495
+ expect(typeof rt.send).toBe('function');
496
+ expect(typeof rt.broadcast).toBe('function');
497
+ expect(typeof rt.disconnect).toBe('function');
498
+ expect(typeof rt.sendToNick).toBe('function');
499
+ expect(typeof rt.reserveNick).toBe('function');
500
+ expect(typeof rt.changeNick).toBe('function');
501
+ expect(typeof rt.releaseNick).toBe('function');
502
+ expect(typeof rt.applyChannelDelta).toBe('function');
503
+ expect(typeof rt.lookupNick).toBe('function');
504
+ expect(typeof rt.getConnectionInfo).toBe('function');
505
+ expect(typeof rt.getChannelSnapshot).toBe('function');
506
+ });
507
+
508
+ it('getChannelSnapshot return type is assignable to ChanSnapshot | null', async () => {
509
+ const stubEnv = envWithChannelStub();
510
+ const rt = new CfRuntime(stubEnv, 'conn-type', recordingHandlers());
511
+ const snap: ChanSnapshot | null = await rt.getChannelSnapshot('#typecheck');
512
+ // The recording stub returns null; CfRuntime passes it through.
513
+ expect(snap).toBeNull();
514
+ });
515
+
516
+ it('makeCfRuntime factory constructs an equivalent runtime', async () => {
517
+ const stubEnv = envWithChannelStub();
518
+ const { makeCfRuntime } = await import('../src/cf-runtime');
519
+ const handlers = recordingHandlers();
520
+ const rt = makeCfRuntime(stubEnv, 'conn-factory', 'ignored-stub-key', handlers);
521
+ // Same shape as a direct CfRuntime construction.
522
+ expect(typeof rt.send).toBe('function');
523
+ expect(typeof rt.sendToNick).toBe('function');
524
+ await rt.send('conn-factory', [{ text: ':server 001 alice :Welcome' }]);
525
+ expect(handlers.sent).toEqual([':server 001 alice :Welcome']);
526
+ });
527
+ });