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
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Membership atomicity for JOIN / PART / KICK (the DynamoDB
|
|
3
|
+
* `TransactWriteItems` path).
|
|
4
|
+
*
|
|
5
|
+
* Each membership mutation must span BOTH tables in a single transaction:
|
|
6
|
+
* - `ChannelMembers` (the authoritative roster), AND
|
|
7
|
+
* - `Connections.joinedChannels` (the per-connection joined set used
|
|
8
|
+
* by `$disconnect` cleanup).
|
|
9
|
+
*
|
|
10
|
+
* Without the transaction, a concurrent JOIN of two different channels
|
|
11
|
+
* by the same connection leaves `joinedChannels` missing one of them
|
|
12
|
+
* (the second `persistConnectionState` overwrites the first). The
|
|
13
|
+
* stress test below proves the transactions serialise correctly.
|
|
14
|
+
*
|
|
15
|
+
* Gated on `process.env.DDB_AVAILABLE` — needs real DynamoDB Local
|
|
16
|
+
* (TransactWriteItems semantics aren't faithfully mockable).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
20
|
+
import { GetCommand, PutCommand, QueryCommand, TransactWriteCommand } from '@aws-sdk/lib-dynamodb';
|
|
21
|
+
import { type ChanName, type Nick, createConnection } from '@serverless-ircd/irc-core';
|
|
22
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
23
|
+
import { AwsRuntime, type AwsRuntimeHandlers } from '../src/aws-runtime.js';
|
|
24
|
+
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
25
|
+
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
26
|
+
import { marshalChannelMember, marshalConnection } from '../src/serialize.js';
|
|
27
|
+
import type { TablesConfig } from '../src/tables.js';
|
|
28
|
+
|
|
29
|
+
const available = process.env.DDB_AVAILABLE === '1';
|
|
30
|
+
const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
31
|
+
|
|
32
|
+
interface Fixture {
|
|
33
|
+
tables: TablesConfig;
|
|
34
|
+
client: ReturnType<typeof createDynamoDocumentClient>;
|
|
35
|
+
cleanup: () => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let fx: Fixture | null;
|
|
39
|
+
|
|
40
|
+
async function makeFixture(): Promise<Fixture> {
|
|
41
|
+
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
42
|
+
const prefix = `x${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
|
|
43
|
+
const tables = Object.fromEntries(
|
|
44
|
+
Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
|
|
45
|
+
) as TablesConfig;
|
|
46
|
+
const client = createDynamoDocumentClient({ endpoint });
|
|
47
|
+
for (const [logical, props] of Object.entries(TABLE_DEFS)) {
|
|
48
|
+
const pkName = props.partitionKey?.name;
|
|
49
|
+
const skName = props.sortKey?.name;
|
|
50
|
+
if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
|
|
51
|
+
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
|
|
52
|
+
{ AttributeName: pkName, AttributeType: 'S' },
|
|
53
|
+
];
|
|
54
|
+
const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
55
|
+
{ AttributeName: pkName, KeyType: 'HASH' },
|
|
56
|
+
];
|
|
57
|
+
if (skName !== undefined) {
|
|
58
|
+
attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
|
|
59
|
+
keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
|
|
60
|
+
}
|
|
61
|
+
await client.send(
|
|
62
|
+
new CreateTableCommand({
|
|
63
|
+
TableName: `${prefix}${logical}`,
|
|
64
|
+
AttributeDefinitions: attributeDefinitions,
|
|
65
|
+
KeySchema: keySchema,
|
|
66
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
tables,
|
|
72
|
+
client,
|
|
73
|
+
cleanup: async () => {
|
|
74
|
+
for (const logical of Object.keys(TABLE_DEFS)) {
|
|
75
|
+
try {
|
|
76
|
+
await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
|
|
77
|
+
} catch {
|
|
78
|
+
// best-effort
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function makeRuntime(connId: string): AwsRuntime {
|
|
86
|
+
const handlers: AwsRuntimeHandlers = {
|
|
87
|
+
send: () => {},
|
|
88
|
+
disconnect: () => {},
|
|
89
|
+
snapshot: () => undefined,
|
|
90
|
+
};
|
|
91
|
+
return new AwsRuntime({
|
|
92
|
+
dynamo: fx!.client,
|
|
93
|
+
tables: fx!.tables,
|
|
94
|
+
connId,
|
|
95
|
+
handlers,
|
|
96
|
+
managementApi: null,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Persists a fresh Connections row with no joined channels. */
|
|
101
|
+
async function seedConnection(connId: string, idleSince = 1_000): Promise<void> {
|
|
102
|
+
const state = createConnection({ id: connId, connectedSince: idleSince });
|
|
103
|
+
await fx!.client.send(
|
|
104
|
+
new PutCommand({
|
|
105
|
+
TableName: fx!.tables.Connections,
|
|
106
|
+
Item: marshalConnection(state, idleSince),
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Reads the raw Connections row (so we can inspect the stored shape). */
|
|
112
|
+
async function readConnection(connId: string): Promise<{ joinedChannels?: unknown } | undefined> {
|
|
113
|
+
const res = await fx!.client.send(
|
|
114
|
+
new GetCommand({
|
|
115
|
+
TableName: fx!.tables.Connections,
|
|
116
|
+
Key: { connectionId: connId },
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
return res.Item as { joinedChannels?: unknown } | undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Reads every ChannelMembers row for a channel. */
|
|
123
|
+
async function readMembers(channelKey: string): Promise<{ connectionId: string }[]> {
|
|
124
|
+
const res = await fx!.client.send(
|
|
125
|
+
new QueryCommand({
|
|
126
|
+
TableName: fx!.tables.ChannelMembers,
|
|
127
|
+
KeyConditionExpression: 'channelName = :k',
|
|
128
|
+
ExpressionAttributeValues: { ':k': channelKey },
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
return (res.Items ?? []) as { connectionId: string }[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Seeds a ChannelMembers row + reflects it on the connection's joined set. */
|
|
135
|
+
async function seedMembership(connId: string, nick: string, channel: ChanName): Promise<void> {
|
|
136
|
+
const state = createConnection({ id: connId, connectedSince: 1_000 });
|
|
137
|
+
state.registration = 'registered';
|
|
138
|
+
state.nick = nick as Nick;
|
|
139
|
+
state.joinedChannels.add(channel);
|
|
140
|
+
await fx!.client.send(
|
|
141
|
+
new PutCommand({
|
|
142
|
+
TableName: fx!.tables.Connections,
|
|
143
|
+
Item: marshalConnection(state, 1_000),
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
await fx!.client.send(
|
|
147
|
+
new PutCommand({
|
|
148
|
+
TableName: fx!.tables.ChannelMembers,
|
|
149
|
+
Item: marshalChannelMember(channel.toLowerCase(), {
|
|
150
|
+
conn: connId,
|
|
151
|
+
nick: nick as Nick,
|
|
152
|
+
op: false,
|
|
153
|
+
voice: false,
|
|
154
|
+
}),
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
describe.skipIf(!available)('membership transactions', () => {
|
|
160
|
+
beforeEach(async () => {
|
|
161
|
+
fx = await makeFixture();
|
|
162
|
+
});
|
|
163
|
+
afterEach(async () => {
|
|
164
|
+
if (fx) await fx.cleanup();
|
|
165
|
+
fx = null;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// -------------------------------------------------------------------------
|
|
169
|
+
// Atomicity: ChannelMembers ↔ Connections.joinedChannels
|
|
170
|
+
// -------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
it('JOIN writes both the ChannelMembers row and the channel to Connections.joinedChannels', async () => {
|
|
173
|
+
await seedConnection('c1');
|
|
174
|
+
const rt = makeRuntime('caller');
|
|
175
|
+
|
|
176
|
+
await rt.applyChannelDelta('#foo' as ChanName, {
|
|
177
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const members = await readMembers('#foo');
|
|
181
|
+
expect(members.map((m) => m.connectionId)).toEqual(['c1']);
|
|
182
|
+
|
|
183
|
+
const row = await readConnection('c1');
|
|
184
|
+
expect(row).toBeDefined();
|
|
185
|
+
expect(row!.joinedChannels).toBeInstanceOf(Set);
|
|
186
|
+
expect((row!.joinedChannels as Set<string>).has('#foo')).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('PART removes both the ChannelMembers row and the channel from joinedChannels', async () => {
|
|
190
|
+
await seedMembership('c1', 'Alice', '#foo' as ChanName);
|
|
191
|
+
const rt = makeRuntime('caller');
|
|
192
|
+
|
|
193
|
+
await rt.applyChannelDelta('#foo' as ChanName, {
|
|
194
|
+
memberships: [{ type: 'remove', conn: 'c1' }],
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const members = await readMembers('#foo');
|
|
198
|
+
expect(members).toHaveLength(0);
|
|
199
|
+
|
|
200
|
+
const row = await readConnection('c1');
|
|
201
|
+
expect(row).toBeDefined();
|
|
202
|
+
expect((row!.joinedChannels as Set<string> | undefined)?.has('#foo') ?? false).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('KICK (remove of a different connection) drops the target from joinedChannels', async () => {
|
|
206
|
+
await seedMembership('target', 'Bob', '#room' as ChanName);
|
|
207
|
+
const rt = makeRuntime('op');
|
|
208
|
+
|
|
209
|
+
await rt.applyChannelDelta('#room' as ChanName, {
|
|
210
|
+
memberships: [{ type: 'remove', conn: 'target' }],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const row = await readConnection('target');
|
|
214
|
+
expect((row!.joinedChannels as Set<string> | undefined)?.has('#room') ?? false).toBe(false);
|
|
215
|
+
expect(await readMembers('#room')).toHaveLength(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// -------------------------------------------------------------------------
|
|
219
|
+
// Stress: concurrent mutations by the same connection
|
|
220
|
+
// -------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
it('concurrent JOINs of different channels by the same connection leave both channels in joinedChannels', async () => {
|
|
223
|
+
await seedConnection('c1');
|
|
224
|
+
const rtA = makeRuntime('probe-a');
|
|
225
|
+
const rtB = makeRuntime('probe-b');
|
|
226
|
+
|
|
227
|
+
await Promise.all([
|
|
228
|
+
rtA.applyChannelDelta('#foo' as ChanName, {
|
|
229
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
|
|
230
|
+
}),
|
|
231
|
+
rtB.applyChannelDelta('#bar' as ChanName, {
|
|
232
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
|
|
233
|
+
}),
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
const row = await readConnection('c1');
|
|
237
|
+
const joined = row!.joinedChannels as Set<string>;
|
|
238
|
+
expect(joined.has('#foo')).toBe(true);
|
|
239
|
+
expect(joined.has('#bar')).toBe(true);
|
|
240
|
+
|
|
241
|
+
expect((await readMembers('#foo')).map((m) => m.connectionId)).toEqual(['c1']);
|
|
242
|
+
expect((await readMembers('#bar')).map((m) => m.connectionId)).toEqual(['c1']);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('concurrent JOINs of the same channel by the same connection are idempotent (one roster row, channel listed once)', async () => {
|
|
246
|
+
await seedConnection('c1');
|
|
247
|
+
const rtA = makeRuntime('probe-a');
|
|
248
|
+
const rtB = makeRuntime('probe-b');
|
|
249
|
+
|
|
250
|
+
await expect(
|
|
251
|
+
Promise.all([
|
|
252
|
+
rtA.applyChannelDelta('#foo' as ChanName, {
|
|
253
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
|
|
254
|
+
}),
|
|
255
|
+
rtB.applyChannelDelta('#foo' as ChanName, {
|
|
256
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
|
|
257
|
+
}),
|
|
258
|
+
]),
|
|
259
|
+
).resolves.toBeDefined();
|
|
260
|
+
|
|
261
|
+
expect(await readMembers('#foo')).toHaveLength(1);
|
|
262
|
+
const row = await readConnection('c1');
|
|
263
|
+
const joined = row!.joinedChannels as Set<string>;
|
|
264
|
+
expect(joined.has('#foo')).toBe(true);
|
|
265
|
+
expect([...joined].filter((c) => c === '#foo')).toHaveLength(1);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('concurrent PARTs of the same channel are idempotent (no rows left, joinedChannels empty)', async () => {
|
|
269
|
+
await seedMembership('c1', 'Alice', '#foo' as ChanName);
|
|
270
|
+
const rtA = makeRuntime('probe-a');
|
|
271
|
+
const rtB = makeRuntime('probe-b');
|
|
272
|
+
|
|
273
|
+
await expect(
|
|
274
|
+
Promise.all([
|
|
275
|
+
rtA.applyChannelDelta('#foo' as ChanName, {
|
|
276
|
+
memberships: [{ type: 'remove', conn: 'c1' }],
|
|
277
|
+
}),
|
|
278
|
+
rtB.applyChannelDelta('#foo' as ChanName, {
|
|
279
|
+
memberships: [{ type: 'remove', conn: 'c1' }],
|
|
280
|
+
}),
|
|
281
|
+
]),
|
|
282
|
+
).resolves.toBeDefined();
|
|
283
|
+
|
|
284
|
+
expect(await readMembers('#foo')).toHaveLength(0);
|
|
285
|
+
const row = await readConnection('c1');
|
|
286
|
+
expect((row!.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// -------------------------------------------------------------------------
|
|
290
|
+
// Sequence: JOIN then PART leaves no trace
|
|
291
|
+
// -------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
it('JOIN then PART on the same connection leaves joinedChannels empty and no roster row', async () => {
|
|
294
|
+
await seedConnection('c1');
|
|
295
|
+
const rt = makeRuntime('caller');
|
|
296
|
+
|
|
297
|
+
await rt.applyChannelDelta('#foo' as ChanName, {
|
|
298
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
|
|
299
|
+
});
|
|
300
|
+
await rt.applyChannelDelta('#foo' as ChanName, {
|
|
301
|
+
memberships: [{ type: 'remove', conn: 'c1' }],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
expect(await readMembers('#foo')).toHaveLength(0);
|
|
305
|
+
const row = await readConnection('c1');
|
|
306
|
+
expect((row!.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Retry / error-classification (no DynamoDB Local required — fake client)
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Minimal fake document client. Counts `TransactWriteCommand` invocations
|
|
316
|
+
* and throws a configurable error for the first N transact attempts so
|
|
317
|
+
* the retry loop in `AwsRuntime.sendTransactWithRetry` is exercised
|
|
318
|
+
* without needing real DynamoDB concurrency.
|
|
319
|
+
*/
|
|
320
|
+
class FakeDynamo {
|
|
321
|
+
transactCalls = 0;
|
|
322
|
+
otherCalls = 0;
|
|
323
|
+
constructor(
|
|
324
|
+
private readonly opts: {
|
|
325
|
+
failTransactTimes?: number;
|
|
326
|
+
error?: { name: string; CancellationReasons?: Array<{ Code?: string }> };
|
|
327
|
+
},
|
|
328
|
+
) {}
|
|
329
|
+
async send(cmd: unknown): Promise<unknown> {
|
|
330
|
+
if (cmd instanceof TransactWriteCommand) {
|
|
331
|
+
this.transactCalls++;
|
|
332
|
+
const fail = this.opts.failTransactTimes ?? 0;
|
|
333
|
+
if (this.transactCalls <= fail) {
|
|
334
|
+
throw (
|
|
335
|
+
this.opts.error ?? {
|
|
336
|
+
name: 'TransactionCanceledException',
|
|
337
|
+
CancellationReasons: [{ Code: 'TransactionConflict' }],
|
|
338
|
+
}
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return {};
|
|
342
|
+
}
|
|
343
|
+
this.otherCalls++;
|
|
344
|
+
// ensureMetaRow does a PutCommand (ChannelMeta); return success.
|
|
345
|
+
return {};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function fakeRuntime(client: FakeDynamo): AwsRuntime {
|
|
350
|
+
const handlers: AwsRuntimeHandlers = {
|
|
351
|
+
send: () => {},
|
|
352
|
+
disconnect: () => {},
|
|
353
|
+
snapshot: () => undefined,
|
|
354
|
+
};
|
|
355
|
+
return new AwsRuntime({
|
|
356
|
+
dynamo: client as unknown as ReturnType<typeof createDynamoDocumentClient>,
|
|
357
|
+
tables: {
|
|
358
|
+
Connections: 'C',
|
|
359
|
+
ChannelMeta: 'CM',
|
|
360
|
+
ChannelMembers: 'CMem',
|
|
361
|
+
Nicks: 'N',
|
|
362
|
+
Accounts: 'A',
|
|
363
|
+
},
|
|
364
|
+
connId: 'probe',
|
|
365
|
+
handlers,
|
|
366
|
+
managementApi: null,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
describe('membership transaction retry / error classification', () => {
|
|
371
|
+
it('retries on TransactionConflict and succeeds once the conflict clears', async () => {
|
|
372
|
+
const fake = new FakeDynamo({ failTransactTimes: 2 });
|
|
373
|
+
const rt = fakeRuntime(fake);
|
|
374
|
+
|
|
375
|
+
await rt.applyChannelDelta('#foo' as ChanName, {
|
|
376
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// 2 failed attempts + 1 successful = 3 transact calls.
|
|
380
|
+
expect(fake.transactCalls).toBe(3);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('rethrows a non-conflict TransactionCanceledException immediately (no retry)', async () => {
|
|
384
|
+
const fake = new FakeDynamo({
|
|
385
|
+
failTransactTimes: 1,
|
|
386
|
+
error: {
|
|
387
|
+
name: 'TransactionCanceledException',
|
|
388
|
+
CancellationReasons: [{ Code: 'ConditionalCheckFailed' }],
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
const rt = fakeRuntime(fake);
|
|
392
|
+
|
|
393
|
+
await expect(
|
|
394
|
+
rt.applyChannelDelta('#foo' as ChanName, {
|
|
395
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
|
|
396
|
+
}),
|
|
397
|
+
).rejects.toThrow();
|
|
398
|
+
expect(fake.transactCalls).toBe(1);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('rethrows after exhausting the retry budget on persistent conflicts', async () => {
|
|
402
|
+
const fake = new FakeDynamo({ failTransactTimes: 999 });
|
|
403
|
+
const rt = fakeRuntime(fake);
|
|
404
|
+
|
|
405
|
+
await expect(
|
|
406
|
+
rt.applyChannelDelta('#foo' as ChanName, {
|
|
407
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
|
|
408
|
+
}),
|
|
409
|
+
).rejects.toThrow();
|
|
410
|
+
// MAX_ATTEMPTS = 6.
|
|
411
|
+
expect(fake.transactCalls).toBe(6);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('rethrows a non-transact error (e.g. ProvisionedThroughputExceeded) without retry', async () => {
|
|
415
|
+
const fake = new FakeDynamo({
|
|
416
|
+
failTransactTimes: 1,
|
|
417
|
+
error: { name: 'ProvisionedThroughputExceededException' },
|
|
418
|
+
});
|
|
419
|
+
const rt = fakeRuntime(fake);
|
|
420
|
+
|
|
421
|
+
await expect(
|
|
422
|
+
rt.applyChannelDelta('#foo' as ChanName, {
|
|
423
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
|
|
424
|
+
}),
|
|
425
|
+
).rejects.toThrow();
|
|
426
|
+
expect(fake.transactCalls).toBe(1);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('rejects a TransactionConflict classifier on null / non-object errors', async () => {
|
|
430
|
+
// Smoke-test the classifier's defensive branches by replaying the
|
|
431
|
+
// same fake-runtime path with a non-object error. The classifier
|
|
432
|
+
// must treat it as non-retryable and rethrow on the first attempt.
|
|
433
|
+
const fake = new FakeDynamo({
|
|
434
|
+
failTransactTimes: 1,
|
|
435
|
+
error: { name: 'TransactionCanceledException' }, // no CancellationReasons
|
|
436
|
+
});
|
|
437
|
+
const rt = fakeRuntime(fake);
|
|
438
|
+
|
|
439
|
+
await expect(
|
|
440
|
+
rt.applyChannelDelta('#foo' as ChanName, {
|
|
441
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
|
|
442
|
+
}),
|
|
443
|
+
).rejects.toThrow();
|
|
444
|
+
expect(fake.transactCalls).toBe(1);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"composite": true,
|
|
5
|
+
"rootDir": "./src",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"tsBuildInfoFile": "./.tsbuildinfo",
|
|
8
|
+
"types": ["node"]
|
|
9
|
+
},
|
|
10
|
+
"include": ["src/**/*.ts"],
|
|
11
|
+
"exclude": ["dist", "tests", "**/*.test.ts"],
|
|
12
|
+
"references": [
|
|
13
|
+
{ "path": "../irc-core/tsconfig.build.json" },
|
|
14
|
+
{ "path": "../irc-server/tsconfig.build.json" }
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": true,
|
|
5
|
+
"types": ["node"]
|
|
6
|
+
},
|
|
7
|
+
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
|
8
|
+
"exclude": ["dist"],
|
|
9
|
+
"references": [
|
|
10
|
+
{ "path": "../irc-core/tsconfig.build.json" },
|
|
11
|
+
{ "path": "../irc-server/tsconfig.build.json" },
|
|
12
|
+
{ "path": "../irc-test-support/tsconfig.build.json" }
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globalSetup: ['./tests/global-setup.ts'],
|
|
6
|
+
setupFiles: [],
|
|
7
|
+
include: ['tests/**/*.test.ts'],
|
|
8
|
+
exclude: ['dist/**', 'node_modules/**'],
|
|
9
|
+
coverage: {
|
|
10
|
+
provider: 'v8',
|
|
11
|
+
all: false,
|
|
12
|
+
include: ['src/**/*.ts'],
|
|
13
|
+
exclude: ['src/**/index.ts'],
|
|
14
|
+
reporter: ['text', 'html', 'json-summary'],
|
|
15
|
+
thresholds: {
|
|
16
|
+
lines: 90,
|
|
17
|
+
functions: 90,
|
|
18
|
+
branches: 90,
|
|
19
|
+
statements: 90,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/cf-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Cloudflare Workers adapter: Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and CfRuntime implementing IrcRuntime",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"@cloudflare/vitest-pool-workers": "0.18.6",
|
|
31
31
|
"@cloudflare/workers-types": "^5.20260714.1",
|
|
32
32
|
"@serverless-ircd/in-memory-runtime": "workspace:*",
|
|
33
|
+
"@serverless-ircd/irc-test-support": "workspace:*",
|
|
33
34
|
"@vitest/coverage-v8": "^4.1.0",
|
|
34
35
|
"rimraf": "^6.0.0",
|
|
35
36
|
"typescript": "^5.6.0",
|
|
@@ -38,8 +38,10 @@ import {
|
|
|
38
38
|
toSnapshot,
|
|
39
39
|
} from '@serverless-ircd/irc-core';
|
|
40
40
|
import type { IrcRuntime } from '@serverless-ircd/irc-server';
|
|
41
|
-
import type { Env, RegistryRpc } from './env.js';
|
|
41
|
+
import type { ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
|
|
42
42
|
import { DEFAULT_REGISTRY_SHARDS, registryKeyForNick, shardNick } from './sharding.js';
|
|
43
|
+
import type { PersistedConnectionState } from './serialize.js';
|
|
44
|
+
import { deserialize } from './serialize.js';
|
|
43
45
|
|
|
44
46
|
/** Per-connection transport callbacks the ConnectionDO wires up. */
|
|
45
47
|
export interface CfConnectionHandlers {
|
|
@@ -64,6 +66,10 @@ export interface ConnectionRpc {
|
|
|
64
66
|
deliver(lines: RawLine[]): Promise<{ delivered: number }>;
|
|
65
67
|
/** Returns a serializable snapshot of the connection state. */
|
|
66
68
|
getConnSnapshot(): Promise<ConnSnapshot | null>;
|
|
69
|
+
/** Returns the full connection state as a serializable DTO. */
|
|
70
|
+
getConnState(): Promise<PersistedConnectionState | null>;
|
|
71
|
+
/** Cross-connection disconnect: closes the WebSocket and runs teardown. */
|
|
72
|
+
disconnect(reason?: string): Promise<void>;
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
/**
|
|
@@ -117,12 +123,8 @@ export class CfRuntime implements IrcRuntime {
|
|
|
117
123
|
this.handlers.disconnect(reason);
|
|
118
124
|
return;
|
|
119
125
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
// a dedicated `disconnect` RPC today — the canonical teardown path is
|
|
123
|
-
// the WebSocket close, which is owned by the connection itself. We
|
|
124
|
-
// therefore no-op for cross-connection disconnects; this preserves
|
|
125
|
-
// the IrcRuntime contract without inventing a new RPC.)
|
|
126
|
+
const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(conn));
|
|
127
|
+
await (stub as unknown as ConnectionRpc).disconnect(reason);
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
async sendToNick(
|
|
@@ -232,29 +234,37 @@ export class CfRuntime implements IrcRuntime {
|
|
|
232
234
|
}
|
|
233
235
|
|
|
234
236
|
async getConnection(conn: ConnId): Promise<ConnectionState | null> {
|
|
235
|
-
// CF stores ConnectionState inside each ConnectionDO; the only
|
|
236
|
-
// authoritative view this runtime can hand out without a new RPC is
|
|
237
|
-
// the local connection's live state. Remote connections return null
|
|
238
|
-
// until a dedicated `getConnState` RPC lands alongside the broader
|
|
239
|
-
// WHOIS-over-CF work — the actor degrades to "unknown nick" for them.
|
|
240
237
|
if (conn === this.connId) {
|
|
241
238
|
return this.handlers.snapshot() ?? null;
|
|
242
239
|
}
|
|
243
|
-
|
|
240
|
+
const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(conn));
|
|
241
|
+
const dto = await (stub as unknown as ConnectionRpc).getConnState();
|
|
242
|
+
return dto === null ? null : deserialize(dto);
|
|
244
243
|
}
|
|
245
244
|
|
|
246
|
-
async getChannelConnections(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
245
|
+
async getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
|
|
246
|
+
const memberIds = await this.channelRpc(chan).listMembers();
|
|
247
|
+
const out = new Map<ConnId, ConnectionState>();
|
|
248
|
+
await Promise.all(
|
|
249
|
+
memberIds.map(async (id) => {
|
|
250
|
+
const connStub = this.env.CONNECTION_DO.get(
|
|
251
|
+
this.env.CONNECTION_DO.idFromString(id),
|
|
252
|
+
);
|
|
253
|
+
const dto = await (connStub as unknown as ConnectionRpc).getConnState();
|
|
254
|
+
if (dto !== null) {
|
|
255
|
+
out.set(id, deserialize(dto));
|
|
256
|
+
}
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
return out;
|
|
251
260
|
}
|
|
252
261
|
|
|
253
262
|
async listChannels(): Promise<ChanSnapshot[]> {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
263
|
+
const names = await this.channelRegistryRpc().list();
|
|
264
|
+
const snapshots = await Promise.all(
|
|
265
|
+
names.map((nameLower) => this.getChannelSnapshot(nameLower)),
|
|
266
|
+
);
|
|
267
|
+
return snapshots.filter((s): s is ChanSnapshot => s !== null);
|
|
258
268
|
}
|
|
259
269
|
|
|
260
270
|
// -------------------------------------------------------------------------
|
|
@@ -278,6 +288,7 @@ export class CfRuntime implements IrcRuntime {
|
|
|
278
288
|
broadcast(lines: unknown[], except?: string): Promise<void>;
|
|
279
289
|
applyChannelDelta(delta: unknown): Promise<void>;
|
|
280
290
|
getChannelSnapshot(): Promise<unknown>;
|
|
291
|
+
listMembers(): Promise<string[]>;
|
|
281
292
|
} {
|
|
282
293
|
const lower = name.toLowerCase();
|
|
283
294
|
const stub = this.env.CHANNEL_DO.get(this.env.CHANNEL_DO.idFromName(lower));
|
|
@@ -285,8 +296,17 @@ export class CfRuntime implements IrcRuntime {
|
|
|
285
296
|
broadcast(lines: unknown[], except?: string): Promise<void>;
|
|
286
297
|
applyChannelDelta(delta: unknown): Promise<void>;
|
|
287
298
|
getChannelSnapshot(): Promise<unknown>;
|
|
299
|
+
listMembers(): Promise<string[]>;
|
|
288
300
|
};
|
|
289
301
|
}
|
|
302
|
+
|
|
303
|
+
/** Returns the channel registry DO RPC stub. */
|
|
304
|
+
private channelRegistryRpc(): ChannelRegistryRpc {
|
|
305
|
+
const stub = this.env.CHANNEL_REGISTRY_DO.get(
|
|
306
|
+
this.env.CHANNEL_REGISTRY_DO.idFromName('channels'),
|
|
307
|
+
);
|
|
308
|
+
return stub as unknown as ChannelRegistryRpc;
|
|
309
|
+
}
|
|
290
310
|
}
|
|
291
311
|
|
|
292
312
|
/**
|