serverless-ircd 0.2.0 → 0.4.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 +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +258 -18
- package/README.md +72 -30
- package/apps/aws-stack/README.md +2 -2
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +118 -6
- package/apps/aws-stack/tests/stack.test.ts +98 -3
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -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 +11 -1
- package/apps/cf-worker/wrangler.toml +69 -19
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +11 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +46 -3
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- package/package.json +19 -16
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +124 -34
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +62 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +43 -2
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +120 -9
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +13 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
- package/packages/aws-adapter/tests/account-store.test.ts +279 -0
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +194 -47
- package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +40 -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 +62 -0
- package/packages/cf-adapter/src/connection-do.ts +181 -31
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +58 -8
- package/packages/cf-adapter/src/index.ts +11 -9
- package/packages/cf-adapter/tests/cf-harness.ts +11 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/worker/main.ts +9 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +8 -2
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/caps/capabilities.ts +23 -2
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +11 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/ison.ts +61 -0
- 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/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +26 -19
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -0
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +84 -3
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +6 -29
- package/packages/irc-core/src/index.ts +3 -0
- package/packages/irc-core/src/ports.ts +240 -7
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +60 -1
- package/packages/irc-core/tests/account-store.test.ts +131 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -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/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +256 -19
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -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/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +170 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/effects.test.ts +0 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +12 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- package/packages/irc-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +182 -14
- package/packages/irc-server/src/dispatch.ts +0 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +21 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +617 -1
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +7 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +132 -2
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +9 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +79 -0
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- 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/docs/AWS-Adapter-Architecture.md +0 -494
- package/docs/AWS-Deployment.md +0 -1107
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -1
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -440
- 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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `handleConnect` that exercise the DynamoDB plumbing
|
|
3
|
+
* branches the integration suite cannot reach cheaply — namely the
|
|
4
|
+
* paginated `Scan COUNT` loop (a real DynamoDB Local table fits in a
|
|
5
|
+
* single page) and the `Count ?? 0` defensive fallback. A hand-rolled
|
|
6
|
+
* stub client stands in for `DynamoDBDocumentClient`; the admission
|
|
7
|
+
* policy itself is covered by `admission.test.ts`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
11
|
+
import { DEFAULT_SERVER_CONFIG } from '@serverless-ircd/irc-core';
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
import { handleConnect } from '../src/handlers/connect.js';
|
|
14
|
+
import type { TablesConfig } from '../src/tables.js';
|
|
15
|
+
|
|
16
|
+
const TABLES: TablesConfig = {
|
|
17
|
+
Connections: 'Connections',
|
|
18
|
+
ChannelMeta: 'ChannelMeta',
|
|
19
|
+
ChannelMembers: 'ChannelMembers',
|
|
20
|
+
Nicks: 'Nicks',
|
|
21
|
+
Accounts: 'Accounts',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
interface Stub {
|
|
25
|
+
dynamo: DynamoDBDocumentClient;
|
|
26
|
+
puts: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Builds a stub client that serves the supplied `Scan` pages in order. */
|
|
30
|
+
function makeStub(pages: Array<Record<string, unknown>>): Stub {
|
|
31
|
+
let scanIdx = 0;
|
|
32
|
+
const calls = { puts: 0 };
|
|
33
|
+
const dynamo = {
|
|
34
|
+
send: async (cmd: unknown) => {
|
|
35
|
+
if (cmd instanceof ScanCommand) {
|
|
36
|
+
return pages[scanIdx++] ?? { Count: 0 };
|
|
37
|
+
}
|
|
38
|
+
if (cmd instanceof PutCommand) {
|
|
39
|
+
calls.puts++;
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
throw new Error('unexpected command');
|
|
43
|
+
},
|
|
44
|
+
} as unknown as DynamoDBDocumentClient;
|
|
45
|
+
return {
|
|
46
|
+
dynamo,
|
|
47
|
+
get puts() {
|
|
48
|
+
return calls.puts;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('handleConnect admission plumbing', () => {
|
|
54
|
+
it('sums Scan COUNT across pages and admits (writes the row) when below the cap', async () => {
|
|
55
|
+
const stub = makeStub([{ Count: 1, LastEvaluatedKey: { connectionId: 'page-1' } }, {}]);
|
|
56
|
+
const outcome = await handleConnect({
|
|
57
|
+
dynamo: stub.dynamo,
|
|
58
|
+
tables: TABLES,
|
|
59
|
+
connectionId: 'c-paged',
|
|
60
|
+
now: 42,
|
|
61
|
+
serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
|
|
62
|
+
});
|
|
63
|
+
expect(outcome).toEqual({ admitted: true });
|
|
64
|
+
expect(stub.puts).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does not write a row when the count reaches the cap', async () => {
|
|
68
|
+
const stub = makeStub([{ Count: 5 }]);
|
|
69
|
+
const outcome = await handleConnect({
|
|
70
|
+
dynamo: stub.dynamo,
|
|
71
|
+
tables: TABLES,
|
|
72
|
+
connectionId: 'c-full',
|
|
73
|
+
serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
|
|
74
|
+
});
|
|
75
|
+
expect(outcome).toEqual({ admitted: false, statusCode: 429, reason: 'server full' });
|
|
76
|
+
expect(stub.puts).toBe(0);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,343 @@
|
|
|
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, type PostToConnection, cleanupConnection } from '../src/aws-runtime.js';
|
|
23
|
+
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
24
|
+
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
25
|
+
import { handleDisconnect } from '../src/handlers/disconnect.js';
|
|
26
|
+
import { type HandlerDeps, __resetHandlerCacheForTests, dispatch } from '../src/handlers/index.js';
|
|
27
|
+
import { marshalChannelMember, marshalConnection, marshalNick } from '../src/serialize.js';
|
|
28
|
+
import type { TablesConfig } from '../src/tables.js';
|
|
29
|
+
|
|
30
|
+
const available = process.env.DDB_AVAILABLE === '1';
|
|
31
|
+
const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
32
|
+
|
|
33
|
+
const SERVER_CONFIG: ParsedServerConfig = {
|
|
34
|
+
...DEFAULT_SERVER_CONFIG,
|
|
35
|
+
serverName: 'irc.example.com',
|
|
36
|
+
networkName: 'ExampleNet',
|
|
37
|
+
motdLines: [],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
interface Fixture {
|
|
41
|
+
tables: TablesConfig;
|
|
42
|
+
client: ReturnType<typeof createDynamoDocumentClient>;
|
|
43
|
+
cleanup: () => Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let fx: Fixture | null;
|
|
47
|
+
|
|
48
|
+
function requireFx(): Fixture {
|
|
49
|
+
if (!fx) throw new Error('test fixture not initialized');
|
|
50
|
+
return fx;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function makeFixture(): Promise<Fixture> {
|
|
54
|
+
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
55
|
+
const prefix = `q${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
|
|
56
|
+
const tables = Object.fromEntries(
|
|
57
|
+
Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
|
|
58
|
+
) as TablesConfig;
|
|
59
|
+
const client = createDynamoDocumentClient({ endpoint });
|
|
60
|
+
for (const [logical, props] of Object.entries(TABLE_DEFS)) {
|
|
61
|
+
const pkName = props.partitionKey?.name;
|
|
62
|
+
const skName = props.sortKey?.name;
|
|
63
|
+
if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
|
|
64
|
+
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
|
|
65
|
+
{ AttributeName: pkName, AttributeType: 'S' },
|
|
66
|
+
];
|
|
67
|
+
const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
68
|
+
{ AttributeName: pkName, KeyType: 'HASH' },
|
|
69
|
+
];
|
|
70
|
+
if (skName !== undefined) {
|
|
71
|
+
attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
|
|
72
|
+
keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
|
|
73
|
+
}
|
|
74
|
+
await client.send(
|
|
75
|
+
new CreateTableCommand({
|
|
76
|
+
TableName: `${prefix}${logical}`,
|
|
77
|
+
AttributeDefinitions: attributeDefinitions,
|
|
78
|
+
KeySchema: keySchema,
|
|
79
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
tables,
|
|
85
|
+
client,
|
|
86
|
+
cleanup: async () => {
|
|
87
|
+
for (const logical of Object.keys(TABLE_DEFS)) {
|
|
88
|
+
try {
|
|
89
|
+
await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
|
|
90
|
+
} catch {
|
|
91
|
+
// best-effort
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Seed a registered connection with nick/user/host and a channel membership. */
|
|
99
|
+
async function seedConnection(
|
|
100
|
+
client: ReturnType<typeof createDynamoDocumentClient>,
|
|
101
|
+
tables: TablesConfig,
|
|
102
|
+
connId: string,
|
|
103
|
+
nick: string,
|
|
104
|
+
channel: string,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
const state = createConnection({ id: connId, connectedSince: now });
|
|
108
|
+
state.registration = 'registered';
|
|
109
|
+
state.nick = nick as Nick;
|
|
110
|
+
state.user = nick;
|
|
111
|
+
state.host = 'x';
|
|
112
|
+
state.realname = nick;
|
|
113
|
+
state.joinedChannels = new Set([channel as never]);
|
|
114
|
+
state.lastSeen = now;
|
|
115
|
+
await client.send(
|
|
116
|
+
new PutCommand({ TableName: tables.Connections, Item: marshalConnection(state, now) }),
|
|
117
|
+
);
|
|
118
|
+
await client.send(
|
|
119
|
+
new PutCommand({
|
|
120
|
+
TableName: tables.Nicks,
|
|
121
|
+
Item: marshalNick(nick as Nick, connId, now),
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
await client.send(
|
|
125
|
+
new PutCommand({
|
|
126
|
+
TableName: tables.ChannelMembers,
|
|
127
|
+
Item: marshalChannelMember(channel, {
|
|
128
|
+
conn: connId,
|
|
129
|
+
nick: nick as Nick,
|
|
130
|
+
op: false,
|
|
131
|
+
voice: false,
|
|
132
|
+
}),
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Records every `postToConnection` call keyed by ConnectionId. */
|
|
138
|
+
class RecordingPostToConnection implements PostToConnection {
|
|
139
|
+
readonly sent = new Map<string, string[]>();
|
|
140
|
+
private readonly gone = new Set<string>();
|
|
141
|
+
private readonly errorPeers = new Map<string, Error>();
|
|
142
|
+
async postToConnection(input: { ConnectionId: string; Data: string }): Promise<unknown> {
|
|
143
|
+
const custom = this.errorPeers.get(input.ConnectionId);
|
|
144
|
+
if (custom !== undefined) throw custom;
|
|
145
|
+
if (this.gone.has(input.ConnectionId)) {
|
|
146
|
+
throw new GoneException({ $metadata: {}, message: 'Gone' });
|
|
147
|
+
}
|
|
148
|
+
const list = this.sent.get(input.ConnectionId) ?? [];
|
|
149
|
+
for (const line of input.Data.split('\r\n')) {
|
|
150
|
+
if (line.length > 0) list.push(line);
|
|
151
|
+
}
|
|
152
|
+
this.sent.set(input.ConnectionId, list);
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
markGone(connId: string): void {
|
|
156
|
+
this.gone.add(connId);
|
|
157
|
+
}
|
|
158
|
+
markError(connId: string, err: Error): void {
|
|
159
|
+
this.errorPeers.set(connId, err);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function probe(
|
|
164
|
+
client: ReturnType<typeof createDynamoDocumentClient>,
|
|
165
|
+
tables: TablesConfig,
|
|
166
|
+
): AwsRuntime {
|
|
167
|
+
return new AwsRuntime({
|
|
168
|
+
dynamo: client,
|
|
169
|
+
tables,
|
|
170
|
+
connId: '__probe__',
|
|
171
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
172
|
+
managementApi: null,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
describe.skipIf(!available)('$disconnect QUIT fanout', () => {
|
|
177
|
+
beforeEach(async () => {
|
|
178
|
+
fx = await makeFixture();
|
|
179
|
+
});
|
|
180
|
+
afterEach(async () => {
|
|
181
|
+
if (fx) await fx.cleanup();
|
|
182
|
+
fx = null;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('handleDisconnect fans the QUIT line out to every other channel member', async () => {
|
|
186
|
+
await seedConnection(requireFx().client, requireFx().tables, 'a', 'alice', '#room');
|
|
187
|
+
await seedConnection(requireFx().client, requireFx().tables, 'b', 'bob', '#room');
|
|
188
|
+
|
|
189
|
+
const mgmt = new RecordingPostToConnection();
|
|
190
|
+
await handleDisconnect({
|
|
191
|
+
dynamo: requireFx().client,
|
|
192
|
+
tables: requireFx().tables,
|
|
193
|
+
connectionId: 'a',
|
|
194
|
+
managementApi: mgmt,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Peer b received the QUIT line for alice.
|
|
198
|
+
const bobReceived = mgmt.sent.get('b') ?? [];
|
|
199
|
+
expect(bobReceived).toContain(':alice!alice@x QUIT :Client Quit');
|
|
200
|
+
// The disconnecting connection never receives its own QUIT.
|
|
201
|
+
expect(mgmt.sent.has('a')).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('handleDisconnect without a managementApi performs no fanout but still cleans up', async () => {
|
|
205
|
+
await seedConnection(requireFx().client, requireFx().tables, 'a', 'alice', '#room');
|
|
206
|
+
await handleDisconnect({
|
|
207
|
+
dynamo: requireFx().client,
|
|
208
|
+
tables: requireFx().tables,
|
|
209
|
+
connectionId: 'a',
|
|
210
|
+
managementApi: null,
|
|
211
|
+
});
|
|
212
|
+
const p = probe(requireFx().client, requireFx().tables);
|
|
213
|
+
expect(await p.getConnectionInfo('a')).toBeNull();
|
|
214
|
+
expect(await p.lookupNick('alice' as Nick)).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('a never-registered connection disconnects without fanout (no nick)', async () => {
|
|
218
|
+
const mgmt = new RecordingPostToConnection();
|
|
219
|
+
await handleDisconnect({
|
|
220
|
+
dynamo: requireFx().client,
|
|
221
|
+
tables: requireFx().tables,
|
|
222
|
+
connectionId: 'nobody',
|
|
223
|
+
managementApi: mgmt,
|
|
224
|
+
});
|
|
225
|
+
expect(mgmt.sent.size).toBe(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('fanout to a gone peer triggers lazy cleanup instead of crashing', async () => {
|
|
229
|
+
await seedConnection(requireFx().client, requireFx().tables, 'a', 'alice', '#room');
|
|
230
|
+
await seedConnection(requireFx().client, requireFx().tables, 'b', 'bob', '#room');
|
|
231
|
+
|
|
232
|
+
const mgmt = new RecordingPostToConnection();
|
|
233
|
+
mgmt.markGone('b');
|
|
234
|
+
|
|
235
|
+
await expect(
|
|
236
|
+
handleDisconnect({
|
|
237
|
+
dynamo: requireFx().client,
|
|
238
|
+
tables: requireFx().tables,
|
|
239
|
+
connectionId: 'a',
|
|
240
|
+
managementApi: mgmt,
|
|
241
|
+
}),
|
|
242
|
+
).resolves.toBeUndefined();
|
|
243
|
+
|
|
244
|
+
// The gone peer b was lazily cleaned up (Connection row removed).
|
|
245
|
+
const p = probe(requireFx().client, requireFx().tables);
|
|
246
|
+
expect(await p.getConnectionInfo('b')).toBeNull();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('cleanupConnection directly broadcasts QUIT when given a managementApi', async () => {
|
|
250
|
+
await seedConnection(requireFx().client, requireFx().tables, 'a', 'alice', '#room');
|
|
251
|
+
await seedConnection(requireFx().client, requireFx().tables, 'b', 'bob', '#room');
|
|
252
|
+
|
|
253
|
+
const mgmt = new RecordingPostToConnection();
|
|
254
|
+
await cleanupConnection(requireFx().client, requireFx().tables, 'a', mgmt);
|
|
255
|
+
|
|
256
|
+
const bobReceived = mgmt.sent.get('b') ?? [];
|
|
257
|
+
expect(bobReceived).toContain(':alice!alice@x QUIT :Client Quit');
|
|
258
|
+
const p = probe(requireFx().client, requireFx().tables);
|
|
259
|
+
expect(await p.getConnectionInfo('a')).toBeNull();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('the $disconnect dispatch route fans QUIT out to peers via the management API', async () => {
|
|
263
|
+
const deps: HandlerDeps = {
|
|
264
|
+
dynamo: requireFx().client,
|
|
265
|
+
tables: requireFx().tables,
|
|
266
|
+
serverConfig: SERVER_CONFIG,
|
|
267
|
+
motd: new StaticMotdProvider([]),
|
|
268
|
+
messages: { record() {}, query: () => Promise.resolve([]) } as never,
|
|
269
|
+
managementApi: null,
|
|
270
|
+
};
|
|
271
|
+
// Register alice + bob into the same channel via $default frames.
|
|
272
|
+
const mgmt = new RecordingPostToConnection();
|
|
273
|
+
deps.managementApi = mgmt as never;
|
|
274
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'a' } }, deps);
|
|
275
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'b' } }, deps);
|
|
276
|
+
await dispatch(
|
|
277
|
+
{
|
|
278
|
+
requestContext: { routeKey: '$default', connectionId: 'a' },
|
|
279
|
+
body: 'NICK alice\r\nUSER alice 0 * :Alice\r\nJOIN #room\r\n',
|
|
280
|
+
},
|
|
281
|
+
deps,
|
|
282
|
+
);
|
|
283
|
+
await dispatch(
|
|
284
|
+
{
|
|
285
|
+
requestContext: { routeKey: '$default', connectionId: 'b' },
|
|
286
|
+
body: 'NICK bob\r\nUSER bob 0 * :Bob\r\nJOIN #room\r\n',
|
|
287
|
+
},
|
|
288
|
+
deps,
|
|
289
|
+
);
|
|
290
|
+
mgmt.sent.clear();
|
|
291
|
+
|
|
292
|
+
// alice disconnects.
|
|
293
|
+
await dispatch({ requestContext: { routeKey: '$disconnect', connectionId: 'a' } }, deps);
|
|
294
|
+
|
|
295
|
+
const bobReceived = mgmt.sent.get('b') ?? [];
|
|
296
|
+
expect(bobReceived.some((l) => /^:alice!alice\S* QUIT :/.test(l))).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("the send() GoneException cleanup path also broadcasts QUIT to the gone peer's channels", async () => {
|
|
300
|
+
// alice and carol share #room; bob shares a different channel with carol.
|
|
301
|
+
await seedConnection(requireFx().client, requireFx().tables, 'alice', 'alice', '#room');
|
|
302
|
+
await seedConnection(requireFx().client, requireFx().tables, 'carol', 'carol', '#room');
|
|
303
|
+
|
|
304
|
+
// A management double where alice is "gone" (raises GoneException).
|
|
305
|
+
const mgmt = new RecordingPostToConnection();
|
|
306
|
+
mgmt.markGone('alice');
|
|
307
|
+
|
|
308
|
+
const runtime = new AwsRuntime({
|
|
309
|
+
dynamo: requireFx().client,
|
|
310
|
+
tables: requireFx().tables,
|
|
311
|
+
connId: 'carol',
|
|
312
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
313
|
+
managementApi: mgmt,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// carol sends to alice → GoneException → cleanupConnection(alice)
|
|
317
|
+
// → alice's QUIT fans out to carol (the other #room member).
|
|
318
|
+
await runtime.send('alice', [{ text: ':carol!carol@x PRIVMSG alice :hi' } as never]);
|
|
319
|
+
|
|
320
|
+
const carolReceived = mgmt.sent.get('carol') ?? [];
|
|
321
|
+
expect(carolReceived.some((l) => l.startsWith(':alice!alice@x QUIT :'))).toBe(true);
|
|
322
|
+
// alice was cleaned up.
|
|
323
|
+
const p = probe(requireFx().client, requireFx().tables);
|
|
324
|
+
expect(await p.getConnectionInfo('alice')).toBeNull();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('fanout to a peer that throws a non-Gone error rethrows (no silent swallow)', async () => {
|
|
328
|
+
await seedConnection(requireFx().client, requireFx().tables, 'a', 'alice', '#room');
|
|
329
|
+
await seedConnection(requireFx().client, requireFx().tables, 'b', 'bob', '#room');
|
|
330
|
+
|
|
331
|
+
const mgmt = new RecordingPostToConnection();
|
|
332
|
+
mgmt.markError('b', new Error('transient network failure'));
|
|
333
|
+
|
|
334
|
+
await expect(
|
|
335
|
+
handleDisconnect({
|
|
336
|
+
dynamo: requireFx().client,
|
|
337
|
+
tables: requireFx().tables,
|
|
338
|
+
connectionId: 'a',
|
|
339
|
+
managementApi: mgmt,
|
|
340
|
+
}),
|
|
341
|
+
).rejects.toThrow('transient network failure');
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -42,6 +42,11 @@ interface Fixture {
|
|
|
42
42
|
|
|
43
43
|
let fx: Fixture | null;
|
|
44
44
|
|
|
45
|
+
function requireFx(): Fixture {
|
|
46
|
+
if (!fx) throw new Error('test fixture not initialized');
|
|
47
|
+
return fx;
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
async function makeFixture(): Promise<Fixture> {
|
|
46
51
|
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
47
52
|
const prefix = `g${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
|
|
@@ -136,7 +141,7 @@ describe.skipIf(!available)('GoneException cleanup', () => {
|
|
|
136
141
|
});
|
|
137
142
|
|
|
138
143
|
it('send() to a gone connection triggers cleanupConnection and deletes Connections/Nicks/Members rows', async () => {
|
|
139
|
-
await seedConnection(
|
|
144
|
+
await seedConnection(requireFx().client, requireFx().tables, 'gone', 'bob', '#room');
|
|
140
145
|
|
|
141
146
|
// A self-connection runtime that tries to `send` to 'gone'.
|
|
142
147
|
const mgmt: PostToConnection = {
|
|
@@ -149,8 +154,8 @@ describe.skipIf(!available)('GoneException cleanup', () => {
|
|
|
149
154
|
};
|
|
150
155
|
|
|
151
156
|
const runtime = new AwsRuntime({
|
|
152
|
-
dynamo:
|
|
153
|
-
tables:
|
|
157
|
+
dynamo: requireFx().client,
|
|
158
|
+
tables: requireFx().tables,
|
|
154
159
|
connId: 'self',
|
|
155
160
|
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
156
161
|
managementApi: mgmt,
|
|
@@ -159,8 +164,8 @@ describe.skipIf(!available)('GoneException cleanup', () => {
|
|
|
159
164
|
await runtime.send('gone', [{ text: ':self PRIVMSG bob :hi' } as never]);
|
|
160
165
|
|
|
161
166
|
const probe = new AwsRuntime({
|
|
162
|
-
dynamo:
|
|
163
|
-
tables:
|
|
167
|
+
dynamo: requireFx().client,
|
|
168
|
+
tables: requireFx().tables,
|
|
164
169
|
connId: '__probe__',
|
|
165
170
|
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
166
171
|
managementApi: null,
|
|
@@ -172,15 +177,15 @@ describe.skipIf(!available)('GoneException cleanup', () => {
|
|
|
172
177
|
});
|
|
173
178
|
|
|
174
179
|
it('send() to a non-gone error rethrows', async () => {
|
|
175
|
-
await seedConnection(
|
|
180
|
+
await seedConnection(requireFx().client, requireFx().tables, 'present', 'carol', '#room');
|
|
176
181
|
const mgmt: PostToConnection = {
|
|
177
182
|
async postToConnection() {
|
|
178
183
|
throw new Error('network down');
|
|
179
184
|
},
|
|
180
185
|
};
|
|
181
186
|
const runtime = new AwsRuntime({
|
|
182
|
-
dynamo:
|
|
183
|
-
tables:
|
|
187
|
+
dynamo: requireFx().client,
|
|
188
|
+
tables: requireFx().tables,
|
|
184
189
|
connId: 'self',
|
|
185
190
|
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
186
191
|
managementApi: mgmt,
|
|
@@ -190,8 +195,8 @@ describe.skipIf(!available)('GoneException cleanup', () => {
|
|
|
190
195
|
).rejects.toThrow('network down');
|
|
191
196
|
// The target row should NOT have been cleaned up.
|
|
192
197
|
const probe = new AwsRuntime({
|
|
193
|
-
dynamo:
|
|
194
|
-
tables:
|
|
198
|
+
dynamo: requireFx().client,
|
|
199
|
+
tables: requireFx().tables,
|
|
195
200
|
connId: '__probe__',
|
|
196
201
|
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
197
202
|
managementApi: null,
|
|
@@ -200,12 +205,12 @@ describe.skipIf(!available)('GoneException cleanup', () => {
|
|
|
200
205
|
});
|
|
201
206
|
|
|
202
207
|
it('cleanupConnection is idempotent when called twice', async () => {
|
|
203
|
-
await seedConnection(
|
|
204
|
-
await cleanupConnection(
|
|
205
|
-
await cleanupConnection(
|
|
208
|
+
await seedConnection(requireFx().client, requireFx().tables, 'gone2', 'dave', '#room');
|
|
209
|
+
await cleanupConnection(requireFx().client, requireFx().tables, 'gone2', null);
|
|
210
|
+
await cleanupConnection(requireFx().client, requireFx().tables, 'gone2', null);
|
|
206
211
|
const probe = new AwsRuntime({
|
|
207
|
-
dynamo:
|
|
208
|
-
tables:
|
|
212
|
+
dynamo: requireFx().client,
|
|
213
|
+
tables: requireFx().tables,
|
|
209
214
|
connId: '__probe__',
|
|
210
215
|
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
211
216
|
managementApi: null,
|
|
@@ -215,20 +220,20 @@ describe.skipIf(!available)('GoneException cleanup', () => {
|
|
|
215
220
|
|
|
216
221
|
it('cleanupConnection is safe when the connection row is missing', async () => {
|
|
217
222
|
await expect(
|
|
218
|
-
cleanupConnection(
|
|
223
|
+
cleanupConnection(requireFx().client, requireFx().tables, 'never-existed', null),
|
|
219
224
|
).resolves.toBeUndefined();
|
|
220
225
|
});
|
|
221
226
|
|
|
222
227
|
it('broadcast() skips and cleans up gone members', async () => {
|
|
223
|
-
await seedConnection(
|
|
228
|
+
await seedConnection(requireFx().client, requireFx().tables, 'gone3', 'eve', '#room');
|
|
224
229
|
const mgmt: PostToConnection = {
|
|
225
230
|
async postToConnection() {
|
|
226
231
|
throw new GoneException({ $metadata: {}, message: 'Gone' });
|
|
227
232
|
},
|
|
228
233
|
};
|
|
229
234
|
const runtime = new AwsRuntime({
|
|
230
|
-
dynamo:
|
|
231
|
-
tables:
|
|
235
|
+
dynamo: requireFx().client,
|
|
236
|
+
tables: requireFx().tables,
|
|
232
237
|
connId: 'self',
|
|
233
238
|
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
234
239
|
managementApi: mgmt,
|
|
@@ -236,8 +241,8 @@ describe.skipIf(!available)('GoneException cleanup', () => {
|
|
|
236
241
|
// `broadcast` iterates ChannelMembers and skips `except`.
|
|
237
242
|
await runtime.broadcast('#room' as never, [{ text: ':self PRIVMSG #room :hi' } as never]);
|
|
238
243
|
const probe = new AwsRuntime({
|
|
239
|
-
dynamo:
|
|
240
|
-
tables:
|
|
244
|
+
dynamo: requireFx().client,
|
|
245
|
+
tables: requireFx().tables,
|
|
241
246
|
connId: '__probe__',
|
|
242
247
|
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
243
248
|
managementApi: null,
|
|
@@ -246,18 +251,18 @@ describe.skipIf(!available)('GoneException cleanup', () => {
|
|
|
246
251
|
});
|
|
247
252
|
|
|
248
253
|
it('send() with managementApi=null is a silent no-op (no throw, no cleanup)', async () => {
|
|
249
|
-
await seedConnection(
|
|
254
|
+
await seedConnection(requireFx().client, requireFx().tables, 'present2', 'frank', '#room');
|
|
250
255
|
const runtime = new AwsRuntime({
|
|
251
|
-
dynamo:
|
|
252
|
-
tables:
|
|
256
|
+
dynamo: requireFx().client,
|
|
257
|
+
tables: requireFx().tables,
|
|
253
258
|
connId: 'self',
|
|
254
259
|
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
255
260
|
managementApi: null,
|
|
256
261
|
});
|
|
257
262
|
await runtime.send('present2', [{ text: ':self PRIVMSG frank :hi' } as never]);
|
|
258
263
|
const probe = new AwsRuntime({
|
|
259
|
-
dynamo:
|
|
260
|
-
tables:
|
|
264
|
+
dynamo: requireFx().client,
|
|
265
|
+
tables: requireFx().tables,
|
|
261
266
|
connId: '__probe__',
|
|
262
267
|
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
263
268
|
managementApi: null,
|