serverless-ircd 0.2.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/CHANGELOG.md +84 -0
- package/README.md +28 -19
- 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 +41 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +1 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +28 -2
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- 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 +6 -4
- package/docs/AWS-Deployment.md +86 -7
- package/docs/Cloudflare-Deployment-Guide.md +5 -5
- package/docs/Home.md +11 -1
- package/docs/Release-Process.md +9 -6
- package/package.json +14 -15
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +118 -30
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +30 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/handlers/default.ts +8 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +51 -6
- package/packages/aws-adapter/src/index.ts +7 -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/config-loader.test.ts +55 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/handlers.test.ts +46 -0
- package/packages/cf-adapter/package.json +1 -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 +29 -0
- package/packages/cf-adapter/src/connection-do.ts +85 -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-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +40 -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 +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +3 -2
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/commands/index.ts +2 -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 +4 -3
- 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/registration.ts +8 -7
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +26 -3
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +68 -4
- package/packages/irc-core/src/types.ts +22 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- 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/registration.test.ts +106 -14
- 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 +75 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +24 -0
- package/packages/irc-server/src/routing.ts +9 -0
- package/packages/irc-server/tests/actor.test.ts +220 -1
- package/packages/irc-server/tests/routing.test.ts +3 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
- package/pnpm-workspace.yaml +1 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/AGENTS.md +0 -5
- package/MOTD.txt +0 -3
- package/PLAN-FIXES.md +0 -358
- package/PLAN.md +0 -420
- package/dashboards/cloudwatch-irc.json +0 -139
- package/progress.md +0 -107
- package/tickets.md +0 -2485
- package/webircgateway/LICENSE +0 -201
- package/webircgateway/Makefile +0 -44
- package/webircgateway/README.md +0 -134
- package/webircgateway/config.conf.example +0 -135
- package/webircgateway/go.mod +0 -16
- package/webircgateway/go.sum +0 -89
- package/webircgateway/main.go +0 -118
- package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
- package/webircgateway/pkg/identd/identd.go +0 -86
- package/webircgateway/pkg/identd/rpcclient.go +0 -59
- package/webircgateway/pkg/irc/isupport.go +0 -56
- package/webircgateway/pkg/irc/message.go +0 -217
- package/webircgateway/pkg/irc/state.go +0 -79
- package/webircgateway/pkg/proxy/proxy.go +0 -129
- package/webircgateway/pkg/proxy/server.go +0 -237
- package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
- package/webircgateway/pkg/webircgateway/client.go +0 -741
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
- package/webircgateway/pkg/webircgateway/config.go +0 -385
- package/webircgateway/pkg/webircgateway/gateway.go +0 -278
- package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
- package/webircgateway/pkg/webircgateway/hooks.go +0 -152
- package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
- package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
- package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
- package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
- package/webircgateway/pkg/webircgateway/utils.go +0 -147
- package/webircgateway/plugins/example/plugin.go +0 -11
- package/webircgateway/plugins/stats/plugin.go +0 -52
- package/webircgateway/staticcheck.conf +0 -1
- package/webircgateway/webircgateway.svg +0 -3
|
@@ -75,6 +75,49 @@ describe('buildLambdaConfigInput', () => {
|
|
|
75
75
|
it('passes QUIT_MESSAGE through', () => {
|
|
76
76
|
expect(buildLambdaConfigInput({ QUIT_MESSAGE: 'bye' }).quitMessage).toBe('bye');
|
|
77
77
|
});
|
|
78
|
+
|
|
79
|
+
it('parses SASL_ACCOUNTS newline-delimited username:password pairs', () => {
|
|
80
|
+
const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'alice:secret\nbob:b-pass' });
|
|
81
|
+
expect(out.saslAccounts).toEqual([
|
|
82
|
+
{ username: 'alice', password: 'secret' },
|
|
83
|
+
{ username: 'bob', password: 'b-pass' },
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('omits saslAccounts when SASL_ACCOUNTS is empty', () => {
|
|
88
|
+
expect('saslAccounts' in buildLambdaConfigInput({ SASL_ACCOUNTS: '' })).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('omits saslAccounts when SASL_ACCOUNTS is undefined', () => {
|
|
92
|
+
expect('saslAccounts' in buildLambdaConfigInput({})).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('skips blank lines in SASL_ACCOUNTS (trailing newline tolerance)', () => {
|
|
96
|
+
const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'alice:secret\n\n \n' });
|
|
97
|
+
expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('skips SASL_ACCOUNTS entries with no colon separator', () => {
|
|
101
|
+
const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'nopass\nalice:secret' });
|
|
102
|
+
expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('skips SASL_ACCOUNTS entries with an empty username (leading colon)', () => {
|
|
106
|
+
const out = buildLambdaConfigInput({ SASL_ACCOUNTS: ':onlypass\nalice:secret' });
|
|
107
|
+
expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('skips SASL_ACCOUNTS entries with an empty password', () => {
|
|
111
|
+
const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'alice:\nbob:real' });
|
|
112
|
+
expect(out.saslAccounts).toEqual([{ username: 'bob', password: 'real' }]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('trims whole SASL_ACCOUNTS lines but preserves fields around the colon', () => {
|
|
116
|
+
// Only the line is trimmed; whitespace adjacent to ':' is kept (so a
|
|
117
|
+
// stray space does not silently change the stored username/password).
|
|
118
|
+
const out = buildLambdaConfigInput({ SASL_ACCOUNTS: ' alice:secret ' });
|
|
119
|
+
expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
|
|
120
|
+
});
|
|
78
121
|
});
|
|
79
122
|
|
|
80
123
|
describe('loadServerConfigFromLambdaEnv', () => {
|
|
@@ -155,4 +198,16 @@ describe('loadServerConfigFromLambdaEnv', () => {
|
|
|
155
198
|
});
|
|
156
199
|
expect(cfg.quitMessage).toBe('goodbye');
|
|
157
200
|
});
|
|
201
|
+
|
|
202
|
+
it('round-trips SASL_ACCOUNTS into cfg.saslAccounts end-to-end', () => {
|
|
203
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
204
|
+
SERVER_NAME: 's',
|
|
205
|
+
NETWORK_NAME: 'n',
|
|
206
|
+
SASL_ACCOUNTS: 'alice:secret\nbob:b-pass',
|
|
207
|
+
});
|
|
208
|
+
expect(cfg.saslAccounts).toEqual([
|
|
209
|
+
{ username: 'alice', password: 'secret' },
|
|
210
|
+
{ username: 'bob', password: 'b-pass' },
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
158
213
|
});
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for QUIT fanout on `$disconnect` (and on the
|
|
3
|
+
* `GoneException` cleanup path).
|
|
4
|
+
*
|
|
5
|
+
* Verifies that when connection A tears down, every peer sharing a
|
|
6
|
+
* channel with A receives the canonical `:A QUIT :reason` line via
|
|
7
|
+
* `managementApi.postToConnection`, and that a gone peer during fanout
|
|
8
|
+
* triggers lazy cleanup instead of crashing.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { GoneException } from '@aws-sdk/client-apigatewaymanagementapi';
|
|
12
|
+
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
13
|
+
import { PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_SERVER_CONFIG,
|
|
16
|
+
type Nick,
|
|
17
|
+
type ParsedServerConfig,
|
|
18
|
+
StaticMotdProvider,
|
|
19
|
+
createConnection,
|
|
20
|
+
} from '@serverless-ircd/irc-core';
|
|
21
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
22
|
+
import { AwsRuntime, cleanupConnection, type PostToConnection } from '../src/aws-runtime.js';
|
|
23
|
+
import { handleDisconnect } from '../src/handlers/disconnect.js';
|
|
24
|
+
import {
|
|
25
|
+
type HandlerDeps,
|
|
26
|
+
__resetHandlerCacheForTests,
|
|
27
|
+
dispatch,
|
|
28
|
+
} from '../src/handlers/index.js';
|
|
29
|
+
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
30
|
+
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
31
|
+
import { marshalChannelMember, marshalConnection, marshalNick } from '../src/serialize.js';
|
|
32
|
+
import type { TablesConfig } from '../src/tables.js';
|
|
33
|
+
|
|
34
|
+
const available = process.env.DDB_AVAILABLE === '1';
|
|
35
|
+
const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
36
|
+
|
|
37
|
+
const SERVER_CONFIG: ParsedServerConfig = {
|
|
38
|
+
...DEFAULT_SERVER_CONFIG,
|
|
39
|
+
serverName: 'irc.example.com',
|
|
40
|
+
networkName: 'ExampleNet',
|
|
41
|
+
motdLines: [],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
interface Fixture {
|
|
45
|
+
tables: TablesConfig;
|
|
46
|
+
client: ReturnType<typeof createDynamoDocumentClient>;
|
|
47
|
+
cleanup: () => Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let fx: Fixture | null;
|
|
51
|
+
|
|
52
|
+
async function makeFixture(): Promise<Fixture> {
|
|
53
|
+
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
54
|
+
const prefix = `q${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
|
|
55
|
+
const tables = Object.fromEntries(
|
|
56
|
+
Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
|
|
57
|
+
) as TablesConfig;
|
|
58
|
+
const client = createDynamoDocumentClient({ endpoint });
|
|
59
|
+
for (const [logical, props] of Object.entries(TABLE_DEFS)) {
|
|
60
|
+
const pkName = props.partitionKey?.name;
|
|
61
|
+
const skName = props.sortKey?.name;
|
|
62
|
+
if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
|
|
63
|
+
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
|
|
64
|
+
{ AttributeName: pkName, AttributeType: 'S' },
|
|
65
|
+
];
|
|
66
|
+
const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
67
|
+
{ AttributeName: pkName, KeyType: 'HASH' },
|
|
68
|
+
];
|
|
69
|
+
if (skName !== undefined) {
|
|
70
|
+
attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
|
|
71
|
+
keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
|
|
72
|
+
}
|
|
73
|
+
await client.send(
|
|
74
|
+
new CreateTableCommand({
|
|
75
|
+
TableName: `${prefix}${logical}`,
|
|
76
|
+
AttributeDefinitions: attributeDefinitions,
|
|
77
|
+
KeySchema: keySchema,
|
|
78
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
tables,
|
|
84
|
+
client,
|
|
85
|
+
cleanup: async () => {
|
|
86
|
+
for (const logical of Object.keys(TABLE_DEFS)) {
|
|
87
|
+
try {
|
|
88
|
+
await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
|
|
89
|
+
} catch {
|
|
90
|
+
// best-effort
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Seed a registered connection with nick/user/host and a channel membership. */
|
|
98
|
+
async function seedConnection(
|
|
99
|
+
client: ReturnType<typeof createDynamoDocumentClient>,
|
|
100
|
+
tables: TablesConfig,
|
|
101
|
+
connId: string,
|
|
102
|
+
nick: string,
|
|
103
|
+
channel: string,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const state = createConnection({ id: connId, connectedSince: now });
|
|
107
|
+
state.registration = 'registered';
|
|
108
|
+
state.nick = nick as Nick;
|
|
109
|
+
state.user = nick;
|
|
110
|
+
state.host = 'x';
|
|
111
|
+
state.realname = nick;
|
|
112
|
+
state.joinedChannels = new Set([channel as never]);
|
|
113
|
+
state.lastSeen = now;
|
|
114
|
+
await client.send(
|
|
115
|
+
new PutCommand({ TableName: tables.Connections, Item: marshalConnection(state, now) }),
|
|
116
|
+
);
|
|
117
|
+
await client.send(
|
|
118
|
+
new PutCommand({
|
|
119
|
+
TableName: tables.Nicks,
|
|
120
|
+
Item: marshalNick(nick as Nick, connId, now),
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
await client.send(
|
|
124
|
+
new PutCommand({
|
|
125
|
+
TableName: tables.ChannelMembers,
|
|
126
|
+
Item: marshalChannelMember(channel, {
|
|
127
|
+
conn: connId,
|
|
128
|
+
nick: nick as Nick,
|
|
129
|
+
op: false,
|
|
130
|
+
voice: false,
|
|
131
|
+
}),
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Records every `postToConnection` call keyed by ConnectionId. */
|
|
137
|
+
class RecordingPostToConnection implements PostToConnection {
|
|
138
|
+
readonly sent = new Map<string, string[]>();
|
|
139
|
+
private readonly gone = new Set<string>();
|
|
140
|
+
private readonly errorPeers = new Map<string, Error>();
|
|
141
|
+
async postToConnection(input: { ConnectionId: string; Data: string }): Promise<unknown> {
|
|
142
|
+
const custom = this.errorPeers.get(input.ConnectionId);
|
|
143
|
+
if (custom !== undefined) throw custom;
|
|
144
|
+
if (this.gone.has(input.ConnectionId)) {
|
|
145
|
+
throw new GoneException({ $metadata: {}, message: 'Gone' });
|
|
146
|
+
}
|
|
147
|
+
const list = this.sent.get(input.ConnectionId) ?? [];
|
|
148
|
+
for (const line of input.Data.split('\r\n')) {
|
|
149
|
+
if (line.length > 0) list.push(line);
|
|
150
|
+
}
|
|
151
|
+
this.sent.set(input.ConnectionId, list);
|
|
152
|
+
return {};
|
|
153
|
+
}
|
|
154
|
+
markGone(connId: string): void {
|
|
155
|
+
this.gone.add(connId);
|
|
156
|
+
}
|
|
157
|
+
markError(connId: string, err: Error): void {
|
|
158
|
+
this.errorPeers.set(connId, err);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function probe(
|
|
163
|
+
client: ReturnType<typeof createDynamoDocumentClient>,
|
|
164
|
+
tables: TablesConfig,
|
|
165
|
+
): AwsRuntime {
|
|
166
|
+
return new AwsRuntime({
|
|
167
|
+
dynamo: client,
|
|
168
|
+
tables,
|
|
169
|
+
connId: '__probe__',
|
|
170
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
171
|
+
managementApi: null,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
describe.skipIf(!available)('$disconnect QUIT fanout', () => {
|
|
176
|
+
beforeEach(async () => {
|
|
177
|
+
fx = await makeFixture();
|
|
178
|
+
});
|
|
179
|
+
afterEach(async () => {
|
|
180
|
+
if (fx) await fx.cleanup();
|
|
181
|
+
fx = null;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('handleDisconnect fans the QUIT line out to every other channel member', async () => {
|
|
185
|
+
await seedConnection(fx!.client, fx!.tables, 'a', 'alice', '#room');
|
|
186
|
+
await seedConnection(fx!.client, fx!.tables, 'b', 'bob', '#room');
|
|
187
|
+
|
|
188
|
+
const mgmt = new RecordingPostToConnection();
|
|
189
|
+
await handleDisconnect({
|
|
190
|
+
dynamo: fx!.client,
|
|
191
|
+
tables: fx!.tables,
|
|
192
|
+
connectionId: 'a',
|
|
193
|
+
managementApi: mgmt,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Peer b received the QUIT line for alice.
|
|
197
|
+
const bobReceived = mgmt.sent.get('b') ?? [];
|
|
198
|
+
expect(bobReceived).toContain(':alice!alice@x QUIT :Client Quit');
|
|
199
|
+
// The disconnecting connection never receives its own QUIT.
|
|
200
|
+
expect(mgmt.sent.has('a')).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('handleDisconnect without a managementApi performs no fanout but still cleans up', async () => {
|
|
204
|
+
await seedConnection(fx!.client, fx!.tables, 'a', 'alice', '#room');
|
|
205
|
+
await handleDisconnect({
|
|
206
|
+
dynamo: fx!.client,
|
|
207
|
+
tables: fx!.tables,
|
|
208
|
+
connectionId: 'a',
|
|
209
|
+
managementApi: null,
|
|
210
|
+
});
|
|
211
|
+
const p = probe(fx!.client, fx!.tables);
|
|
212
|
+
expect(await p.getConnectionInfo('a')).toBeNull();
|
|
213
|
+
expect(await p.lookupNick('alice' as Nick)).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('a never-registered connection disconnects without fanout (no nick)', async () => {
|
|
217
|
+
const mgmt = new RecordingPostToConnection();
|
|
218
|
+
await handleDisconnect({
|
|
219
|
+
dynamo: fx!.client,
|
|
220
|
+
tables: fx!.tables,
|
|
221
|
+
connectionId: 'nobody',
|
|
222
|
+
managementApi: mgmt,
|
|
223
|
+
});
|
|
224
|
+
expect(mgmt.sent.size).toBe(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('fanout to a gone peer triggers lazy cleanup instead of crashing', async () => {
|
|
228
|
+
await seedConnection(fx!.client, fx!.tables, 'a', 'alice', '#room');
|
|
229
|
+
await seedConnection(fx!.client, fx!.tables, 'b', 'bob', '#room');
|
|
230
|
+
|
|
231
|
+
const mgmt = new RecordingPostToConnection();
|
|
232
|
+
mgmt.markGone('b');
|
|
233
|
+
|
|
234
|
+
await expect(
|
|
235
|
+
handleDisconnect({
|
|
236
|
+
dynamo: fx!.client,
|
|
237
|
+
tables: fx!.tables,
|
|
238
|
+
connectionId: 'a',
|
|
239
|
+
managementApi: mgmt,
|
|
240
|
+
}),
|
|
241
|
+
).resolves.toBeUndefined();
|
|
242
|
+
|
|
243
|
+
// The gone peer b was lazily cleaned up (Connection row removed).
|
|
244
|
+
const p = probe(fx!.client, fx!.tables);
|
|
245
|
+
expect(await p.getConnectionInfo('b')).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('cleanupConnection directly broadcasts QUIT when given a managementApi', async () => {
|
|
249
|
+
await seedConnection(fx!.client, fx!.tables, 'a', 'alice', '#room');
|
|
250
|
+
await seedConnection(fx!.client, fx!.tables, 'b', 'bob', '#room');
|
|
251
|
+
|
|
252
|
+
const mgmt = new RecordingPostToConnection();
|
|
253
|
+
await cleanupConnection(fx!.client, fx!.tables, 'a', mgmt);
|
|
254
|
+
|
|
255
|
+
const bobReceived = mgmt.sent.get('b') ?? [];
|
|
256
|
+
expect(bobReceived).toContain(':alice!alice@x QUIT :Client Quit');
|
|
257
|
+
const p = probe(fx!.client, fx!.tables);
|
|
258
|
+
expect(await p.getConnectionInfo('a')).toBeNull();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('the $disconnect dispatch route fans QUIT out to peers via the management API', async () => {
|
|
262
|
+
const deps: HandlerDeps = {
|
|
263
|
+
dynamo: fx!.client,
|
|
264
|
+
tables: fx!.tables,
|
|
265
|
+
serverConfig: SERVER_CONFIG,
|
|
266
|
+
motd: new StaticMotdProvider([]),
|
|
267
|
+
messages: { record() {}, query: () => Promise.resolve([]) } as never,
|
|
268
|
+
managementApi: null,
|
|
269
|
+
};
|
|
270
|
+
// Register alice + bob into the same channel via $default frames.
|
|
271
|
+
const mgmt = new RecordingPostToConnection();
|
|
272
|
+
deps.managementApi = mgmt as never;
|
|
273
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'a' } }, deps);
|
|
274
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'b' } }, deps);
|
|
275
|
+
await dispatch(
|
|
276
|
+
{ requestContext: { routeKey: '$default', connectionId: 'a' }, body: 'NICK alice\r\nUSER alice 0 * :Alice\r\nJOIN #room\r\n' },
|
|
277
|
+
deps,
|
|
278
|
+
);
|
|
279
|
+
await dispatch(
|
|
280
|
+
{ requestContext: { routeKey: '$default', connectionId: 'b' }, body: 'NICK bob\r\nUSER bob 0 * :Bob\r\nJOIN #room\r\n' },
|
|
281
|
+
deps,
|
|
282
|
+
);
|
|
283
|
+
mgmt.sent.clear();
|
|
284
|
+
|
|
285
|
+
// alice disconnects.
|
|
286
|
+
await dispatch({ requestContext: { routeKey: '$disconnect', connectionId: 'a' } }, deps);
|
|
287
|
+
|
|
288
|
+
const bobReceived = mgmt.sent.get('b') ?? [];
|
|
289
|
+
expect(bobReceived.some((l) => /^:alice!alice\S* QUIT :/.test(l))).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('the send() GoneException cleanup path also broadcasts QUIT to the gone peer\'s channels', async () => {
|
|
293
|
+
// alice and carol share #room; bob shares a different channel with carol.
|
|
294
|
+
await seedConnection(fx!.client, fx!.tables, 'alice', 'alice', '#room');
|
|
295
|
+
await seedConnection(fx!.client, fx!.tables, 'carol', 'carol', '#room');
|
|
296
|
+
|
|
297
|
+
// A management double where alice is "gone" (raises GoneException).
|
|
298
|
+
const mgmt = new RecordingPostToConnection();
|
|
299
|
+
mgmt.markGone('alice');
|
|
300
|
+
|
|
301
|
+
const runtime = new AwsRuntime({
|
|
302
|
+
dynamo: fx!.client,
|
|
303
|
+
tables: fx!.tables,
|
|
304
|
+
connId: 'carol',
|
|
305
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
306
|
+
managementApi: mgmt,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// carol sends to alice → GoneException → cleanupConnection(alice)
|
|
310
|
+
// → alice's QUIT fans out to carol (the other #room member).
|
|
311
|
+
await runtime.send('alice', [{ text: ':carol!carol@x PRIVMSG alice :hi' } as never]);
|
|
312
|
+
|
|
313
|
+
const carolReceived = mgmt.sent.get('carol') ?? [];
|
|
314
|
+
expect(carolReceived.some((l) => l.startsWith(':alice!alice@x QUIT :'))).toBe(true);
|
|
315
|
+
// alice was cleaned up.
|
|
316
|
+
const p = probe(fx!.client, fx!.tables);
|
|
317
|
+
expect(await p.getConnectionInfo('alice')).toBeNull();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('fanout to a peer that throws a non-Gone error rethrows (no silent swallow)', async () => {
|
|
321
|
+
await seedConnection(fx!.client, fx!.tables, 'a', 'alice', '#room');
|
|
322
|
+
await seedConnection(fx!.client, fx!.tables, 'b', 'bob', '#room');
|
|
323
|
+
|
|
324
|
+
const mgmt = new RecordingPostToConnection();
|
|
325
|
+
mgmt.markError('b', new Error('transient network failure'));
|
|
326
|
+
|
|
327
|
+
await expect(
|
|
328
|
+
handleDisconnect({
|
|
329
|
+
dynamo: fx!.client,
|
|
330
|
+
tables: fx!.tables,
|
|
331
|
+
connectionId: 'a',
|
|
332
|
+
managementApi: mgmt,
|
|
333
|
+
}),
|
|
334
|
+
).rejects.toThrow('transient network failure');
|
|
335
|
+
});
|
|
336
|
+
});
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
10
10
|
import {
|
|
11
11
|
DEFAULT_SERVER_CONFIG,
|
|
12
|
+
InMemoryAccountStore,
|
|
12
13
|
type ParsedServerConfig,
|
|
13
14
|
StaticMotdProvider,
|
|
14
15
|
} from '@serverless-ircd/irc-core';
|
|
@@ -383,6 +384,51 @@ describe.skipIf(!available)('handlers', () => {
|
|
|
383
384
|
expect(replay).toBeDefined();
|
|
384
385
|
});
|
|
385
386
|
|
|
387
|
+
it('completes SASL PLAIN with 900/903 when an AccountStore is bound', async () => {
|
|
388
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
389
|
+
const sink: string[] = [];
|
|
390
|
+
fx!.mgmt.register('c1', (data) => {
|
|
391
|
+
for (const line of data.split('\r\n')) {
|
|
392
|
+
if (line.length > 0) sink.push(line);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
// Swap in a deps with a seeded AccountStore for this dispatch.
|
|
396
|
+
const depsWithAccounts: HandlerDeps = {
|
|
397
|
+
...fx!.deps,
|
|
398
|
+
accounts: new InMemoryAccountStore([{ username: 'aws-sasl', password: 's3cret' }]),
|
|
399
|
+
};
|
|
400
|
+
const payload = Buffer.from('\0aws-sasl\0s3cret', 'utf8').toString('base64');
|
|
401
|
+
await dispatch(
|
|
402
|
+
{
|
|
403
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
404
|
+
body: `CAP REQ :sasl\r\nAUTHENTICATE PLAIN\r\nAUTHENTICATE ${payload}\r\n`,
|
|
405
|
+
},
|
|
406
|
+
depsWithAccounts,
|
|
407
|
+
);
|
|
408
|
+
expect(sink.some((l) => l.includes(' 900 '))).toBe(true);
|
|
409
|
+
expect(sink.some((l) => l.includes(' 903 '))).toBe(true);
|
|
410
|
+
expect(sink.some((l) => l.includes(' 904 '))).toBe(false);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('emits 904 when no AccountStore is bound (regression guard)', async () => {
|
|
414
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c2' } }, fx!.deps);
|
|
415
|
+
const sink: string[] = [];
|
|
416
|
+
fx!.mgmt.register('c2', (data) => {
|
|
417
|
+
for (const line of data.split('\r\n')) {
|
|
418
|
+
if (line.length > 0) sink.push(line);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
const payload = Buffer.from('\0aws-sasl\0s3cret', 'utf8').toString('base64');
|
|
422
|
+
await dispatch(
|
|
423
|
+
{
|
|
424
|
+
requestContext: { routeKey: '$default', connectionId: 'c2' },
|
|
425
|
+
body: `CAP REQ :sasl\r\nAUTHENTICATE PLAIN\r\nAUTHENTICATE ${payload}\r\n`,
|
|
426
|
+
},
|
|
427
|
+
fx!.deps,
|
|
428
|
+
);
|
|
429
|
+
expect(sink.some((l) => l.includes(' 904 '))).toBe(true);
|
|
430
|
+
});
|
|
431
|
+
|
|
386
432
|
it('returns 500 when unmarshalConnection throws on a corrupted row (caught by Lambda)', async () => {
|
|
387
433
|
// Skip: the catch in handleDefault wraps `actor.receiveTextFrame`,
|
|
388
434
|
// not `unmarshalConnection`. A corrupted-row test would exercise
|
|
@@ -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",
|
|
@@ -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
|
/**
|
|
@@ -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
|
// -------------------------------------------------------------------------
|