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.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- 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
|
+
});
|