serverless-ircd 0.1.0 → 0.3.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 +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/CHANGELOG.md +436 -0
- package/README.md +127 -84
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +5 -1
- package/apps/cf-worker/wrangler.toml +66 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +57 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +267 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +126 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +93 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1186 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +11 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +19 -14
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +871 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
- package/packages/aws-adapter/src/config-loader.ts +126 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +330 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
- package/packages/aws-adapter/src/handlers/index.ts +293 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +74 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
- package/packages/aws-adapter/tests/account-store.test.ts +280 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +473 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/cf-runtime.ts +42 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +135 -0
- package/packages/cf-adapter/src/connection-do.ts +119 -0
- package/packages/cf-adapter/src/env.ts +27 -1
- package/packages/cf-adapter/src/index.ts +2 -1
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
- package/packages/cf-adapter/tests/worker/main.ts +2 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +10 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
- package/packages/irc-core/package.json +13 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +63 -10
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +79 -21
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +270 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +7 -0
- package/packages/irc-core/src/ports.ts +719 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +88 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- package/packages/irc-core/tests/commands/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +380 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/config.test.ts +721 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +186 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +160 -0
- package/packages/irc-server/tests/actor.test.ts +690 -15
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +204 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
createChannel,
|
|
47
47
|
toSnapshot,
|
|
48
48
|
} from '@serverless-ircd/irc-core';
|
|
49
|
-
import type { Env } from './env.js';
|
|
49
|
+
import type { ChannelRegistryRpc, Env } from './env.js';
|
|
50
50
|
|
|
51
51
|
/** Storage key under which the per-channel state is persisted. */
|
|
52
52
|
const STATE_STORAGE_KEY = 'channel-state';
|
|
@@ -129,8 +129,10 @@ export class ChannelDO extends DurableObject<Env> {
|
|
|
129
129
|
*/
|
|
130
130
|
async applyChannelDelta(delta: ChannelDelta): Promise<void> {
|
|
131
131
|
const state = await this.loadState();
|
|
132
|
+
const wasEmpty = state.members.size === 0;
|
|
132
133
|
const next = applyChannelDelta(state, delta);
|
|
133
134
|
await this.persistState(next);
|
|
135
|
+
await this.syncRegistry(wasEmpty, next);
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
// -------------------------------------------------------------------------
|
|
@@ -160,10 +162,12 @@ export class ChannelDO extends DurableObject<Env> {
|
|
|
160
162
|
const payload = lines.map((text) => ({ text }));
|
|
161
163
|
const pruned = await this.fanoutAndCollect(state, payload, except);
|
|
162
164
|
if (pruned.length > 0) {
|
|
165
|
+
const wasEmpty = false;
|
|
163
166
|
const next = applyChannelDelta(state, {
|
|
164
167
|
memberships: pruned.map((conn) => ({ type: 'remove' as const, conn })),
|
|
165
168
|
});
|
|
166
169
|
await this.persistState(next);
|
|
170
|
+
await this.syncRegistry(wasEmpty, next);
|
|
167
171
|
}
|
|
168
172
|
}
|
|
169
173
|
|
|
@@ -182,10 +186,12 @@ export class ChannelDO extends DurableObject<Env> {
|
|
|
182
186
|
// OPEN-socket count without sending bytes).
|
|
183
187
|
const pruned = await this.fanoutAndCollect(state, [], undefined);
|
|
184
188
|
if (pruned.length > 0) {
|
|
189
|
+
const wasEmpty = false;
|
|
185
190
|
const next = applyChannelDelta(state, {
|
|
186
191
|
memberships: pruned.map((conn) => ({ type: 'remove' as const, conn })),
|
|
187
192
|
});
|
|
188
193
|
await this.persistState(next);
|
|
194
|
+
await this.syncRegistry(wasEmpty, next);
|
|
189
195
|
}
|
|
190
196
|
return pruned.length;
|
|
191
197
|
}
|
|
@@ -206,6 +212,16 @@ export class ChannelDO extends DurableObject<Env> {
|
|
|
206
212
|
return toDto(state);
|
|
207
213
|
}
|
|
208
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Returns the ConnIds of every member of this channel. Used by
|
|
217
|
+
* {@link CfRuntime.getChannelConnections} to enumerate the
|
|
218
|
+
* ConnectionStates of all members (for WHO).
|
|
219
|
+
*/
|
|
220
|
+
async listMembers(): Promise<string[]> {
|
|
221
|
+
const state = await this.loadState();
|
|
222
|
+
return Array.from(state.members.keys());
|
|
223
|
+
}
|
|
224
|
+
|
|
209
225
|
// -------------------------------------------------------------------------
|
|
210
226
|
// Internal helpers
|
|
211
227
|
// -------------------------------------------------------------------------
|
|
@@ -289,6 +305,29 @@ export class ChannelDO extends DurableObject<Env> {
|
|
|
289
305
|
}
|
|
290
306
|
}
|
|
291
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Keeps the channel registry in sync: registers the channel when it
|
|
310
|
+
* transitions from empty to non-empty, unregisters on the reverse
|
|
311
|
+
* transition. Called after every membership-mutating path so the
|
|
312
|
+
* registry always reflects the live channel set.
|
|
313
|
+
*/
|
|
314
|
+
private async syncRegistry(wasEmpty: boolean, next: ChannelState): Promise<void> {
|
|
315
|
+
const isEmpty = next.members.size === 0;
|
|
316
|
+
if (wasEmpty && !isEmpty) {
|
|
317
|
+
await this.channelRegistry().register(next.nameLower);
|
|
318
|
+
} else if (!wasEmpty && isEmpty) {
|
|
319
|
+
await this.channelRegistry().unregister(next.nameLower);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Returns the channel registry DO RPC stub. */
|
|
324
|
+
private channelRegistry(): ChannelRegistryRpc {
|
|
325
|
+
const stub = this.env.CHANNEL_REGISTRY_DO.get(
|
|
326
|
+
this.env.CHANNEL_REGISTRY_DO.idFromName('channels'),
|
|
327
|
+
);
|
|
328
|
+
return stub as unknown as ChannelRegistryRpc;
|
|
329
|
+
}
|
|
330
|
+
|
|
292
331
|
// -------------------------------------------------------------------------
|
|
293
332
|
// Test hooks (only invoked from `runInDurableObject` in tests)
|
|
294
333
|
// -------------------------------------------------------------------------
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ChannelRegistryDO` — Cloudflare Durable Object that tracks the set
|
|
3
|
+
* of all channel names.
|
|
4
|
+
*
|
|
5
|
+
* A single DO instance (keyed by the literal name `channels`) maintains
|
|
6
|
+
* the complete channel-name set so {@link CfRuntime.listChannels} can
|
|
7
|
+
* enumerate every channel without scanning each per-channel DO
|
|
8
|
+
* individually.
|
|
9
|
+
*
|
|
10
|
+
* Storage layout:
|
|
11
|
+
* - One key per channel: `chan:<lower>` → `1`
|
|
12
|
+
*
|
|
13
|
+
* Per-channel keys keep storage proportional to the live channel count
|
|
14
|
+
* and let individual register/unregister calls stay O(1).
|
|
15
|
+
*
|
|
16
|
+
* RPC surface — implements {@link ChannelRegistryRpc}. The ChannelDO
|
|
17
|
+
* calls `register` / `unregister` whenever its membership transitions
|
|
18
|
+
* between empty and non-empty; CfRuntime calls `list` for LIST.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
22
|
+
import type { ChannelRegistryRpc } from './env.js';
|
|
23
|
+
|
|
24
|
+
/** Storage key prefix for per-channel entries. */
|
|
25
|
+
const CHAN_KEY_PREFIX = 'chan:';
|
|
26
|
+
|
|
27
|
+
/** Returns the storage key for `nameLower`. */
|
|
28
|
+
function chanKey(nameLower: string): string {
|
|
29
|
+
return `${CHAN_KEY_PREFIX}${nameLower}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Channel registry authority.
|
|
34
|
+
*
|
|
35
|
+
* The class is exported as a binding target in `wrangler.toml`; the
|
|
36
|
+
* Workers runtime instantiates it with `(ctx, env)`.
|
|
37
|
+
*/
|
|
38
|
+
export class ChannelRegistryDO extends DurableObject implements ChannelRegistryRpc {
|
|
39
|
+
/** Adds `name` to the registry (idempotent). */
|
|
40
|
+
async register(name: string): Promise<void> {
|
|
41
|
+
await this.ctx.storage.put(chanKey(name.toLowerCase()), 1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Removes `name` from the registry (idempotent). */
|
|
45
|
+
async unregister(name: string): Promise<void> {
|
|
46
|
+
await this.ctx.storage.delete(chanKey(name.toLowerCase()));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Returns all registered channel names (lowercased). */
|
|
50
|
+
async list(): Promise<string[]> {
|
|
51
|
+
const entries = await this.ctx.storage.list({ prefix: CHAN_KEY_PREFIX });
|
|
52
|
+
const names: string[] = [];
|
|
53
|
+
for (const key of entries.keys()) {
|
|
54
|
+
names.push(key.slice(CHAN_KEY_PREFIX.length));
|
|
55
|
+
}
|
|
56
|
+
return names;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Workers config loader.
|
|
3
|
+
*
|
|
4
|
+
* Adapts the platform-native env vars (Workers `vars` in `wrangler.toml`
|
|
5
|
+
* or per-environment secrets) into the shared `ServerConfigSchema` from
|
|
6
|
+
* `irc-core`. Both adapters (CF + AWS) consume the same schema; only the
|
|
7
|
+
* env-shape mapping differs per platform.
|
|
8
|
+
*
|
|
9
|
+
* String env vars are coerced into the schema's expected types here so
|
|
10
|
+
* operators can express every config knob as a wrangler var/secret
|
|
11
|
+
* without per-type plumbing in the worker.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The subset of the Workers `Env` that this loader reads. Mirrors the
|
|
18
|
+
* production {@link Env} in `env.ts` plus the optional knobs the schema
|
|
19
|
+
* recognises. Optional members are omitted from the input object when
|
|
20
|
+
* undefined so the schema's defaults apply.
|
|
21
|
+
*/
|
|
22
|
+
export interface CfConfigEnv {
|
|
23
|
+
SERVER_NAME?: string;
|
|
24
|
+
NETWORK_NAME?: string;
|
|
25
|
+
MOTD_LINES?: string;
|
|
26
|
+
MAX_CLIENTS?: string;
|
|
27
|
+
CHANNEL_PREFIXES?: string;
|
|
28
|
+
OPER_USER?: string;
|
|
29
|
+
OPER_PASSWORD?: string;
|
|
30
|
+
MAX_CHANNELS_PER_USER?: string;
|
|
31
|
+
MAX_TARGETS_PER_COMMAND?: string;
|
|
32
|
+
NICK_LEN?: string;
|
|
33
|
+
CHANNEL_LEN?: string;
|
|
34
|
+
TOPIC_LEN?: string;
|
|
35
|
+
MAX_LIST_ENTRIES?: string;
|
|
36
|
+
QUIT_MESSAGE?: string;
|
|
37
|
+
/**
|
|
38
|
+
* SASL PLAIN accounts. Newline-delimited `username:password` pairs.
|
|
39
|
+
* Parsed into the `saslAccounts` config field so the config-driven
|
|
40
|
+
* path (vs the connection-do's direct env read) stays consistent.
|
|
41
|
+
*/
|
|
42
|
+
SASL_ACCOUNTS?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Builds the input object the shared schema expects from a Workers env.
|
|
47
|
+
*
|
|
48
|
+
* Helper extracted so the assemble→parse boundary is unit-testable in
|
|
49
|
+
* isolation: the produced input is a plain object that exactly matches
|
|
50
|
+
* `ServerConfigSchema`'s shape (strings converted to numbers / arrays
|
|
51
|
+
* as needed). Missing optional fields are kept out so the schema
|
|
52
|
+
* defaults kick in.
|
|
53
|
+
*/
|
|
54
|
+
function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
|
|
55
|
+
const input: Record<string, unknown> = {
|
|
56
|
+
serverName: env.SERVER_NAME,
|
|
57
|
+
networkName: env.NETWORK_NAME,
|
|
58
|
+
};
|
|
59
|
+
if (env.MOTD_LINES !== undefined && env.MOTD_LINES.length > 0) {
|
|
60
|
+
input.motdLines = env.MOTD_LINES.split('\n');
|
|
61
|
+
}
|
|
62
|
+
if (env.MAX_CLIENTS !== undefined) {
|
|
63
|
+
input.maxClients = Number.parseInt(env.MAX_CLIENTS, 10);
|
|
64
|
+
}
|
|
65
|
+
if (env.CHANNEL_PREFIXES !== undefined) {
|
|
66
|
+
input.channelPrefixes = env.CHANNEL_PREFIXES;
|
|
67
|
+
}
|
|
68
|
+
if (env.OPER_USER !== undefined || env.OPER_PASSWORD !== undefined) {
|
|
69
|
+
// Build the cred object from whatever was supplied; the schema
|
|
70
|
+
// surfaces a `user`/`password` error if only one half was provided.
|
|
71
|
+
input.operCreds = [
|
|
72
|
+
{
|
|
73
|
+
user: env.OPER_USER ?? '',
|
|
74
|
+
password: env.OPER_PASSWORD ?? '',
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
if (env.MAX_CHANNELS_PER_USER !== undefined) {
|
|
79
|
+
input.maxChannelsPerUser = Number.parseInt(env.MAX_CHANNELS_PER_USER, 10);
|
|
80
|
+
}
|
|
81
|
+
if (env.MAX_TARGETS_PER_COMMAND !== undefined) {
|
|
82
|
+
input.maxTargetsPerCommand = Number.parseInt(env.MAX_TARGETS_PER_COMMAND, 10);
|
|
83
|
+
}
|
|
84
|
+
if (env.NICK_LEN !== undefined) {
|
|
85
|
+
input.nickLen = Number.parseInt(env.NICK_LEN, 10);
|
|
86
|
+
}
|
|
87
|
+
if (env.CHANNEL_LEN !== undefined) {
|
|
88
|
+
input.channelLen = Number.parseInt(env.CHANNEL_LEN, 10);
|
|
89
|
+
}
|
|
90
|
+
if (env.TOPIC_LEN !== undefined) {
|
|
91
|
+
input.topicLen = Number.parseInt(env.TOPIC_LEN, 10);
|
|
92
|
+
}
|
|
93
|
+
if (env.MAX_LIST_ENTRIES !== undefined) {
|
|
94
|
+
input.maxListEntries = Number.parseInt(env.MAX_LIST_ENTRIES, 10);
|
|
95
|
+
}
|
|
96
|
+
if (env.QUIT_MESSAGE !== undefined) {
|
|
97
|
+
input.quitMessage = env.QUIT_MESSAGE;
|
|
98
|
+
}
|
|
99
|
+
if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
|
|
100
|
+
input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
|
|
101
|
+
}
|
|
102
|
+
return input;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parses a newline-delimited `username:password` string into the config's
|
|
107
|
+
* `saslAccounts` shape. Malformed entries are skipped so a trailing
|
|
108
|
+
* newline or partial edit does not break boot.
|
|
109
|
+
*/
|
|
110
|
+
function parseSaslAccountsLines(raw: string): Array<{ username: string; password: string }> {
|
|
111
|
+
const out: Array<{ username: string; password: string }> = [];
|
|
112
|
+
for (const line of raw.split('\n')) {
|
|
113
|
+
const trimmed = line.trim();
|
|
114
|
+
if (trimmed.length === 0) continue;
|
|
115
|
+
const sep = trimmed.indexOf(':');
|
|
116
|
+
if (sep <= 0) continue;
|
|
117
|
+
const username = trimmed.slice(0, sep);
|
|
118
|
+
const password = trimmed.slice(sep + 1);
|
|
119
|
+
if (username.length === 0 || password.length === 0) continue;
|
|
120
|
+
out.push({ username, password });
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Loads and validates the server config from a Cloudflare Workers env.
|
|
127
|
+
*
|
|
128
|
+
* Wraps the shared {@link parseServerConfig} so a single boot-time
|
|
129
|
+
* failure mode covers every adapter: invalid config throws a readable
|
|
130
|
+
* `Error` listing every offending field, surfacing the problem before
|
|
131
|
+
* any connection is accepted.
|
|
132
|
+
*/
|
|
133
|
+
export function loadServerConfigFromCfEnv(env: CfConfigEnv): ParsedServerConfig {
|
|
134
|
+
return parseServerConfig(buildConfigInput(env));
|
|
135
|
+
}
|
|
@@ -29,14 +29,22 @@
|
|
|
29
29
|
|
|
30
30
|
import { DurableObject } from 'cloudflare:workers';
|
|
31
31
|
import {
|
|
32
|
+
type AccountStore,
|
|
32
33
|
type ChanName,
|
|
33
34
|
type ChannelState,
|
|
34
35
|
type Clock,
|
|
35
36
|
type ConnId,
|
|
36
37
|
type ConnectionState,
|
|
38
|
+
ConsoleLogger,
|
|
37
39
|
type IdFactory,
|
|
40
|
+
InMemoryAccountStore,
|
|
41
|
+
InMemoryMessageStore,
|
|
42
|
+
LogLevel,
|
|
43
|
+
type Logger,
|
|
44
|
+
type MessageStore,
|
|
38
45
|
type MotdProvider,
|
|
39
46
|
type RawLine,
|
|
47
|
+
type SaslAccountCredential,
|
|
40
48
|
type ServerConfig,
|
|
41
49
|
SystemClock,
|
|
42
50
|
UuidIdFactory,
|
|
@@ -50,6 +58,7 @@ import { makeCfRuntime } from './cf-runtime.js';
|
|
|
50
58
|
import type { CfConnectionHandlers } from './cf-runtime.js';
|
|
51
59
|
import type { Env } from './env.js';
|
|
52
60
|
import { PERSISTED_STATE_VERSION, STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
|
|
61
|
+
import type { PersistedConnectionState } from './serialize.js';
|
|
53
62
|
|
|
54
63
|
/** Default PING cadence (ms) — tuned for typical IRC client behavior. */
|
|
55
64
|
export const DEFAULT_PING_INTERVAL_MS = 60_000;
|
|
@@ -90,6 +99,22 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
90
99
|
* should seed `createdAt` with its receive time.
|
|
91
100
|
*/
|
|
92
101
|
private channelAccess: PassthroughChannelAccess | undefined;
|
|
102
|
+
/**
|
|
103
|
+
* Per-connection chat-history store backing `draft/chathistory`
|
|
104
|
+
* playback. The minimum-viable in-worker ring buffer is scoped to this
|
|
105
|
+
* connection: messages this connection sends are recorded and replayable
|
|
106
|
+
* by the same connection. A Durable-Object-backed persistent variant
|
|
107
|
+
* (shared across connections, keyed by channel) is a documented
|
|
108
|
+
* follow-up. Lazily constructed so the first frame seeds it.
|
|
109
|
+
*/
|
|
110
|
+
private messages: MessageStore | undefined;
|
|
111
|
+
/**
|
|
112
|
+
* In-worker SASL account store seeded from the `SASL_ACCOUNTS` env var.
|
|
113
|
+
* Lazily constructed so the first frame that needs it parses the env once.
|
|
114
|
+
* A Durable-Object / D1-backed persistent variant is a documented
|
|
115
|
+
* follow-up; swapping it in means replacing {@link accountStore}.
|
|
116
|
+
*/
|
|
117
|
+
private accounts: AccountStore | undefined;
|
|
93
118
|
private readonly clock: Clock = SystemClock;
|
|
94
119
|
private readonly ids: IdFactory = new UuidIdFactory();
|
|
95
120
|
private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
|
|
@@ -293,6 +318,11 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
293
318
|
if (this.channelAccess === undefined) {
|
|
294
319
|
this.channelAccess = new PassthroughChannelAccess(this.clock.now(), this.env);
|
|
295
320
|
}
|
|
321
|
+
// Workers Observability ingests `console.*` directly.
|
|
322
|
+
// Bound `connectionId` so every record the actor emits is filterable
|
|
323
|
+
// per-connection in the Workers dashboard.
|
|
324
|
+
const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
|
|
325
|
+
const accounts = this.accountStore();
|
|
296
326
|
return new ConnectionActor({
|
|
297
327
|
state,
|
|
298
328
|
runtime,
|
|
@@ -301,6 +331,9 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
301
331
|
clock: this.clock,
|
|
302
332
|
ids: this.ids,
|
|
303
333
|
motd,
|
|
334
|
+
messages: this.messageStore(),
|
|
335
|
+
...(accounts !== undefined ? { accounts } : {}),
|
|
336
|
+
logger,
|
|
304
337
|
});
|
|
305
338
|
}
|
|
306
339
|
|
|
@@ -351,6 +384,34 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
351
384
|
return { lines: () => raw.split('\n') };
|
|
352
385
|
}
|
|
353
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Lazily constructs the per-connection chat-history store. The ring
|
|
389
|
+
* buffer default cap matches the shared `InMemoryMessageStore`; a
|
|
390
|
+
* future persistent variant (DO/KV-backed, keyed by channel) will
|
|
391
|
+
* replace this without touching the actor wiring.
|
|
392
|
+
*/
|
|
393
|
+
private messageStore(): MessageStore {
|
|
394
|
+
if (this.messages === undefined) {
|
|
395
|
+
this.messages = new InMemoryMessageStore();
|
|
396
|
+
}
|
|
397
|
+
return this.messages;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Lazily constructs the in-worker SASL account store from the
|
|
402
|
+
* `SASL_ACCOUNTS` env var. Returns `undefined` when no accounts are
|
|
403
|
+
* configured so the actor's `ctx.accounts` stays unset (preserving the
|
|
404
|
+
* no-store behaviour: `AUTHENTICATE PLAIN` → `904`). A Durable-Object /
|
|
405
|
+
* D1-backed persistent variant is a documented follow-up.
|
|
406
|
+
*/
|
|
407
|
+
private accountStore(): AccountStore | undefined {
|
|
408
|
+
if (this.accounts !== undefined) return this.accounts;
|
|
409
|
+
const creds = parseSaslAccountsEnv(this.env.SASL_ACCOUNTS);
|
|
410
|
+
if (creds.length === 0) return undefined;
|
|
411
|
+
this.accounts = new InMemoryAccountStore(creds);
|
|
412
|
+
return this.accounts;
|
|
413
|
+
}
|
|
414
|
+
|
|
354
415
|
// -------------------------------------------------------------------------
|
|
355
416
|
// Test hooks (only invoked from `runInDurableObject` in tests)
|
|
356
417
|
// -------------------------------------------------------------------------
|
|
@@ -394,6 +455,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
394
455
|
clock: this.clock,
|
|
395
456
|
ids: this.ids,
|
|
396
457
|
motd: this.motdProvider(),
|
|
458
|
+
messages: this.messageStore(),
|
|
397
459
|
});
|
|
398
460
|
await actor.receiveTextFrame('QUIT\r\n');
|
|
399
461
|
await this.releaseAndPersist(state);
|
|
@@ -436,6 +498,38 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
436
498
|
const state = await this.loadState();
|
|
437
499
|
return toSnapshot(state);
|
|
438
500
|
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Cross-connection RPC: returns the full connection state as a
|
|
504
|
+
* JSON-friendly DTO ({@link PersistedConnectionState}). Called by
|
|
505
|
+
* another connection's {@link CfRuntime} when a `GetConnection`
|
|
506
|
+
* lookup targets this connection (e.g. WHOIS). The DTO survives DO
|
|
507
|
+
* RPC because `Set`/`Map` fields are pre-flattened to arrays by
|
|
508
|
+
* {@link serialize}; the caller rehydrates via {@link deserialize}.
|
|
509
|
+
*/
|
|
510
|
+
public async getConnState(): Promise<PersistedConnectionState | null> {
|
|
511
|
+
const state = await this.loadState();
|
|
512
|
+
return serialize(state);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Cross-connection RPC: tears down this connection. Reuses the same
|
|
517
|
+
* QUIT fan-out / nick-release / WebSocket-close path as the normal
|
|
518
|
+
* `webSocketClose` handler so KICK and operator-driven disconnect
|
|
519
|
+
* produce identical side effects. If no live socket is attached the
|
|
520
|
+
* no-socket teardown path still releases the nick.
|
|
521
|
+
*/
|
|
522
|
+
public async disconnect(reason?: string): Promise<void> {
|
|
523
|
+
void reason;
|
|
524
|
+
const sockets = this.ctx.getWebSockets();
|
|
525
|
+
const ws = sockets[0];
|
|
526
|
+
if (ws === undefined) {
|
|
527
|
+
const state = await this.loadState();
|
|
528
|
+
await this.tearDownNoSocket(state);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
await this.tearDown(ws as WebSocket);
|
|
532
|
+
}
|
|
439
533
|
}
|
|
440
534
|
|
|
441
535
|
/** Default MOTD when no `MOTD_LINES` env is supplied. */
|
|
@@ -540,3 +634,28 @@ class PassthroughChannelAccess {
|
|
|
540
634
|
// in production today. (Removed from the ConnectionDO path because the
|
|
541
635
|
// authoritative delta application lives in the ChannelDO stub today.)
|
|
542
636
|
export { applyChannelDelta, PERSISTED_STATE_VERSION };
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Parses the `SASL_ACCOUNTS` env var into credential entries.
|
|
640
|
+
*
|
|
641
|
+
* Format: newline-delimited `username:password` pairs (mirroring the
|
|
642
|
+
* `MOTD_LINES` convention). Blank lines and malformed entries (missing
|
|
643
|
+
* colon or empty username/password) are skipped so a trailing newline or a
|
|
644
|
+
* partial edit does not break boot. Exported so the parsing boundary is
|
|
645
|
+
* unit-testable in isolation.
|
|
646
|
+
*/
|
|
647
|
+
export function parseSaslAccountsEnv(raw: string | undefined): SaslAccountCredential[] {
|
|
648
|
+
if (raw === undefined || raw.length === 0) return [];
|
|
649
|
+
const out: SaslAccountCredential[] = [];
|
|
650
|
+
for (const line of raw.split('\n')) {
|
|
651
|
+
const trimmed = line.trim();
|
|
652
|
+
if (trimmed.length === 0) continue;
|
|
653
|
+
const sep = trimmed.indexOf(':');
|
|
654
|
+
if (sep <= 0) continue; // missing colon or empty username
|
|
655
|
+
const username = trimmed.slice(0, sep);
|
|
656
|
+
const password = trimmed.slice(sep + 1);
|
|
657
|
+
if (username.length === 0 || password.length === 0) continue;
|
|
658
|
+
out.push({ username, password });
|
|
659
|
+
}
|
|
660
|
+
return out;
|
|
661
|
+
}
|
|
@@ -31,13 +31,28 @@ export interface Env {
|
|
|
31
31
|
REGISTRY_DO: DurableObjectNamespace;
|
|
32
32
|
/**
|
|
33
33
|
* Channel authority, keyed by lowercased channel name. RPC: broadcast /
|
|
34
|
-
* applyChannelDelta / getChannelSnapshot. Stubbed now;
|
|
34
|
+
* applyChannelDelta / getChannelSnapshot / listMembers. Stubbed now;
|
|
35
|
+
* real in 035.
|
|
35
36
|
*/
|
|
36
37
|
CHANNEL_DO: DurableObjectNamespace;
|
|
38
|
+
/**
|
|
39
|
+
* Channel registry authority. A single DO instance tracks the set of
|
|
40
|
+
* all channel names so {@link CfRuntime.listChannels} can enumerate
|
|
41
|
+
* them without scanning every per-channel DO. RPC: register /
|
|
42
|
+
* unregister / list.
|
|
43
|
+
*/
|
|
44
|
+
CHANNEL_REGISTRY_DO: DurableObjectNamespace;
|
|
37
45
|
/** Server-level config knobs (name, MOTD lines, limits). */
|
|
38
46
|
SERVER_NAME: string;
|
|
39
47
|
NETWORK_NAME: string;
|
|
40
48
|
MOTD_LINES: string;
|
|
49
|
+
/**
|
|
50
|
+
* SASL PLAIN accounts seeded into the in-worker `AccountStore`.
|
|
51
|
+
* Newline-delimited `username:password` pairs; the connection-do parses
|
|
52
|
+
* these into an `InMemoryAccountStore` so `AUTHENTICATE PLAIN` works
|
|
53
|
+
* end-to-end. Empty/undefined disables SASL account verification.
|
|
54
|
+
*/
|
|
55
|
+
SASL_ACCOUNTS?: string;
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
/**
|
|
@@ -64,4 +79,15 @@ export interface ChannelRpc {
|
|
|
64
79
|
broadcast(lines: unknown[], except?: string): Promise<void>;
|
|
65
80
|
applyChannelDelta(delta: unknown): Promise<void>;
|
|
66
81
|
getChannelSnapshot(): Promise<unknown>;
|
|
82
|
+
listMembers(): Promise<string[]>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* RPC contract for the channel registry DO. Tracks the set of all
|
|
87
|
+
* channel names so {@link CfRuntime.listChannels} can enumerate them.
|
|
88
|
+
*/
|
|
89
|
+
export interface ChannelRegistryRpc {
|
|
90
|
+
register(name: string): Promise<void>;
|
|
91
|
+
unregister(name: string): Promise<void>;
|
|
92
|
+
list(): Promise<string[]>;
|
|
67
93
|
}
|
|
@@ -19,6 +19,7 @@ export {
|
|
|
19
19
|
} from './connection-do.js';
|
|
20
20
|
export { RegistryDO } from './registry-do.js';
|
|
21
21
|
export { ChannelDO } from './channel-do.js';
|
|
22
|
+
export { ChannelRegistryDO } from './channel-registry-do.js';
|
|
22
23
|
export type { DeliverResult, ConnectionDeliveryRpc } from './channel-do.js';
|
|
23
24
|
export {
|
|
24
25
|
DEFAULT_REGISTRY_SHARDS,
|
|
@@ -27,7 +28,7 @@ export {
|
|
|
27
28
|
} from './sharding.js';
|
|
28
29
|
export { makeCfRuntime } from './cf-runtime.js';
|
|
29
30
|
export type { CfConnectionHandlers } from './cf-runtime.js';
|
|
30
|
-
export type { ChannelRpc, Env, RegistryRpc } from './env.js';
|
|
31
|
+
export type { ChannelRpc, ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
|
|
31
32
|
export {
|
|
32
33
|
PERSISTED_STATE_VERSION,
|
|
33
34
|
STATE_STORAGE_KEY,
|
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
IrcHarness,
|
|
21
21
|
IrcHarnessFactory,
|
|
22
22
|
SpawnClientOptions,
|
|
23
|
-
} from '
|
|
23
|
+
} from '@serverless-ircd/irc-test-support';
|
|
24
24
|
|
|
25
25
|
/** Default tick for the polling waitFor* implementations (ms). */
|
|
26
26
|
const POLL_MS = 10;
|
|
@@ -57,7 +57,7 @@ export interface CfHarnessEnv {
|
|
|
57
57
|
*
|
|
58
58
|
* ```ts
|
|
59
59
|
* import { env } from 'cloudflare:test';
|
|
60
|
-
* import { runIrcScenarios } from '
|
|
60
|
+
* import { runIrcScenarios } from '@serverless-ircd/irc-test-support';
|
|
61
61
|
* import { makeCfHarnessFactory } from './cf-harness';
|
|
62
62
|
*
|
|
63
63
|
* runIrcScenarios([makeCfHarnessFactory({ env })]);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Parametrized IRC scenarios against the Cloudflare runtime — 036.
|
|
3
3
|
*
|
|
4
|
-
* Reuses the scenario runner from
|
|
4
|
+
* Reuses the scenario runner from `@serverless-ircd/irc-test-support`
|
|
5
5
|
* (032) and registers the CF harness factory built on top of the
|
|
6
6
|
* real ConnectionDO / RegistryDO / ChannelDO worker bindings. Each
|
|
7
7
|
* scenario spawns one or more real WebSockets and drives the full
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { env, reset } from 'cloudflare:test';
|
|
29
|
+
import { runIrcScenarios } from '@serverless-ircd/irc-test-support';
|
|
29
30
|
import { afterAll, afterEach, beforeAll } from 'vitest';
|
|
30
31
|
import { makeCfHarnessFactory } from './cf-harness';
|
|
31
|
-
import { runIrcScenarios } from './integration/scenarios.js';
|
|
32
32
|
|
|
33
33
|
declare global {
|
|
34
34
|
namespace Cloudflare {
|
|
@@ -44,6 +44,7 @@ declare global {
|
|
|
44
44
|
CHANNEL_DO: DurableObjectNamespace;
|
|
45
45
|
CHANNEL_DO_REAL: DurableObjectNamespace;
|
|
46
46
|
CHANNEL_DO_STUB: DurableObjectNamespace;
|
|
47
|
+
CHANNEL_REGISTRY_DO: DurableObjectNamespace;
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
}
|
|
@@ -62,6 +63,7 @@ function envWithRealDOs(): Env {
|
|
|
62
63
|
CONNECTION_DO: env.CONNECTION_DO,
|
|
63
64
|
REGISTRY_DO: env.REGISTRY_DO_REAL,
|
|
64
65
|
CHANNEL_DO: env.CHANNEL_DO_REAL,
|
|
66
|
+
CHANNEL_REGISTRY_DO: env.CHANNEL_REGISTRY_DO,
|
|
65
67
|
SERVER_NAME: 'irc.example.com',
|
|
66
68
|
NETWORK_NAME: 'ExampleNet',
|
|
67
69
|
MOTD_LINES: '',
|
|
@@ -78,6 +80,7 @@ function envWithChannelStub(): Env {
|
|
|
78
80
|
CONNECTION_DO: env.CONNECTION_DO,
|
|
79
81
|
REGISTRY_DO: env.REGISTRY_DO_REAL,
|
|
80
82
|
CHANNEL_DO: env.CHANNEL_DO_STUB,
|
|
83
|
+
CHANNEL_REGISTRY_DO: env.CHANNEL_REGISTRY_DO,
|
|
81
84
|
SERVER_NAME: 'irc.example.com',
|
|
82
85
|
NETWORK_NAME: 'ExampleNet',
|
|
83
86
|
MOTD_LINES: '',
|
|
@@ -484,6 +487,94 @@ describe('CfRuntime — cross-connection RPC via ConnectionDO', () => {
|
|
|
484
487
|
});
|
|
485
488
|
});
|
|
486
489
|
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Tests — cross-connection getConnection / getChannelConnections / listChannels / disconnect
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
describe('CfRuntime — cross-connection lookups and disconnect', () => {
|
|
495
|
+
it('getConnection(remoteConnId) returns the remote connection state via RPC', async () => {
|
|
496
|
+
const targetName = 'conn-get-target';
|
|
497
|
+
const ws = await openConnection(targetName);
|
|
498
|
+
await new Promise<void>((r) => setTimeout(r, 20));
|
|
499
|
+
ws.send('NICK carol\r\n');
|
|
500
|
+
ws.send('USER carol 0 * :Carol\r\n');
|
|
501
|
+
await new Promise<void>((r) => setTimeout(r, 100));
|
|
502
|
+
|
|
503
|
+
const targetHexId = await getConnectionHexId(targetName);
|
|
504
|
+
const realEnv = envWithRealDOs();
|
|
505
|
+
const rt = new CfRuntime(realEnv, 'conn-get-caller', recordingHandlers());
|
|
506
|
+
const conn = await rt.getConnection(targetHexId);
|
|
507
|
+
expect(conn).not.toBeNull();
|
|
508
|
+
expect(conn?.nick).toBe('carol');
|
|
509
|
+
expect(conn?.id).toBe(targetHexId);
|
|
510
|
+
|
|
511
|
+
ws.close();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('getChannelConnections(chan) returns the ConnectionStates of all members', async () => {
|
|
515
|
+
const memberName = 'conn-who-target';
|
|
516
|
+
const ws = await openConnection(memberName);
|
|
517
|
+
await new Promise<void>((r) => setTimeout(r, 20));
|
|
518
|
+
ws.send('NICK dave\r\n');
|
|
519
|
+
ws.send('USER dave 0 * :Dave\r\n');
|
|
520
|
+
await new Promise<void>((r) => setTimeout(r, 100));
|
|
521
|
+
|
|
522
|
+
const memberHexId = await getConnectionHexId(memberName);
|
|
523
|
+
const realEnv = envWithRealDOs();
|
|
524
|
+
const rt = new CfRuntime(realEnv, 'conn-who-caller', recordingHandlers());
|
|
525
|
+
|
|
526
|
+
await rt.applyChannelDelta('#whochan', {
|
|
527
|
+
memberships: [{ type: 'add' as const, conn: memberHexId, nick: 'dave' }],
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const conns = await rt.getChannelConnections('#whochan');
|
|
531
|
+
expect(conns.size).toBe(1);
|
|
532
|
+
expect(conns.get(memberHexId)?.nick).toBe('dave');
|
|
533
|
+
|
|
534
|
+
ws.close();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('listChannels returns channels registered in the registry', async () => {
|
|
538
|
+
const realEnv = envWithRealDOs();
|
|
539
|
+
const rt = new CfRuntime(realEnv, 'conn-list', recordingHandlers());
|
|
540
|
+
|
|
541
|
+
await rt.applyChannelDelta('#listchan', {
|
|
542
|
+
memberships: [{ type: 'add' as const, conn: 'conn-list', nick: 'eve' }],
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
const channels = await rt.listChannels();
|
|
546
|
+
expect(channels.length).toBeGreaterThan(0);
|
|
547
|
+
expect(channels.some((c) => c.nameLower === '#listchan')).toBe(true);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('listChannels returns empty array when no channels exist', async () => {
|
|
551
|
+
const realEnv = envWithRealDOs();
|
|
552
|
+
const rt = new CfRuntime(realEnv, 'conn-list-empty', recordingHandlers());
|
|
553
|
+
const channels = await rt.listChannels();
|
|
554
|
+
expect(channels).toEqual([]);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('disconnect(remoteConn) closes the target connection', async () => {
|
|
558
|
+
const targetName = 'conn-disc-target';
|
|
559
|
+
const ws = await openConnection(targetName);
|
|
560
|
+
await new Promise<void>((r) => setTimeout(r, 20));
|
|
561
|
+
|
|
562
|
+
const targetHexId = await getConnectionHexId(targetName);
|
|
563
|
+
const realEnv = envWithRealDOs();
|
|
564
|
+
const rt = new CfRuntime(realEnv, 'conn-disc-caller', recordingHandlers());
|
|
565
|
+
|
|
566
|
+
const closed = new Promise<void>((resolve) => {
|
|
567
|
+
ws.addEventListener('close', () => resolve());
|
|
568
|
+
setTimeout(resolve, 2000);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
await rt.disconnect(targetHexId, 'Kicked');
|
|
572
|
+
await closed;
|
|
573
|
+
|
|
574
|
+
expect([WebSocket.CLOSING, WebSocket.CLOSED]).toContain(ws.readyState);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
487
578
|
// ---------------------------------------------------------------------------
|
|
488
579
|
// Tests — implements IrcRuntime
|
|
489
580
|
// ---------------------------------------------------------------------------
|