serverless-ircd 0.9.0 → 0.11.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 (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -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 +398 -67
  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 +714 -105
  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/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `ConnectionDO` × `CounterDO` — admission-slot teardown wiring.
3
+ *
4
+ * TDD outline:
5
+ * "Red: the counter is not decremented on close; assert it is. Green:
6
+ * decrement on close + error. Red: a crashed DO leaves the counter
7
+ * sticky; assert the reconcile sweep reduces it (covered in
8
+ * counter-do.test.ts — here we assert the live-side of that
9
+ * contract: the PING alarm heartbeats the entry so the sweep spares
10
+ * healthy connections)."
11
+ *
12
+ * The worker edge registers each upgrade in the global counter keyed by
13
+ * the ConnectionDO's hex id. These tests pre-register that key directly
14
+ * (simulating the worker), drive every ConnectionDO teardown path, and
15
+ * assert the slot is released.
16
+ */
17
+
18
+ import { env, reset, runDurableObjectAlarm, runInDurableObject } from 'cloudflare:test';
19
+ import { afterEach, describe, expect, it } from 'vitest';
20
+ import { COUNTER_INSTANCE_NAME, DEFAULT_CONNECTION_TTL_MS } from '../src/counter-do';
21
+ import type { CounterRpc } from '../src/env';
22
+
23
+ declare global {
24
+ namespace Cloudflare {
25
+ interface Env {
26
+ CONNECTION_DO: DurableObjectNamespace;
27
+ COUNTER_DO: DurableObjectNamespace;
28
+ }
29
+ }
30
+ }
31
+
32
+ afterEach(async () => {
33
+ await reset();
34
+ });
35
+
36
+ /** RPC handle on the single global counter instance. */
37
+ function counter(): CounterRpc {
38
+ const stub = env.COUNTER_DO.get(env.COUNTER_DO.idFromName(COUNTER_INSTANCE_NAME));
39
+ return stub as unknown as CounterRpc;
40
+ }
41
+
42
+ /** Stub for the ConnectionDO instance named `name`. */
43
+ function connStub(name: string): DurableObjectStub {
44
+ return env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(name));
45
+ }
46
+
47
+ /**
48
+ * The canonical conn id used by the admission counter: the ConnectionDO's
49
+ * hex id. This is exactly what the worker registers
50
+ * (`idFromName(uuid).toString()`) and what the DO computes from `ctx.id`,
51
+ * so both sides agree on the key without passing it explicitly.
52
+ */
53
+ function hexId(name: string): string {
54
+ return env.CONNECTION_DO.idFromName(name).toString();
55
+ }
56
+
57
+ /** Opens a real WebSocket to the ConnectionDO named `name`. */
58
+ async function openSocket(name: string): Promise<WebSocket> {
59
+ const response = await connStub(name).fetch('https://do/upgrade', {
60
+ headers: { Upgrade: 'websocket' },
61
+ });
62
+ const ws = response.webSocket;
63
+ if (ws === undefined || ws === null) {
64
+ throw new Error('ConnectionDO.fetch did not return a WebSocket');
65
+ }
66
+ ws.accept();
67
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
68
+ return ws as WebSocket;
69
+ }
70
+
71
+ /** Simulates the worker edge: registers the connection's slot. */
72
+ async function registerSlot(name: string, now?: number): Promise<void> {
73
+ const result = await counter().admit(hexId(name), 10_000, now);
74
+ if (!result.admitted) throw new Error('test setup admit unexpectedly rejected');
75
+ }
76
+
77
+ describe('ConnectionDO — admission-slot release on teardown', () => {
78
+ it('a clean close releases the slot (webSocketClose → tearDown)', async () => {
79
+ await registerSlot('slot-close-1');
80
+ const ws = await openSocket('slot-close-1');
81
+ expect(await counter().liveCount()).toBe(1);
82
+
83
+ // Deterministic close-teardown (the platform's close event lags in
84
+ // the test runtime; __triggerClose runs the same code path).
85
+ await runInDurableObject(connStub('slot-close-1'), async (instance: unknown) => {
86
+ const doi = instance as { __triggerClose?: () => Promise<void> };
87
+ await doi.__triggerClose?.();
88
+ });
89
+
90
+ expect(await counter().liveCount()).toBe(0);
91
+ ws.close();
92
+ });
93
+
94
+ it('a transport error releases the slot (webSocketError → tearDown)', async () => {
95
+ await registerSlot('slot-error-1');
96
+ const ws = await openSocket('slot-error-1');
97
+ expect(await counter().liveCount()).toBe(1);
98
+
99
+ await runInDurableObject(connStub('slot-error-1'), async (instance: unknown) => {
100
+ const doi = instance as { __triggerWsError?: (error: unknown) => Promise<void> };
101
+ await doi.__triggerWsError?.(new Error('boom'));
102
+ });
103
+
104
+ expect(await counter().liveCount()).toBe(0);
105
+ ws.close();
106
+ });
107
+
108
+ it('a socketless teardown releases the slot (disconnect RPC → tearDownNoSocket)', async () => {
109
+ await registerSlot('slot-nosock-1');
110
+ expect(await counter().liveCount()).toBe(1);
111
+
112
+ await (
113
+ connStub('slot-nosock-1') as unknown as { disconnect: () => Promise<void> }
114
+ ).disconnect();
115
+
116
+ expect(await counter().liveCount()).toBe(0);
117
+ });
118
+
119
+ it('the PING alarm heartbeats the slot so the TTL reconcile spares live connections', async () => {
120
+ // Register with a timestamp that is ALREADY stale — only the alarm's
121
+ // heartbeat can rescue the entry from the next sweep. This is the
122
+ // live-side complement of the crashed-DO reap test: a ConnectionDO
123
+ // that keeps running must keep its slot alive.
124
+ await registerSlot('slot-beat-1', Date.now() - DEFAULT_CONNECTION_TTL_MS - 60_000);
125
+ const ws = await openSocket('slot-beat-1');
126
+
127
+ expect(await runDurableObjectAlarm(connStub('slot-beat-1'))).toBe(true);
128
+
129
+ const swept = await counter().sweep();
130
+ expect(swept.reaped).toBe(0);
131
+ expect(swept.live).toBe(1);
132
+ ws.close();
133
+ });
134
+
135
+ it('an idle-timeout alarm teardown releases the slot (alarm → tearDown)', async () => {
136
+ await registerSlot('slot-idle-1');
137
+ const ws = await openSocket('slot-idle-1');
138
+ expect(await counter().liveCount()).toBe(1);
139
+
140
+ // Backdate lastSeen past the PONG timeout so the alarm takes the
141
+ // idle-teardown branch.
142
+ await runInDurableObject(connStub('slot-idle-1'), async (instance: unknown) => {
143
+ const doi = instance as { __backdateLastSeen?: (ms: number) => Promise<void> };
144
+ await doi.__backdateLastSeen?.(48 * 60 * 60 * 1000);
145
+ });
146
+ expect(await runDurableObjectAlarm(connStub('slot-idle-1'))).toBe(true);
147
+
148
+ expect(await counter().liveCount()).toBe(0);
149
+ ws.close();
150
+ });
151
+
152
+ it('teardown of a never-registered connection is a harmless no-op', async () => {
153
+ // Direct-to-DO paths (integration harness, RPC callers) never
154
+ // registered a slot; teardown must not throw and the counter must
155
+ // stay untouched.
156
+ const ws = await openSocket('slot-unreg-1');
157
+ await runInDurableObject(connStub('slot-unreg-1'), async (instance: unknown) => {
158
+ const doi = instance as { __triggerClose?: () => Promise<void> };
159
+ await doi.__triggerClose?.();
160
+ });
161
+
162
+ expect(await counter().liveCount()).toBe(0);
163
+ ws.close();
164
+ });
165
+ });
@@ -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
+ });