serverless-ircd 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -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 +177 -52
- 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 +267 -92
- 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/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- 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 +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- 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 +20 -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 +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- 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/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -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 +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- 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 +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- 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/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -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,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
|
+
});
|