serverless-ircd 0.1.0 → 0.2.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/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- 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/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -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 +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -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 +67 -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/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 +158 -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 +427 -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/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- 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/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -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/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 +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -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 +66 -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/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -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/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -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 +162 -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 +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -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 +1 -1
- 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/progress.md +107 -0
- package/tickets.md +2485 -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/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/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -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,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the AWS adapter's chat-history store binding helper.
|
|
3
|
+
*
|
|
4
|
+
* The helper centralises MessageStore construction so a future
|
|
5
|
+
* DynamoDB-backed persistent variant can replace the in-memory v1 impl
|
|
6
|
+
* at a single site. These tests prove the helper returns a functional
|
|
7
|
+
* store (record → query round-trip) without needing DynamoDB Local.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { DEFAULT_SERVER_CONFIG, type StoredMessage } from '@serverless-ircd/irc-core';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { bindMessageStore } from '../src/message-store.js';
|
|
13
|
+
|
|
14
|
+
function sampleMessage(chan: string, text: string): StoredMessage {
|
|
15
|
+
return {
|
|
16
|
+
msgid: 'm1',
|
|
17
|
+
time: 1_000,
|
|
18
|
+
chan: chan.toLowerCase(),
|
|
19
|
+
command: 'PRIVMSG',
|
|
20
|
+
nick: 'alice',
|
|
21
|
+
user: 'alice',
|
|
22
|
+
host: 'ex.org',
|
|
23
|
+
text,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('bindMessageStore', () => {
|
|
28
|
+
it('returns a store that records and replays a message via LATEST', () => {
|
|
29
|
+
const store = bindMessageStore(DEFAULT_SERVER_CONFIG);
|
|
30
|
+
store.record(sampleMessage('#aws', 'hello aws'));
|
|
31
|
+
|
|
32
|
+
const result = store.query({ chan: '#aws', direction: 'latest', limit: 10 });
|
|
33
|
+
expect(result).toHaveLength(1);
|
|
34
|
+
expect(result[0]?.text).toBe('hello aws');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns a store scoped per channel (no cross-channel leak)', () => {
|
|
38
|
+
const store = bindMessageStore(DEFAULT_SERVER_CONFIG);
|
|
39
|
+
store.record(sampleMessage('#a', 'in a'));
|
|
40
|
+
store.record(sampleMessage('#b', 'in b'));
|
|
41
|
+
|
|
42
|
+
expect(store.query({ chan: '#a', direction: 'latest', limit: 10 })).toHaveLength(1);
|
|
43
|
+
expect(store.query({ chan: '#b', direction: 'latest', limit: 10 })).toHaveLength(1);
|
|
44
|
+
expect(store.query({ chan: '#c', direction: 'latest', limit: 10 })).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns an independent store instance per call', () => {
|
|
48
|
+
const a = bindMessageStore(DEFAULT_SERVER_CONFIG);
|
|
49
|
+
const b = bindMessageStore(DEFAULT_SERVER_CONFIG);
|
|
50
|
+
a.record(sampleMessage('#aws', 'only in a'));
|
|
51
|
+
|
|
52
|
+
expect(a.query({ chan: '#aws', direction: 'latest', limit: 10 })).toHaveLength(1);
|
|
53
|
+
expect(b.query({ chan: '#aws', direction: 'latest', limit: 10 })).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventBridge idle / PING sweep — a Lambda on a fixed EventBridge
|
|
3
|
+
* schedule (rate(1 minute)) that scans `Connections` for rows past
|
|
4
|
+
* the PING threshold and either posts a `PING` or, if the PONG
|
|
5
|
+
* window has also elapsed, tears the connection down via
|
|
6
|
+
* {@link cleanupConnection}.
|
|
7
|
+
*
|
|
8
|
+
* Catches the case where a client has gone silent but APIGW has not
|
|
9
|
+
* yet observed a TCP-level close (so no `$disconnect` event). Mirrors
|
|
10
|
+
* the Cloudflare adapter's `ConnectionDO.alarm()` PING/PONG logic,
|
|
11
|
+
* adapted to the batch-scan Lambda model AWS requires (one Lambda
|
|
12
|
+
* invocation inspects every connection; per-connection EventBridge
|
|
13
|
+
* one-shot schedules would scale to N scheduler entries per active
|
|
14
|
+
* user and were ruled out for v1).
|
|
15
|
+
*
|
|
16
|
+
* Gated on `process.env.DDB_AVAILABLE` (real DynamoDB Local). A
|
|
17
|
+
* second `describe` block covers scan-edge-case branches against a
|
|
18
|
+
* fake document client so the suite is green even without Docker.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
22
|
+
import {
|
|
23
|
+
type DynamoDBDocumentClient,
|
|
24
|
+
GetCommand,
|
|
25
|
+
PutCommand,
|
|
26
|
+
ScanCommand,
|
|
27
|
+
} from '@aws-sdk/lib-dynamodb';
|
|
28
|
+
import { type ChanName, type Nick, createConnection } from '@serverless-ircd/irc-core';
|
|
29
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
30
|
+
import { cleanupConnection } from '../src/aws-runtime.js';
|
|
31
|
+
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
32
|
+
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
33
|
+
import {
|
|
34
|
+
DEFAULT_PING_INTERVAL_MS,
|
|
35
|
+
DEFAULT_PONG_TIMEOUT_MS,
|
|
36
|
+
type PingCheckParams,
|
|
37
|
+
type PingCheckResult,
|
|
38
|
+
handlePingCheck,
|
|
39
|
+
} from '../src/handlers/ping-checker.js';
|
|
40
|
+
import { marshalChannelMember, marshalConnection, marshalNick } from '../src/serialize.js';
|
|
41
|
+
import type { TablesConfig } from '../src/tables.js';
|
|
42
|
+
|
|
43
|
+
const available = process.env.DDB_AVAILABLE === '1';
|
|
44
|
+
const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
45
|
+
|
|
46
|
+
interface Fixture {
|
|
47
|
+
tables: TablesConfig;
|
|
48
|
+
client: ReturnType<typeof createDynamoDocumentClient>;
|
|
49
|
+
mgmt: RecordingPostToConnection;
|
|
50
|
+
cleanup: () => Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let fx: Fixture | null;
|
|
54
|
+
|
|
55
|
+
async function makeFixture(): Promise<Fixture> {
|
|
56
|
+
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
57
|
+
const prefix = `p${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
|
|
58
|
+
const tables = Object.fromEntries(
|
|
59
|
+
Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
|
|
60
|
+
) as TablesConfig;
|
|
61
|
+
const client = createDynamoDocumentClient({ endpoint });
|
|
62
|
+
for (const [logical, props] of Object.entries(TABLE_DEFS)) {
|
|
63
|
+
const pkName = props.partitionKey?.name;
|
|
64
|
+
const skName = props.sortKey?.name;
|
|
65
|
+
if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
|
|
66
|
+
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
|
|
67
|
+
{ AttributeName: pkName, AttributeType: 'S' },
|
|
68
|
+
];
|
|
69
|
+
const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
70
|
+
{ AttributeName: pkName, KeyType: 'HASH' },
|
|
71
|
+
];
|
|
72
|
+
if (skName !== undefined) {
|
|
73
|
+
attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
|
|
74
|
+
keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
|
|
75
|
+
}
|
|
76
|
+
await client.send(
|
|
77
|
+
new CreateTableCommand({
|
|
78
|
+
TableName: `${prefix}${logical}`,
|
|
79
|
+
AttributeDefinitions: attributeDefinitions,
|
|
80
|
+
KeySchema: keySchema,
|
|
81
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
tables,
|
|
87
|
+
client,
|
|
88
|
+
mgmt: new RecordingPostToConnection(),
|
|
89
|
+
cleanup: async () => {
|
|
90
|
+
for (const logical of Object.keys(TABLE_DEFS)) {
|
|
91
|
+
try {
|
|
92
|
+
await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
|
|
93
|
+
} catch {
|
|
94
|
+
// best-effort
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function params(overrides: Partial<PingCheckParams> = {}): PingCheckParams {
|
|
102
|
+
return {
|
|
103
|
+
dynamo: fx!.client,
|
|
104
|
+
tables: fx!.tables,
|
|
105
|
+
managementApi: fx!.mgmt,
|
|
106
|
+
...overrides,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Seeds a connection row with the supplied idleSince + optional nick + memberships. */
|
|
111
|
+
async function seedConnection(opts: {
|
|
112
|
+
connId: string;
|
|
113
|
+
idleSince: number;
|
|
114
|
+
nick?: string;
|
|
115
|
+
channels?: ChanName[];
|
|
116
|
+
}): Promise<void> {
|
|
117
|
+
const state = createConnection({ id: opts.connId, connectedSince: opts.idleSince });
|
|
118
|
+
state.registration = 'registered';
|
|
119
|
+
state.lastSeen = opts.idleSince;
|
|
120
|
+
if (opts.nick !== undefined) {
|
|
121
|
+
state.nick = opts.nick as Nick;
|
|
122
|
+
state.user = opts.nick;
|
|
123
|
+
await fx!.client.send(
|
|
124
|
+
new PutCommand({
|
|
125
|
+
TableName: fx!.tables.Nicks,
|
|
126
|
+
Item: marshalNick(opts.nick as Nick, opts.connId, opts.idleSince),
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
for (const chan of opts.channels ?? []) {
|
|
131
|
+
state.joinedChannels.add(chan);
|
|
132
|
+
await fx!.client.send(
|
|
133
|
+
new PutCommand({
|
|
134
|
+
TableName: fx!.tables.ChannelMembers,
|
|
135
|
+
Item: marshalChannelMember(chan.toLowerCase(), {
|
|
136
|
+
conn: opts.connId,
|
|
137
|
+
nick: (opts.nick ?? 'x') as Nick,
|
|
138
|
+
op: false,
|
|
139
|
+
voice: false,
|
|
140
|
+
}),
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
await fx!.client.send(
|
|
145
|
+
new PutCommand({
|
|
146
|
+
TableName: fx!.tables.Connections,
|
|
147
|
+
Item: marshalConnection(state, opts.idleSince),
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function connectionExists(connId: string): Promise<boolean> {
|
|
153
|
+
const res = await fx!.client.send(
|
|
154
|
+
new GetCommand({ TableName: fx!.tables.Connections, Key: { connectionId: connId } }),
|
|
155
|
+
);
|
|
156
|
+
return res.Item !== undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function membersOf(channelKey: string): Promise<{ connectionId: string }[]> {
|
|
160
|
+
const { QueryCommand } = await import('@aws-sdk/lib-dynamodb');
|
|
161
|
+
const res = await fx!.client.send(
|
|
162
|
+
new QueryCommand({
|
|
163
|
+
TableName: fx!.tables.ChannelMembers,
|
|
164
|
+
KeyConditionExpression: 'channelName = :k',
|
|
165
|
+
ExpressionAttributeValues: { ':k': channelKey },
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
return (res.Items ?? []) as { connectionId: string }[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function nickExists(nick: string): Promise<boolean> {
|
|
172
|
+
const res = await fx!.client.send(
|
|
173
|
+
new GetCommand({ TableName: fx!.tables.Nicks, Key: { nickLower: nick.toLowerCase() } }),
|
|
174
|
+
);
|
|
175
|
+
return res.Item !== undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
describe.skipIf(!available)('idle / PING checker', () => {
|
|
179
|
+
beforeEach(async () => {
|
|
180
|
+
fx = await makeFixture();
|
|
181
|
+
});
|
|
182
|
+
afterEach(async () => {
|
|
183
|
+
if (fx) await fx.cleanup();
|
|
184
|
+
fx = null;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// -------------------------------------------------------------------------
|
|
188
|
+
// Acceptance criteria
|
|
189
|
+
// -------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
it('sends a PING to an idle connection past the PING interval', async () => {
|
|
192
|
+
const pingInterval = 60_000;
|
|
193
|
+
const now = 100_000;
|
|
194
|
+
await seedConnection({ connId: 'idle', idleSince: 0 }); // idleFor = 100s > 60s
|
|
195
|
+
|
|
196
|
+
const result: PingCheckResult = await handlePingCheck(
|
|
197
|
+
params({ now, pingIntervalMs: pingInterval, pongTimeoutMs: 60_000 }),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(result.scanned).toBe(1);
|
|
201
|
+
expect(result.pinged).toBe(1);
|
|
202
|
+
expect(result.disconnected).toBe(0);
|
|
203
|
+
expect(fx!.mgmt.calls.size).toBe(1);
|
|
204
|
+
expect(fx!.mgmt.calls.has('idle')).toBe(true);
|
|
205
|
+
// PING line format: `PING :<token>` (token is opaque, server-chosen).
|
|
206
|
+
const line = fx!.mgmt.calls.get('idle')!.join('\r\n');
|
|
207
|
+
expect(line).toMatch(/^PING :.+\r?\n?$/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('disconnects a connection that has not answered PING within the PONG window', async () => {
|
|
211
|
+
const pingInterval = 60_000;
|
|
212
|
+
const pongTimeout = 60_000;
|
|
213
|
+
// Idle for 2× interval → no PONG within window → disconnect.
|
|
214
|
+
const now = (pingInterval + pongTimeout) * 2;
|
|
215
|
+
await seedConnection({ connId: 'ghost', idleSince: 0 });
|
|
216
|
+
|
|
217
|
+
const result = await handlePingCheck(
|
|
218
|
+
params({ now, pingIntervalMs: pingInterval, pongTimeoutMs: pongTimeout }),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(result.disconnected).toBe(1);
|
|
222
|
+
expect(await connectionExists('ghost')).toBe(false);
|
|
223
|
+
// No PING is sent in the disconnect window — past it.
|
|
224
|
+
expect(fx!.mgmt.calls.size).toBe(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// -------------------------------------------------------------------------
|
|
228
|
+
// Sibling cases (driven by the TDD outline + AC text)
|
|
229
|
+
// -------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
it('leaves an active connection alone (idleFor < pingInterval)', async () => {
|
|
232
|
+
const now = 1_000;
|
|
233
|
+
await seedConnection({ connId: 'live', idleSince: now }); // idleFor = 0
|
|
234
|
+
|
|
235
|
+
const result = await handlePingCheck(
|
|
236
|
+
params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 }),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
expect(result.scanned).toBe(1);
|
|
240
|
+
expect(result.pinged).toBe(0);
|
|
241
|
+
expect(result.disconnected).toBe(0);
|
|
242
|
+
expect(fx!.mgmt.calls.size).toBe(0);
|
|
243
|
+
expect(await connectionExists('live')).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('disconnect path also removes memberships and releases the nick', async () => {
|
|
247
|
+
const now = 1_000_000;
|
|
248
|
+
await seedConnection({
|
|
249
|
+
connId: 'stale',
|
|
250
|
+
idleSince: 0,
|
|
251
|
+
nick: 'alice',
|
|
252
|
+
channels: ['#foo' as ChanName, '#bar' as ChanName],
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await handlePingCheck(params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 }));
|
|
256
|
+
|
|
257
|
+
expect(await connectionExists('stale')).toBe(false);
|
|
258
|
+
expect(await nickExists('alice')).toBe(false);
|
|
259
|
+
expect(await membersOf('#foo')).toHaveLength(0);
|
|
260
|
+
expect(await membersOf('#bar')).toHaveLength(0);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('treats a PONG-refreshed connection as active (idleSince recent)', async () => {
|
|
264
|
+
// PONG came back recently via $default → idleSince updated → not idle.
|
|
265
|
+
const pingInterval = 60_000;
|
|
266
|
+
const now = 1_000_000;
|
|
267
|
+
await seedConnection({ connId: 'healthy', idleSince: now - 5_000 }); // idleFor = 5s
|
|
268
|
+
|
|
269
|
+
const result = await handlePingCheck(
|
|
270
|
+
params({ now, pingIntervalMs: pingInterval, pongTimeoutMs: 60_000 }),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(result.pinged).toBe(0);
|
|
274
|
+
expect(result.disconnected).toBe(0);
|
|
275
|
+
expect(await connectionExists('healthy')).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('disconnects exactly at the idle threshold (idleFor == pingInterval + pongTimeout)', async () => {
|
|
279
|
+
const pingInterval = 60_000;
|
|
280
|
+
const pongTimeout = 60_000;
|
|
281
|
+
const threshold = pingInterval + pongTimeout;
|
|
282
|
+
await seedConnection({ connId: 'edge', idleSince: 0 });
|
|
283
|
+
// now - idleSince == threshold → disconnect.
|
|
284
|
+
const result = await handlePingCheck(
|
|
285
|
+
params({ now: threshold, pingIntervalMs: pingInterval, pongTimeoutMs: pongTimeout }),
|
|
286
|
+
);
|
|
287
|
+
expect(result.disconnected).toBe(1);
|
|
288
|
+
expect(await connectionExists('edge')).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('pings but does not disconnect just below the disconnect threshold', async () => {
|
|
292
|
+
const pingInterval = 60_000;
|
|
293
|
+
const pongTimeout = 60_000;
|
|
294
|
+
const threshold = pingInterval + pongTimeout;
|
|
295
|
+
await seedConnection({ connId: 'waiting', idleSince: 0 });
|
|
296
|
+
const result = await handlePingCheck(
|
|
297
|
+
params({ now: threshold - 1, pingIntervalMs: pingInterval, pongTimeoutMs: pongTimeout }),
|
|
298
|
+
);
|
|
299
|
+
expect(result.pinged).toBe(1);
|
|
300
|
+
expect(result.disconnected).toBe(0);
|
|
301
|
+
expect(await connectionExists('waiting')).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('does not ping just below the PING threshold', async () => {
|
|
305
|
+
const pingInterval = 60_000;
|
|
306
|
+
await seedConnection({ connId: 'fresh', idleSince: 0 });
|
|
307
|
+
const result = await handlePingCheck(
|
|
308
|
+
params({ now: pingInterval - 1, pingIntervalMs: pingInterval, pongTimeoutMs: 60_000 }),
|
|
309
|
+
);
|
|
310
|
+
expect(result.pinged).toBe(0);
|
|
311
|
+
expect(result.disconnected).toBe(0);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('handles a mixed table: one PING, one disconnect, one active', async () => {
|
|
315
|
+
const pingInterval = 60_000;
|
|
316
|
+
const pongTimeout = 60_000;
|
|
317
|
+
const now = (pingInterval + pongTimeout) * 2;
|
|
318
|
+
await seedConnection({ connId: 'live', idleSince: now });
|
|
319
|
+
await seedConnection({ connId: 'pingMe', idleSince: now - pingInterval }); // idleFor = 60s
|
|
320
|
+
await seedConnection({ connId: 'reap', idleSince: 0 }); // idleFor = 240s
|
|
321
|
+
|
|
322
|
+
const result = await handlePingCheck(
|
|
323
|
+
params({ now, pingIntervalMs: pingInterval, pongTimeoutMs: pongTimeout }),
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
expect(result.scanned).toBe(3);
|
|
327
|
+
expect(result.pinged).toBe(1);
|
|
328
|
+
expect(result.disconnected).toBe(1);
|
|
329
|
+
expect(await connectionExists('live')).toBe(true);
|
|
330
|
+
expect(await connectionExists('pingMe')).toBe(true);
|
|
331
|
+
expect(await connectionExists('reap')).toBe(false);
|
|
332
|
+
expect([...fx!.mgmt.calls.keys()]).toEqual(['pingMe']);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('uses Date.now() when `now` is omitted (real-clock default path)', async () => {
|
|
336
|
+
// Seed a connection idle in the distant past; whatever Date.now() is,
|
|
337
|
+
// it's idle past the disconnect threshold.
|
|
338
|
+
await seedConnection({ connId: 'ancient', idleSince: 0 });
|
|
339
|
+
const result = await handlePingCheck(params({ pingIntervalMs: 1_000, pongTimeoutMs: 1_000 }));
|
|
340
|
+
expect(result.disconnected).toBeGreaterThanOrEqual(1);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('applies the documented default thresholds when none are passed', async () => {
|
|
344
|
+
// Sanity: defaults are positive and PONG timeout > 0.
|
|
345
|
+
expect(DEFAULT_PING_INTERVAL_MS).toBeGreaterThan(0);
|
|
346
|
+
expect(DEFAULT_PONG_TIMEOUT_MS).toBeGreaterThan(0);
|
|
347
|
+
// Seed a connection idle for 10× the default disconnect threshold → reaped.
|
|
348
|
+
const big = DEFAULT_PING_INTERVAL_MS + DEFAULT_PONG_TIMEOUT_MS + 1;
|
|
349
|
+
await seedConnection({ connId: 'gone', idleSince: -big });
|
|
350
|
+
const result = await handlePingCheck(params({ now: 0 }));
|
|
351
|
+
expect(result.disconnected).toBe(1);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('is idempotent — running twice pings once then sweeps 0 (connection already removed)', async () => {
|
|
355
|
+
const now = 1_000_000;
|
|
356
|
+
await seedConnection({ connId: 'stale', idleSince: 0 });
|
|
357
|
+
const first = await handlePingCheck(
|
|
358
|
+
params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 }),
|
|
359
|
+
);
|
|
360
|
+
const second = await handlePingCheck(
|
|
361
|
+
params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 }),
|
|
362
|
+
);
|
|
363
|
+
expect(first.disconnected).toBe(1);
|
|
364
|
+
expect(second.scanned).toBe(0);
|
|
365
|
+
expect(second.pinged).toBe(0);
|
|
366
|
+
expect(second.disconnected).toBe(0);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('paginates: scans across multiple Scan pages', async () => {
|
|
370
|
+
await seedConnection({ connId: 'p1', idleSince: 0 });
|
|
371
|
+
await seedConnection({ connId: 'p2', idleSince: 0 });
|
|
372
|
+
const result = await handlePingCheck(
|
|
373
|
+
params({ now: 1_000_000, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 }),
|
|
374
|
+
);
|
|
375
|
+
expect(result.scanned).toBe(2);
|
|
376
|
+
expect(result.disconnected).toBe(2);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('skips a row whose idleSince is not a number (defensive)', async () => {
|
|
380
|
+
await fx!.client.send(
|
|
381
|
+
new PutCommand({
|
|
382
|
+
TableName: fx!.tables.Connections,
|
|
383
|
+
Item: {
|
|
384
|
+
connectionId: 'corrupt',
|
|
385
|
+
registration: 'registered',
|
|
386
|
+
capNegotiating: false,
|
|
387
|
+
caps: [],
|
|
388
|
+
userModes: {},
|
|
389
|
+
lastSeen: 0,
|
|
390
|
+
connectedSince: 0,
|
|
391
|
+
idleSince: 'not-a-number',
|
|
392
|
+
version: 1,
|
|
393
|
+
},
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
const result = await handlePingCheck(
|
|
397
|
+
params({ now: 1_000_000, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 }),
|
|
398
|
+
);
|
|
399
|
+
expect(result.scanned).toBe(1);
|
|
400
|
+
expect(result.pinged).toBe(0);
|
|
401
|
+
expect(result.disconnected).toBe(0);
|
|
402
|
+
expect(await connectionExists('corrupt')).toBe(true);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('catches GoneException on PING send and cleans up the connection', async () => {
|
|
406
|
+
// The socket is gone but the row is still there. handlePingCheck's
|
|
407
|
+
// send path should catch GoneException and reap the row, returning
|
|
408
|
+
// the connection as disconnected.
|
|
409
|
+
const now = 100_000;
|
|
410
|
+
await seedConnection({ connId: 'halfopen', idleSince: 0 }); // idleFor = 100s
|
|
411
|
+
fx!.mgmt.failNextWithGone('halfopen');
|
|
412
|
+
|
|
413
|
+
const result = await handlePingCheck(
|
|
414
|
+
params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 }),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
expect(result.pinged).toBe(1); // PING attempt counted even though send threw
|
|
418
|
+
expect(await connectionExists('halfopen')).toBe(false);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('is safe to run when managementApi is null (PING path is a silent no-op)', async () => {
|
|
422
|
+
const now = 100_000;
|
|
423
|
+
await seedConnection({ connId: 'idle', idleSince: 0 });
|
|
424
|
+
const result = await handlePingCheck(
|
|
425
|
+
params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000, managementApi: null }),
|
|
426
|
+
);
|
|
427
|
+
expect(result.pinged).toBe(0); // no backend → no PING attempt counted
|
|
428
|
+
expect(await connectionExists('idle')).toBe(true);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('rethrows non-Gone errors from the PING send (no silent swallow)', async () => {
|
|
432
|
+
const now = 100_000;
|
|
433
|
+
await seedConnection({ connId: 'idle', idleSince: 0 });
|
|
434
|
+
fx!.mgmt.throwNext('idle', new Error('InternalServerError: boom'));
|
|
435
|
+
|
|
436
|
+
await expect(
|
|
437
|
+
handlePingCheck(params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 })),
|
|
438
|
+
).rejects.toThrow(/boom/);
|
|
439
|
+
// The row is still there — we did not run cleanupConnection.
|
|
440
|
+
expect(await connectionExists('idle')).toBe(true);
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
// Scan-response edge cases (no DynamoDB Local — fake client)
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Scripted page for the fake Scan client (mirrors `sweeper.test.ts`).
|
|
450
|
+
*/
|
|
451
|
+
interface ScriptedPage {
|
|
452
|
+
items?: unknown[];
|
|
453
|
+
lastKey?: unknown;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Fake document client: returns scripted `ScanCommand` pages and empty
|
|
458
|
+
* success for every other command. Lets the checker's pagination and
|
|
459
|
+
* defensive branches run without real DynamoDB.
|
|
460
|
+
*/
|
|
461
|
+
class FakeScanDynamo {
|
|
462
|
+
public sent: unknown[] = [];
|
|
463
|
+
constructor(private readonly pages: ScriptedPage[]) {}
|
|
464
|
+
async send(cmd: unknown): Promise<unknown> {
|
|
465
|
+
this.sent.push(cmd);
|
|
466
|
+
if (cmd instanceof ScanCommand) {
|
|
467
|
+
const page = this.pages.shift() ?? {};
|
|
468
|
+
const resp: Record<string, unknown> = {};
|
|
469
|
+
if (page.items !== undefined) resp.Items = page.items;
|
|
470
|
+
if (page.lastKey !== undefined) resp.LastEvaluatedKey = page.lastKey;
|
|
471
|
+
return resp;
|
|
472
|
+
}
|
|
473
|
+
return {};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function fakeParams(client: FakeScanDynamo): PingCheckParams {
|
|
478
|
+
return {
|
|
479
|
+
dynamo: client as unknown as DynamoDBDocumentClient,
|
|
480
|
+
tables: {
|
|
481
|
+
Connections: 'C',
|
|
482
|
+
ChannelMeta: 'CM',
|
|
483
|
+
ChannelMembers: 'CMem',
|
|
484
|
+
Nicks: 'N',
|
|
485
|
+
Accounts: 'A',
|
|
486
|
+
},
|
|
487
|
+
managementApi: null,
|
|
488
|
+
now: 100_000,
|
|
489
|
+
pingIntervalMs: 60_000,
|
|
490
|
+
pongTimeoutMs: 60_000,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
describe('idle / PING checker — scan edge cases', () => {
|
|
495
|
+
it('paginates across multiple Scan pages (LastEvaluatedKey carried between calls)', async () => {
|
|
496
|
+
const client = new FakeScanDynamo([
|
|
497
|
+
{ items: [{ connectionId: 'a', idleSince: 0 }], lastKey: { connectionId: 'a' } },
|
|
498
|
+
{ items: [{ connectionId: 'b', idleSince: 0 }], lastKey: { connectionId: 'b' } },
|
|
499
|
+
{ items: [{ connectionId: 'c', idleSince: 0 }] },
|
|
500
|
+
]);
|
|
501
|
+
const result = await handlePingCheck(fakeParams(client));
|
|
502
|
+
expect(result.scanned).toBe(3);
|
|
503
|
+
// managementApi is null → pinged stays 0 even though idleFor > pingInterval.
|
|
504
|
+
expect(result.pinged).toBe(0);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('treats a missing Items field as an empty page (defensive)', async () => {
|
|
508
|
+
const client = new FakeScanDynamo([{}]);
|
|
509
|
+
const result = await handlePingCheck(fakeParams(client));
|
|
510
|
+
expect(result.scanned).toBe(0);
|
|
511
|
+
expect(result.pinged).toBe(0);
|
|
512
|
+
expect(result.disconnected).toBe(0);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('skips a row whose connectionId is not a string (defensive)', async () => {
|
|
516
|
+
// Real DynamoDB rejects non-string PKs at write time, so this only
|
|
517
|
+
// surfaces in synthetic scans. The fake client lets us inject the
|
|
518
|
+
// malformed row directly.
|
|
519
|
+
const client = new FakeScanDynamo([{ items: [{ connectionId: 12345, idleSince: 0 }] }]);
|
|
520
|
+
const result = await handlePingCheck(fakeParams(client));
|
|
521
|
+
expect(result.scanned).toBe(1);
|
|
522
|
+
expect(result.pinged).toBe(0);
|
|
523
|
+
expect(result.disconnected).toBe(0);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
// Test double for ApiGatewayManagementApi.postToConnection
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Records every `postToConnection` call, grouped by ConnectionId.
|
|
533
|
+
* Optionally throws a GoneException-shaped error on the next call for
|
|
534
|
+
* a registered connection id, so the PING path's gone-cleanup branch
|
|
535
|
+
* is exercisable without real APIGW.
|
|
536
|
+
*/
|
|
537
|
+
class RecordingPostToConnection {
|
|
538
|
+
readonly calls = new Map<string, string[]>();
|
|
539
|
+
private readonly goneNext = new Set<string>();
|
|
540
|
+
private readonly throwNextErr = new Map<string, Error>();
|
|
541
|
+
register(connId: string): void {
|
|
542
|
+
this.calls.set(connId, []);
|
|
543
|
+
}
|
|
544
|
+
failNextWithGone(connId: string): void {
|
|
545
|
+
this.goneNext.add(connId);
|
|
546
|
+
}
|
|
547
|
+
/** Next call for `connId` throws `err` verbatim (used to exercise the non-Gone rethrow). */
|
|
548
|
+
throwNext(connId: string, err: Error): void {
|
|
549
|
+
this.throwNextErr.set(connId, err);
|
|
550
|
+
}
|
|
551
|
+
async postToConnection(input: { ConnectionId: string; Data: string }): Promise<{}> {
|
|
552
|
+
const throwErr = this.throwNextErr.get(input.ConnectionId);
|
|
553
|
+
if (throwErr !== undefined) {
|
|
554
|
+
this.throwNextErr.delete(input.ConnectionId);
|
|
555
|
+
throw throwErr;
|
|
556
|
+
}
|
|
557
|
+
if (this.goneNext.has(input.ConnectionId)) {
|
|
558
|
+
this.goneNext.delete(input.ConnectionId);
|
|
559
|
+
const err = new Error('Gone');
|
|
560
|
+
(err as { name: string }).name = 'GoneException';
|
|
561
|
+
throw err;
|
|
562
|
+
}
|
|
563
|
+
const list = this.calls.get(input.ConnectionId) ?? [];
|
|
564
|
+
list.push(input.Data);
|
|
565
|
+
this.calls.set(input.ConnectionId, list);
|
|
566
|
+
return {};
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Keep the import live for type usage in `params`.
|
|
571
|
+
export type { DynamoDBDocumentClient };
|