serverless-ircd 0.8.0 → 0.10.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 (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -0,0 +1,460 @@
1
+ /**
2
+ * Coverage-focused tests for {@link ConnectionDO}.
3
+ *
4
+ * Each test pins a residual branch that the main `connection-do.test.ts`
5
+ * suite does not naturally drive:
6
+ *
7
+ * - non-WebSocket `fetch` (426 response)
8
+ * - mTLS cert-subject capture + actor wiring
9
+ * - `webSocketError` event handler
10
+ * - `deliver` over a non-OPEN socket (lazy prune signal)
11
+ * - `sendOutbound` no-op on a non-OPEN socket
12
+ * - `spec-binary` outbound path (UTF-8 binary frames)
13
+ * - `__triggerClose` with a live socket (the `tearDown(ws)` branch)
14
+ * - `disconnect()` RPC on a DO with no socket (the `tearDownNoSocket`
15
+ * branch)
16
+ *
17
+ * Tests run inside real workerd via miniflare so DO hibernation, storage,
18
+ * and RPC behave as in production. Each test gets isolated storage.
19
+ */
20
+
21
+ import { env, runInDurableObject } from 'cloudflare:test';
22
+ import { describe, expect, it } from 'vitest';
23
+
24
+ declare global {
25
+ namespace Cloudflare {
26
+ interface Env {
27
+ CONNECTION_DO: DurableObjectNamespace;
28
+ }
29
+ }
30
+ }
31
+
32
+ type WsMode = 'legacy' | 'spec-text' | 'spec-binary';
33
+
34
+ /**
35
+ * Opens a WebSocket to the named ConnectionDO, accepts it, and returns a
36
+ * non-null `WebSocket`. Centralizes the upgrade + null-guard so type
37
+ * narrowing is preserved across awaits in the call sites below.
38
+ */
39
+ async function openWs(connId: string, protocols?: string): Promise<WebSocket> {
40
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(connId));
41
+ const headers: Record<string, string> = { Upgrade: 'websocket' };
42
+ if (protocols !== undefined) {
43
+ headers['Sec-WebSocket-Protocol'] = protocols;
44
+ }
45
+ const response = await stub.fetch('https://do/upgrade', { headers });
46
+ const ws = response.webSocket;
47
+ if (ws === undefined || ws === null) {
48
+ throw new Error('upgrade did not return a WebSocket');
49
+ }
50
+ ws.accept();
51
+ return ws;
52
+ }
53
+
54
+ /** Minimal fake WebSocket whose readyState and sent bytes are controlled. */
55
+ class FakeWs {
56
+ constructor(readonly readyState: number) {}
57
+ readonly sent: unknown[] = [];
58
+ closed = false;
59
+ send(data: unknown): void {
60
+ this.sent.push(data);
61
+ }
62
+ close(): void {
63
+ this.closed = true;
64
+ }
65
+ }
66
+
67
+ describe('ConnectionDO — fetch rejects non-WebSocket upgrades with 426', () => {
68
+ it('returns 426 Expected WebSocket when no Upgrade header is present', async () => {
69
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-426'));
70
+ const response = await stub.fetch('https://do/upgrade', {
71
+ headers: { 'Content-Type': 'text/plain' },
72
+ });
73
+ expect(response.status).toBe(426);
74
+ expect(await response.text()).toBe('Expected WebSocket');
75
+ });
76
+ });
77
+
78
+ describe('ConnectionDO — mTLS cert subject capture at upgrade', () => {
79
+ it('captures cf.tlsClientAuthCertSubject when present on the request', async () => {
80
+ // The workerd test runtime strips request.cf across the DO RPC
81
+ // boundary, so the inline fetch path can't be exercised directly.
82
+ // We drive the same captureMtlsSubject logic via the test seam.
83
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-mtls-fetch'));
84
+ const fake = { cf: { tlsClientAuthCertSubject: 'CN=mtls-fetch-alice' } } as unknown as Request;
85
+ await runInDurableObject(stub, async (instance: unknown) => {
86
+ const doi = instance as { __captureMtlsSubjectForTest?: (request: Request) => void };
87
+ doi.__captureMtlsSubjectForTest?.(fake);
88
+ });
89
+ const captured = await runInDurableObject(stub, async (instance: unknown) => {
90
+ const doi = instance as { __peekMtlsCertSubject?: () => Promise<string | undefined> };
91
+ return doi.__peekMtlsCertSubject?.();
92
+ });
93
+ expect(captured).toBe('CN=mtls-fetch-alice');
94
+ });
95
+ });
96
+
97
+ describe('ConnectionDO — mTLS cert subject seeds InMemoryMtlsIdentityProvider in buildActor', () => {
98
+ it('constructs the actor with mtlsIdentity when __setMtlsCertSubject is set', async () => {
99
+ // The buildActor path builds an InMemoryMtlsIdentityProvider when
100
+ // mtlsCertSubject is non-undefined. Production captures the subject
101
+ // at upgrade time via request.cf; the workerd test runtime does not
102
+ // surface cf, so we set it via the __setMtlsCertSubject test hook
103
+ // and then drive a frame so buildActor runs.
104
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-mtls-build'));
105
+ const ws = await openWs('conn-mtls-build');
106
+ await runInDurableObject(stub, async (instance: unknown) => {
107
+ const doi = instance as { __setMtlsCertSubject?: (s: string | undefined) => void };
108
+ doi.__setMtlsCertSubject?.('CN=mtls-alice');
109
+ });
110
+ // Drive a frame so buildActor runs with mtlsCertSubject set. The
111
+ // registration handshake completes; the actor's mtlsIdentity arm
112
+ // has now executed even though no AUTHENTICATE EXTERNAL follows.
113
+ ws.send('NICK mtls-build\r\n');
114
+ ws.send('USER mtls-build 0 * :mtls-build\r\n');
115
+ await new Promise<void>((r) => setTimeout(r, 80));
116
+ ws.close();
117
+ });
118
+ });
119
+
120
+ describe('ConnectionDO — webSocketError runs the standard teardown', () => {
121
+ it('invokes tearDown when webSocketError fires and the suppression hook is unset', async () => {
122
+ // The webSocketError handler mirrors webSocketClose: same tearDown
123
+ // path unless suppressed. The platform fires it asynchronously on
124
+ // transport errors; the __triggerWsError test hook calls it
125
+ // deterministically against the DO's own attached socket.
126
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-wserror'));
127
+ const ws = await openWs('conn-wserror');
128
+ ws.send('NICK wserr-alice\r\n');
129
+ ws.send('USER wserr-alice 0 * :wserr-alice\r\n');
130
+ await new Promise<void>((r) => setTimeout(r, 50));
131
+
132
+ await runInDurableObject(stub, async (instance: unknown) => {
133
+ const doi = instance as { __triggerWsError?: (error: unknown) => Promise<void> };
134
+ await doi.__triggerWsError?.(new Error('synthetic'));
135
+ });
136
+ ws.close();
137
+ });
138
+
139
+ it('webSocketError is a no-op when __suppressCloseTeardown is set', async () => {
140
+ // The same suppression hook used by webSocketClose also gates
141
+ // webSocketError so a lost-error condition is owned by the alarm.
142
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-wserror-suppressed'));
143
+ const ws = await openWs('conn-wserror-suppressed');
144
+ await runInDurableObject(stub, async (instance: unknown) => {
145
+ const doi = instance as {
146
+ __suppressCloseTeardownHook?: () => void;
147
+ __triggerWsError?: (error: unknown) => Promise<void>;
148
+ };
149
+ doi.__suppressCloseTeardownHook?.();
150
+ // Must not reject and must not run tearDown (no nick release side
151
+ // effects). The test asserts only that the handler returns
152
+ // cleanly — the coverage tracks that the suppression arm fired.
153
+ await doi.__triggerWsError?.(new Error('synthetic'));
154
+ });
155
+ ws.close();
156
+ });
157
+ });
158
+
159
+ describe('ConnectionDO — deliver skips non-OPEN sockets', () => {
160
+ it('returns delivered:0 when the socket is not in OPEN state', async () => {
161
+ // After the client closes, the DO's `ctx.getWebSockets()` may still
162
+ // return the socket transiently; its readyState is CLOSING/CLOSED.
163
+ // The readyState guard in `tryDeliver` skips it, returning false.
164
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-deliver-closed'));
165
+ const ws = await openWs('conn-deliver-closed');
166
+ ws.close();
167
+ // Give the runtime a tick to propagate the close.
168
+ await new Promise<void>((r) => setTimeout(r, 50));
169
+ const deliverResult = await runInDurableObject(stub, async (instance: unknown) => {
170
+ const doi = instance as {
171
+ deliver?: (lines: Array<{ text: string }>) => Promise<{ delivered: number }>;
172
+ };
173
+ return doi.deliver?.([{ text: ':srv NOTICE * :probe' }]);
174
+ });
175
+ expect(deliverResult).toEqual({ delivered: 0 });
176
+ });
177
+ });
178
+
179
+ describe('ConnectionDO — IRCv3 binary.ircv3.net outbound path', () => {
180
+ it('delivers each outbound IRC line as a UTF-8 binary WS message', async () => {
181
+ // spec-binary mode uses TextEncoder + ws.send(Uint8Array) instead
182
+ // of the spec-text path's string send. Opening a binary.ircv3.net
183
+ // connection and completing registration causes the DO to emit
184
+ // several numerics; each is delivered as a binary frame.
185
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-spec-binary-out'));
186
+ const response = await stub.fetch('https://do/upgrade', {
187
+ headers: { Upgrade: 'websocket', 'Sec-WebSocket-Protocol': 'binary.ircv3.net' },
188
+ });
189
+ expect(response.headers.get('Sec-WebSocket-Protocol')).toBe('binary.ircv3.net');
190
+ const ws = response.webSocket;
191
+ if (ws === undefined || ws === null) {
192
+ throw new Error('upgrade did not return a WebSocket');
193
+ }
194
+ ws.accept();
195
+ const received: ArrayBuffer[] = [];
196
+ ws.addEventListener('message', (ev: MessageEvent) => {
197
+ const d = ev.data;
198
+ if (d instanceof ArrayBuffer) received.push(d);
199
+ });
200
+ await new Promise<void>((r) => setTimeout(r, 10));
201
+ ws.send('NICK bin-alice\r\n');
202
+ ws.send('USER bin-alice 0 * :bin-alice\r\n');
203
+ // Wait for the 001 welcome (delivered as a binary frame).
204
+ const start = Date.now();
205
+ while (Date.now() - start < 1500) {
206
+ const lines = received.map((b) => new TextDecoder().decode(b));
207
+ if (lines.some((l) => l.includes(' 001 '))) break;
208
+ await new Promise<void>((r) => setTimeout(r, 30));
209
+ }
210
+ expect(received.length).toBeGreaterThan(0);
211
+ const decoded = received.map((b) => new TextDecoder().decode(b));
212
+ expect(decoded.some((l) => l.includes(' 001 '))).toBe(true);
213
+ ws.close();
214
+ });
215
+ });
216
+
217
+ describe('ConnectionDO — __triggerClose with a live socket', () => {
218
+ it('runs tearDown(ws) when a WebSocket is still attached', async () => {
219
+ // The existing connection-do suite exercises __triggerClose only
220
+ // after client.close(), when the socket has already dropped. This
221
+ // case drives the else arm: __triggerClose with a still-live socket
222
+ // invokes tearDown(ws) directly.
223
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-trigger-live'));
224
+ const ws = await openWs('conn-trigger-live');
225
+ ws.send('NICK trigger-alice\r\n');
226
+ ws.send('USER trigger-alice 0 * :trigger-alice\r\n');
227
+ await new Promise<void>((r) => setTimeout(r, 50));
228
+
229
+ await runInDurableObject(stub, async (instance: unknown) => {
230
+ const doi = instance as { __triggerClose?: () => Promise<void> };
231
+ await doi.__triggerClose?.();
232
+ });
233
+
234
+ // The tearDown path closes the server-side WS; poll readyState
235
+ // rather than relying on the close event reaching the client.
236
+ const start = Date.now();
237
+ while (Date.now() - start < 3000) {
238
+ if (ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) break;
239
+ await new Promise<void>((r) => setTimeout(r, 50));
240
+ }
241
+ expect([WebSocket.CLOSING, WebSocket.CLOSED]).toContain(ws.readyState);
242
+ });
243
+ });
244
+
245
+ describe('ConnectionDO — disconnect() RPC with no live socket', () => {
246
+ it('runs tearDownNoSocket when the DO has no attached WebSocket', async () => {
247
+ // ConnectionDO.disconnect is the cross-connection RPC entry point
248
+ // (KICK, operator-driven kill). When the target socket has already
249
+ // dropped (lost close event in production), the no-socket teardown
250
+ // path still releases the nick and fans the QUIT out.
251
+ //
252
+ // We seed a tiny bit of state first (so loadState returns a real
253
+ // record rather than a fresh default), then invoke disconnect with
254
+ // no live socket.
255
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-disc-no-socket'));
256
+ // Open + close a socket quickly so the DO persists a state record.
257
+ const ws = await openWs('conn-disc-no-socket');
258
+ ws.send('NICK disc-alice\r\n');
259
+ ws.send('USER disc-alice 0 * :disc-alice\r\n');
260
+ await new Promise<void>((r) => setTimeout(r, 50));
261
+ ws.close();
262
+ // Wait for the close to propagate so getWebSockets() returns empty.
263
+ await new Promise<void>((r) => setTimeout(r, 100));
264
+
265
+ // Invoke disconnect via RPC; with no live socket the no-socket
266
+ // path runs.
267
+ const rpc = stub as unknown as { disconnect(reason?: string): Promise<void> };
268
+ await expect(rpc.disconnect('Kicked')).resolves.toBeUndefined();
269
+ });
270
+
271
+ it('tearDownNoSocket initializes channelAccess when no prior frame ran', async () => {
272
+ // The `if (this.channelAccess === undefined)` arm in
273
+ // tearDownNoSocket: a never-messaged DO has not run buildActor yet,
274
+ // so channelAccess is undefined and the no-socket teardown seeds
275
+ // it before constructing the actor.
276
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-disc-fresh-do'));
277
+ const rpc = stub as unknown as { disconnect(reason?: string): Promise<void> };
278
+ await expect(rpc.disconnect('Kicked')).resolves.toBeUndefined();
279
+ });
280
+ });
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Direct seam tests — exercised with a fake non-OPEN WebSocket so the
284
+ // readyState guards (unreachable via real RPC) are covered.
285
+ // ---------------------------------------------------------------------------
286
+
287
+ describe('ConnectionDO — readyState guards (direct seam)', () => {
288
+ it('deliverToSockets counts only OPEN sockets (mixed list)', async () => {
289
+ // Drives the false arm of the tryDeliver ternary inside the loop:
290
+ // a fake CLOSING socket returns false and the count skips it.
291
+ // The real `ctx.getWebSockets()` only returns OPEN sockets in the
292
+ // test runtime, so this seam is the only way to exercise the
293
+ // mixed case. Both fakes here are non-OPEN so the loop body
294
+ // avoids wsFrameMode (which requires a real hibernated WS).
295
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-deliver-mixed'));
296
+ const ws = await openWs('conn-deliver-mixed');
297
+
298
+ const result = await runInDurableObject(stub, async (instance: unknown) => {
299
+ const doi = instance as {
300
+ deliverToSockets?: (
301
+ sockets: WebSocket[],
302
+ lines: Array<{ text: string }>,
303
+ ) => { delivered: number };
304
+ };
305
+ const a = new FakeWs(WebSocket.CLOSING) as unknown as WebSocket;
306
+ const b = new FakeWs(WebSocket.CLOSED) as unknown as WebSocket;
307
+ return doi.deliverToSockets?.([a, b], [{ text: 'x' }]);
308
+ });
309
+ expect(result).toEqual({ delivered: 0 });
310
+ ws.close();
311
+ });
312
+
313
+ it('tryDeliver returns false and does not write when the socket is not OPEN', async () => {
314
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-trydeliver-closed'));
315
+ const ws = await openWs('conn-trydeliver-closed');
316
+
317
+ const result = await runInDurableObject(stub, async (instance: unknown) => {
318
+ const doi = instance as {
319
+ tryDeliver?: (ws: WebSocket, lines: Array<{ text: string }>) => boolean;
320
+ };
321
+ const fake = new FakeWs(WebSocket.CLOSING) as unknown as WebSocket;
322
+ return doi.tryDeliver?.(fake, [{ text: ':srv NOTICE * :probe' }]);
323
+ });
324
+ expect(result).toBe(false);
325
+ ws.close();
326
+ });
327
+
328
+ it('tryDeliver writes and returns true when the socket is OPEN', async () => {
329
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-trydeliver-open'));
330
+ const ws = await openWs('conn-trydeliver-open');
331
+
332
+ // The real OPEN WS goes through tryDeliver via the deliver RPC.
333
+ // We exercise the OPEN arm directly via the seam: pass the live WS
334
+ // back into the DO context through runInDurableObject isn't possible
335
+ // (different DO context). Instead, run deliver() against the live
336
+ // socket — that already exercises the OPEN arm of tryDeliver.
337
+ const deliver = stub as unknown as {
338
+ deliver(lines: Array<{ text: string }>): Promise<{ delivered: number }>;
339
+ };
340
+ const deliverResult = await deliver.deliver([{ text: ':srv NOTICE conn-trydeliver-open :hi' }]);
341
+ expect(deliverResult.delivered).toBe(1);
342
+ ws.close();
343
+ });
344
+
345
+ it('sendOutbound early-returns without writing when the socket is not OPEN', async () => {
346
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-sendoutbound-closed'));
347
+ const ws = await openWs('conn-sendoutbound-closed');
348
+
349
+ const fake = new FakeWs(WebSocket.CLOSED);
350
+ await runInDurableObject(stub, async (instance: unknown) => {
351
+ const doi = instance as {
352
+ __sendOutboundForTest?: (
353
+ ws: WebSocket,
354
+ lines: Array<{ text: string }>,
355
+ mode: WsMode,
356
+ ) => void;
357
+ };
358
+ doi.__sendOutboundForTest?.(
359
+ fake as unknown as WebSocket,
360
+ [{ text: ':srv NOTICE * :probe' }],
361
+ 'legacy',
362
+ );
363
+ });
364
+ // Nothing was written (the guard returned before ws.send).
365
+ expect(fake.sent.length).toBe(0);
366
+ ws.close();
367
+ });
368
+
369
+ it('writeDisconnect with a reason writes the ERROR Closing link line', async () => {
370
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-writedisc-reason'));
371
+ const ws = await openWs('conn-writedisc-reason');
372
+
373
+ // Use a fake OPEN socket so sendOutbound writes through; the
374
+ // __writeDisconnectForTest seam then calls ws.close on the fake.
375
+ const fake = new FakeWs(WebSocket.OPEN);
376
+ await runInDurableObject(stub, async (instance: unknown) => {
377
+ const doi = instance as {
378
+ __writeDisconnectForTest?: (ws: WebSocket, mode: WsMode, reason?: string) => void;
379
+ };
380
+ doi.__writeDisconnectForTest?.(fake as unknown as WebSocket, 'legacy', 'Kicked');
381
+ });
382
+ expect(fake.sent.length).toBe(1);
383
+ expect(String(fake.sent[0])).toContain('ERROR :Closing link: (Kicked)');
384
+ expect(fake.closed).toBe(true);
385
+ ws.close();
386
+ });
387
+
388
+ it('writeDisconnect without a reason skips the ERROR line and still closes', async () => {
389
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-writedisc-noreason'));
390
+ const ws = await openWs('conn-writedisc-noreason');
391
+
392
+ const fake = new FakeWs(WebSocket.OPEN);
393
+ await runInDurableObject(stub, async (instance: unknown) => {
394
+ const doi = instance as {
395
+ __writeDisconnectForTest?: (ws: WebSocket, mode: WsMode, reason?: string) => void;
396
+ };
397
+ doi.__writeDisconnectForTest?.(fake as unknown as WebSocket, 'legacy');
398
+ });
399
+ expect(fake.sent.length).toBe(0);
400
+ expect(fake.closed).toBe(true);
401
+ ws.close();
402
+ });
403
+
404
+ it('closeIfStillOpen closes an OPEN socket', async () => {
405
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-closeifopen-open'));
406
+ const ws = await openWs('conn-closeifopen-open');
407
+
408
+ const fake = new FakeWs(WebSocket.OPEN);
409
+ await runInDurableObject(stub, async (instance: unknown) => {
410
+ const doi = instance as { __closeIfStillOpenForTest?: (ws: WebSocket) => void };
411
+ doi.__closeIfStillOpenForTest?.(fake as unknown as WebSocket);
412
+ });
413
+ expect(fake.closed).toBe(true);
414
+ ws.close();
415
+ });
416
+
417
+ it('closeIfStillOpen is a no-op on a CLOSED socket', async () => {
418
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-closeifopen-closed'));
419
+ const ws = await openWs('conn-closeifopen-closed');
420
+
421
+ const fake = new FakeWs(WebSocket.CLOSED);
422
+ await runInDurableObject(stub, async (instance: unknown) => {
423
+ const doi = instance as { __closeIfStillOpenForTest?: (ws: WebSocket) => void };
424
+ doi.__closeIfStillOpenForTest?.(fake as unknown as WebSocket);
425
+ });
426
+ expect(fake.closed).toBe(false);
427
+ ws.close();
428
+ });
429
+ });
430
+
431
+ describe('ConnectionDO — __triggerWsError with no socket', () => {
432
+ it('is a no-op when no WebSocket is attached', async () => {
433
+ // The "no socket attached" branch of __triggerWsError: a never-opened
434
+ // DO has no live WebSocket, so the hook returns without invoking
435
+ // webSocketError. Covers the `if (ws === undefined) return;` guard.
436
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-wserror-nosocket'));
437
+ await expect(
438
+ runInDurableObject(stub, async (instance: unknown) => {
439
+ const doi = instance as { __triggerWsError?: (error: unknown) => Promise<void> };
440
+ await doi.__triggerWsError?.(new Error('nothing to close'));
441
+ }),
442
+ ).resolves.toBeUndefined();
443
+ });
444
+ });
445
+
446
+ describe('ConnectionDO — flushServices no-op when services are unbound', () => {
447
+ it('does not reject when services is undefined (e.g. binding absent)', async () => {
448
+ // flushServices guards `if (this.services === undefined) return;`.
449
+ // The D1 binding is present in the test env, so the loader resolves
450
+ // a store on the first frame; to exercise the undefined arm we
451
+ // invoke flushServices on a never-touched DO before any frame runs.
452
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-flush-empty'));
453
+ await expect(
454
+ runInDurableObject(stub, async (instance: unknown) => {
455
+ const doi = instance as { __flushServicesForTest?: () => Promise<void> };
456
+ await doi.__flushServicesForTest?.();
457
+ }),
458
+ ).resolves.toBeUndefined();
459
+ });
460
+ });