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,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDK-coupled table definitions (PLAN §4).
|
|
3
|
+
*
|
|
4
|
+
* The single source of truth for the deployed table shapes
|
|
5
|
+
* (`partitionKey`, `sortKey`, `timeToLiveAttribute`, …). Lives in
|
|
6
|
+
* `aws-adapter` (not `aws-stack`) so the runtime can reference the same
|
|
7
|
+
* logical names via {@link TABLE_NAMES} without round-tripping through
|
|
8
|
+
* the app directory.
|
|
9
|
+
*
|
|
10
|
+
* Re-exported from `apps/aws-stack/src/aws-stack.ts`. NOT re-exported
|
|
11
|
+
* from `aws-adapter`'s `index.ts` so the Lambda handler bundle does
|
|
12
|
+
* not transitively include `aws-cdk-lib`.
|
|
13
|
+
*
|
|
14
|
+
* Schema invariants (PLAN §4):
|
|
15
|
+
* • `Nicks` uniqueness is enforced at write-time via conditional PutItem
|
|
16
|
+
* (PK = lowercased nick) — not by the schema itself.
|
|
17
|
+
* • `ChannelMembers` uses a composite key (channelName PK + connectionId SK)
|
|
18
|
+
* so a future `TransactWriteItems` can span Connections ↔ membership
|
|
19
|
+
* atomically. (Atomicity across Connections/ChannelMembers lands in the
|
|
20
|
+
* membership-transaction follow-up.)
|
|
21
|
+
* • `Accounts` never stores plaintext credentials (hash only).
|
|
22
|
+
* • `Connections.idleSince` is the DynamoDB TTL attribute; idle connections
|
|
23
|
+
* are reaped automatically in addition to the explicit sweeper.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
|
|
27
|
+
import type { TableProps } from 'aws-cdk-lib/aws-dynamodb';
|
|
28
|
+
import type { TableName } from './tables.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* All five tables keyed by their logical construct id. The id doubles as the
|
|
32
|
+
* CloudFormation logical id; the physical `TableName` is set on each entry.
|
|
33
|
+
*
|
|
34
|
+
* NOTE: the deployed physical name is **environment-prefixed** by
|
|
35
|
+
* `IrcAwsStack` (`<Env><LogicalId>`, e.g. `StagingConnections`) — the
|
|
36
|
+
* `tableName` here is the schema default overridden at synth time. Both
|
|
37
|
+
* the CDK stack and the runtime read from this record. Tests that prefix
|
|
38
|
+
* every physical name (to isolate parallel runs) consume the
|
|
39
|
+
* `TABLE_DEFS` entries' key/attribute names only.
|
|
40
|
+
*/
|
|
41
|
+
export const TABLE_DEFS: Record<TableName, TableProps> = {
|
|
42
|
+
Connections: {
|
|
43
|
+
tableName: 'Connections',
|
|
44
|
+
partitionKey: { name: 'connectionId', type: AttributeType.STRING },
|
|
45
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
46
|
+
timeToLiveAttribute: 'idleSince',
|
|
47
|
+
},
|
|
48
|
+
ChannelMeta: {
|
|
49
|
+
tableName: 'ChannelMeta',
|
|
50
|
+
partitionKey: { name: 'channelName', type: AttributeType.STRING },
|
|
51
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
52
|
+
},
|
|
53
|
+
ChannelMembers: {
|
|
54
|
+
tableName: 'ChannelMembers',
|
|
55
|
+
partitionKey: { name: 'channelName', type: AttributeType.STRING },
|
|
56
|
+
sortKey: { name: 'connectionId', type: AttributeType.STRING },
|
|
57
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
58
|
+
},
|
|
59
|
+
Nicks: {
|
|
60
|
+
tableName: 'Nicks',
|
|
61
|
+
partitionKey: { name: 'nickLower', type: AttributeType.STRING },
|
|
62
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
63
|
+
},
|
|
64
|
+
Accounts: {
|
|
65
|
+
tableName: 'Accounts',
|
|
66
|
+
partitionKey: { name: 'account', type: AttributeType.STRING },
|
|
67
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lambda env config loader.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the Cloudflare (`cf-adapter/src/config-loader.ts`) and local-cli
|
|
5
|
+
* (`local-cli/src/config-loader.ts`) adapters: takes platform-native env
|
|
6
|
+
* vars and feeds them through the shared {@link parseServerConfig} so a
|
|
7
|
+
* single boot-time failure mode covers every adapter.
|
|
8
|
+
*
|
|
9
|
+
* Lambda env vars are all strings; numeric knobs are coerced here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The subset of `process.env` this loader reads. Optional members are
|
|
16
|
+
* omitted from the input object when undefined so the schema's defaults
|
|
17
|
+
* apply.
|
|
18
|
+
*/
|
|
19
|
+
export interface LambdaConfigEnv {
|
|
20
|
+
SERVER_NAME?: string;
|
|
21
|
+
NETWORK_NAME?: string;
|
|
22
|
+
MOTD?: string;
|
|
23
|
+
MAX_CLIENTS?: string;
|
|
24
|
+
CHANNEL_PREFIXES?: string;
|
|
25
|
+
OPER_USER?: string;
|
|
26
|
+
OPER_PASSWORD?: string;
|
|
27
|
+
MAX_CHANNELS_PER_USER?: string;
|
|
28
|
+
MAX_TARGETS_PER_COMMAND?: string;
|
|
29
|
+
NICK_LEN?: string;
|
|
30
|
+
CHANNEL_LEN?: string;
|
|
31
|
+
TOPIC_LEN?: string;
|
|
32
|
+
MAX_LIST_ENTRIES?: string;
|
|
33
|
+
QUIT_MESSAGE?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Builds the input object the shared schema expects from a Lambda env.
|
|
38
|
+
*
|
|
39
|
+
* Exported so the assemble→parse boundary is unit-testable in isolation.
|
|
40
|
+
* Mirrors `cf-adapter`'s `buildConfigInput` line-for-line except for the
|
|
41
|
+
* MOTD env-var name (`MOTD` here vs `MOTD_LINES` in CF — Lambda env vars
|
|
42
|
+
* conventionally use bare names).
|
|
43
|
+
*/
|
|
44
|
+
export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unknown> {
|
|
45
|
+
const input: Record<string, unknown> = {
|
|
46
|
+
serverName: env.SERVER_NAME,
|
|
47
|
+
networkName: env.NETWORK_NAME,
|
|
48
|
+
};
|
|
49
|
+
if (env.MOTD !== undefined && env.MOTD.length > 0) {
|
|
50
|
+
input.motdLines = env.MOTD.split('\n');
|
|
51
|
+
}
|
|
52
|
+
if (env.MAX_CLIENTS !== undefined) {
|
|
53
|
+
input.maxClients = Number.parseInt(env.MAX_CLIENTS, 10);
|
|
54
|
+
}
|
|
55
|
+
if (env.CHANNEL_PREFIXES !== undefined) {
|
|
56
|
+
input.channelPrefixes = env.CHANNEL_PREFIXES;
|
|
57
|
+
}
|
|
58
|
+
if (env.OPER_USER !== undefined || env.OPER_PASSWORD !== undefined) {
|
|
59
|
+
input.operCreds = [
|
|
60
|
+
{
|
|
61
|
+
user: env.OPER_USER ?? '',
|
|
62
|
+
password: env.OPER_PASSWORD ?? '',
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
if (env.MAX_CHANNELS_PER_USER !== undefined) {
|
|
67
|
+
input.maxChannelsPerUser = Number.parseInt(env.MAX_CHANNELS_PER_USER, 10);
|
|
68
|
+
}
|
|
69
|
+
if (env.MAX_TARGETS_PER_COMMAND !== undefined) {
|
|
70
|
+
input.maxTargetsPerCommand = Number.parseInt(env.MAX_TARGETS_PER_COMMAND, 10);
|
|
71
|
+
}
|
|
72
|
+
if (env.NICK_LEN !== undefined) {
|
|
73
|
+
input.nickLen = Number.parseInt(env.NICK_LEN, 10);
|
|
74
|
+
}
|
|
75
|
+
if (env.CHANNEL_LEN !== undefined) {
|
|
76
|
+
input.channelLen = Number.parseInt(env.CHANNEL_LEN, 10);
|
|
77
|
+
}
|
|
78
|
+
if (env.TOPIC_LEN !== undefined) {
|
|
79
|
+
input.topicLen = Number.parseInt(env.TOPIC_LEN, 10);
|
|
80
|
+
}
|
|
81
|
+
if (env.MAX_LIST_ENTRIES !== undefined) {
|
|
82
|
+
input.maxListEntries = Number.parseInt(env.MAX_LIST_ENTRIES, 10);
|
|
83
|
+
}
|
|
84
|
+
if (env.QUIT_MESSAGE !== undefined) {
|
|
85
|
+
input.quitMessage = env.QUIT_MESSAGE;
|
|
86
|
+
}
|
|
87
|
+
return input;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Loads and validates the server config from a Lambda env. Throws a
|
|
92
|
+
* readable `Error` listing every invalid field on failure.
|
|
93
|
+
*/
|
|
94
|
+
export function loadServerConfigFromLambdaEnv(env: LambdaConfigEnv): ParsedServerConfig {
|
|
95
|
+
return parseServerConfig(buildLambdaConfigInput(env));
|
|
96
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB DocumentClient factory.
|
|
3
|
+
*
|
|
4
|
+
* Centralised so the runtime, the handlers, and the test harness all
|
|
5
|
+
* build their clients with the same marshalling conventions
|
|
6
|
+
* (`removeUndefinedValues: true`, native JS values in/out).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
10
|
+
import {
|
|
11
|
+
DynamoDBDocumentClient,
|
|
12
|
+
type TranslateConfig,
|
|
13
|
+
type marshallOptions,
|
|
14
|
+
type unmarshallOptions,
|
|
15
|
+
} from '@aws-sdk/lib-dynamodb';
|
|
16
|
+
|
|
17
|
+
/** Default marshalling options — every caller uses these. */
|
|
18
|
+
const DEFAULT_TRANSLATE: TranslateConfig = {
|
|
19
|
+
marshallOptions: {
|
|
20
|
+
removeUndefinedValues: true,
|
|
21
|
+
convertClassInstanceToMap: false,
|
|
22
|
+
} as marshallOptions,
|
|
23
|
+
unmarshallOptions: {} as unmarshallOptions,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds a {@link DynamoDBDocumentClient} pointed at `endpoint`
|
|
28
|
+
* (or AWS proper when `endpoint` is omitted).
|
|
29
|
+
*
|
|
30
|
+
* Tests pass `endpoint` so they can address DynamoDB Local; production
|
|
31
|
+
* constructs the client with no endpoint and lets the SDK resolve the
|
|
32
|
+
* region from the Lambda execution role.
|
|
33
|
+
*
|
|
34
|
+
* `region` defaults to `'us-east-1'` when unset. DynamoDB Local ignores
|
|
35
|
+
* the value but the SDK requires one to be present so it can build the
|
|
36
|
+
* SigV4 signature. Production deployments inject a real region via the
|
|
37
|
+
* Lambda execution environment (`AWS_REGION`).
|
|
38
|
+
*
|
|
39
|
+
* When `endpoint` is supplied (the documented signal for DynamoDB Local
|
|
40
|
+
* / test mode) dummy static credentials are injected. The SDK v3 signs
|
|
41
|
+
* every request with SigV4 even against a local endpoint, so without
|
|
42
|
+
* credentials the default provider chain fails with
|
|
43
|
+
* `CredentialsProviderError`. Production never passes an endpoint and
|
|
44
|
+
* therefore resolves real credentials from the Lambda execution role.
|
|
45
|
+
*/
|
|
46
|
+
export function createDynamoDocumentClient(opts: {
|
|
47
|
+
endpoint?: string;
|
|
48
|
+
region?: string;
|
|
49
|
+
}): DynamoDBDocumentClient {
|
|
50
|
+
const region = opts.region ?? process.env.AWS_REGION ?? 'us-east-1';
|
|
51
|
+
const client = new DynamoDBClient({
|
|
52
|
+
region,
|
|
53
|
+
...(opts.endpoint !== undefined
|
|
54
|
+
? {
|
|
55
|
+
endpoint: opts.endpoint,
|
|
56
|
+
credentials: { accessKeyId: 'local', secretAccessKey: 'local' },
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
});
|
|
60
|
+
return DynamoDBDocumentClient.from(client, DEFAULT_TRANSLATE);
|
|
61
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `$connect` handler — Lambda entry for `event.requestContext.routeKey === '$connect'`.
|
|
3
|
+
*
|
|
4
|
+
* Persists a fresh `Connections` row keyed by the APIGW connection id.
|
|
5
|
+
* Subsequent `$default` invocations load this row, run the actor, and
|
|
6
|
+
* persist the mutated state back.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
10
|
+
import { PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
11
|
+
import type { ParsedServerConfig } from '@serverless-ircd/irc-core';
|
|
12
|
+
import { marshalConnection } from '../serialize.js';
|
|
13
|
+
import type { TablesConfig } from '../tables.js';
|
|
14
|
+
import { createInitialConnectionState } from './state.js';
|
|
15
|
+
|
|
16
|
+
/** Parameters accepted by {@link handleConnect}. */
|
|
17
|
+
export interface ConnectParams {
|
|
18
|
+
dynamo: DynamoDBDocumentClient;
|
|
19
|
+
tables: TablesConfig;
|
|
20
|
+
connectionId: string;
|
|
21
|
+
/** Wall clock used to seed `connectedSince` / `idleSince` (default: now). */
|
|
22
|
+
now?: number;
|
|
23
|
+
/** Reserved for future use; required so the signature mirrors `$default`. */
|
|
24
|
+
serverConfig?: ParsedServerConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Inserts the Connections row for a fresh WebSocket upgrade.
|
|
29
|
+
*
|
|
30
|
+
* Idempotent: APIGW retries `$connect` on transient failures, so we
|
|
31
|
+
* use `PutCommand` (overwrites) rather than a conditional insert.
|
|
32
|
+
*/
|
|
33
|
+
export async function handleConnect(params: ConnectParams): Promise<void> {
|
|
34
|
+
const now = params.now ?? Date.now();
|
|
35
|
+
const state = createInitialConnectionState(params.connectionId, now);
|
|
36
|
+
void params.serverConfig; // reserved for max-clients gating (later ticket)
|
|
37
|
+
const row = marshalConnection(state, now);
|
|
38
|
+
await params.dynamo.send(
|
|
39
|
+
new PutCommand({
|
|
40
|
+
TableName: params.tables.Connections,
|
|
41
|
+
Item: row,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `$default` handler — Lambda entry for every non-`$connect`/`$disconnect`
|
|
3
|
+
* route. Receives one (or more) IRC frames as `event.body`, runs them
|
|
4
|
+
* through {@link ConnectionActor}, and persists the mutated state back.
|
|
5
|
+
*
|
|
6
|
+
* One Lambda invocation = one WebSocket frame. The frame may contain
|
|
7
|
+
* multiple `\r\n`-separated lines; the actor splits them and runs each
|
|
8
|
+
* in order.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
|
|
12
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
13
|
+
import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
|
|
14
|
+
import {
|
|
15
|
+
type ChannelState,
|
|
16
|
+
type Clock,
|
|
17
|
+
type ConnectionState,
|
|
18
|
+
type IdFactory,
|
|
19
|
+
type MessageStore,
|
|
20
|
+
type MotdProvider,
|
|
21
|
+
type ParsedServerConfig,
|
|
22
|
+
SystemClock,
|
|
23
|
+
UuidIdFactory,
|
|
24
|
+
createConnection,
|
|
25
|
+
} from '@serverless-ircd/irc-core';
|
|
26
|
+
import { type ActorChannelAccess, ConnectionActor } from '@serverless-ircd/irc-server';
|
|
27
|
+
import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
|
|
28
|
+
import {
|
|
29
|
+
CONNECTION_VERSION,
|
|
30
|
+
type MarshalledConnection,
|
|
31
|
+
unmarshalConnection,
|
|
32
|
+
} from '../serialize.js';
|
|
33
|
+
import type { TablesConfig } from '../tables.js';
|
|
34
|
+
|
|
35
|
+
/** Parameters accepted by {@link handleDefault}. */
|
|
36
|
+
export interface DefaultParams {
|
|
37
|
+
dynamo: DynamoDBDocumentClient;
|
|
38
|
+
tables: TablesConfig;
|
|
39
|
+
connectionId: string;
|
|
40
|
+
body: string;
|
|
41
|
+
serverConfig: ParsedServerConfig;
|
|
42
|
+
motd: MotdProvider;
|
|
43
|
+
/**
|
|
44
|
+
* Chat-history persistence source. When omitted the actor runs with
|
|
45
|
+
* chathistory disabled (no recording, no playback) — preserves the
|
|
46
|
+
* behaviour of callers that have not opted in.
|
|
47
|
+
*/
|
|
48
|
+
messages?: MessageStore;
|
|
49
|
+
managementApi: ApiGatewayManagementApi | PostToConnection | null;
|
|
50
|
+
/** Injected for tests; defaults to {@link SystemClock}. */
|
|
51
|
+
clock?: Clock;
|
|
52
|
+
/** Injected for tests; defaults to {@link UuidIdFactory}. */
|
|
53
|
+
ids?: IdFactory;
|
|
54
|
+
/** Optional management endpoint URL used when constructing a default client. */
|
|
55
|
+
managementEndpoint?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Loads the Connections row, runs the actor over `body`, persists state.
|
|
60
|
+
*
|
|
61
|
+
* Returns the status code the Lambda should return to APIGW:
|
|
62
|
+
* - `200` on success,
|
|
63
|
+
* - `410` if the connection row is gone (client should reconnect),
|
|
64
|
+
* - `500` for any unhandled error.
|
|
65
|
+
*/
|
|
66
|
+
export async function handleDefault(params: DefaultParams): Promise<{ statusCode: number }> {
|
|
67
|
+
const clock = params.clock ?? SystemClock;
|
|
68
|
+
const ids = params.ids ?? new UuidIdFactory();
|
|
69
|
+
// Load persisted state.
|
|
70
|
+
const result = await params.dynamo.send(
|
|
71
|
+
new GetCommand({
|
|
72
|
+
TableName: params.tables.Connections,
|
|
73
|
+
Key: { connectionId: params.connectionId },
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
if (result.Item === undefined) {
|
|
77
|
+
return { statusCode: 410 };
|
|
78
|
+
}
|
|
79
|
+
const persisted = result.Item as unknown as MarshalledConnection;
|
|
80
|
+
const state = unmarshalConnection(persisted);
|
|
81
|
+
|
|
82
|
+
// Mark this frame's activity.
|
|
83
|
+
state.lastSeen = clock.now();
|
|
84
|
+
|
|
85
|
+
// Build the runtime + handlers.
|
|
86
|
+
const outbound: string[] = [];
|
|
87
|
+
const handlers: AwsRuntimeHandlers = {
|
|
88
|
+
send: (lines) => {
|
|
89
|
+
// Self-send: APIGW echoes back to the caller's open socket via the
|
|
90
|
+
// management API in production; in tests we collect for assertions.
|
|
91
|
+
for (const l of lines) outbound.push(l.text);
|
|
92
|
+
},
|
|
93
|
+
disconnect: () => {
|
|
94
|
+
// Lambda cannot close its own socket from inside the handler
|
|
95
|
+
// (the `$disconnect` route fires when APIGW observes the close).
|
|
96
|
+
},
|
|
97
|
+
snapshot: () => state,
|
|
98
|
+
};
|
|
99
|
+
const runtime = new AwsRuntime({
|
|
100
|
+
dynamo: params.dynamo,
|
|
101
|
+
tables: params.tables,
|
|
102
|
+
connId: params.connectionId,
|
|
103
|
+
handlers,
|
|
104
|
+
managementApi: params.managementApi,
|
|
105
|
+
clock,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const channelAccess = new LambdaChannelAccess(runtime, clock.now());
|
|
109
|
+
|
|
110
|
+
const actor = new ConnectionActor({
|
|
111
|
+
state,
|
|
112
|
+
runtime,
|
|
113
|
+
channels: channelAccess,
|
|
114
|
+
serverConfig: params.serverConfig,
|
|
115
|
+
clock,
|
|
116
|
+
ids,
|
|
117
|
+
motd: params.motd,
|
|
118
|
+
...(params.messages !== undefined ? { messages: params.messages } : {}),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await actor.receiveTextFrame(params.body);
|
|
123
|
+
} catch (err: unknown) {
|
|
124
|
+
console.error('[irc-handler] actor.receiveTextFrame failed', err);
|
|
125
|
+
// Still persist the state — partial mutations are valuable for debug.
|
|
126
|
+
await persistState(params.dynamo, params.tables, params.connectionId, state, clock.now());
|
|
127
|
+
return { statusCode: 500 };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Persist the mutated state and flush outbound bytes.
|
|
131
|
+
await persistState(params.dynamo, params.tables, params.connectionId, state, clock.now());
|
|
132
|
+
|
|
133
|
+
// In production, deliver outbound bytes back to the caller via APIGW.
|
|
134
|
+
if (params.managementApi !== null && outbound.length > 0) {
|
|
135
|
+
const data = outbound.join('\r\n');
|
|
136
|
+
try {
|
|
137
|
+
await params.managementApi.postToConnection({
|
|
138
|
+
ConnectionId: params.connectionId,
|
|
139
|
+
Data: data,
|
|
140
|
+
});
|
|
141
|
+
} catch {
|
|
142
|
+
// The handler still returns 200 — the actor's mutations are
|
|
143
|
+
// persisted; the caller just didn't see the reply this frame.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { statusCode: 200 };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Writes the actor's mutated `state` back to the Connections row.
|
|
151
|
+
*
|
|
152
|
+
* Uses an `UpdateCommand` that SETs every actor-owned field EXCEPT
|
|
153
|
+
* `joinedChannels`. The membership transaction (see `AwsRuntime`)
|
|
154
|
+
* owns `joinedChannels` atomically; if this frame's full-state write
|
|
155
|
+
* touched it, a concurrent `$default` for the same connection would
|
|
156
|
+
* lose the other frame's JOIN/PART. Optional fields (nick, user, …)
|
|
157
|
+
* are SET when present and REMOVE'd when absent so a cleared value
|
|
158
|
+
* does not linger from a previous frame.
|
|
159
|
+
*/
|
|
160
|
+
async function persistState(
|
|
161
|
+
dynamo: DynamoDBDocumentClient,
|
|
162
|
+
tables: TablesConfig,
|
|
163
|
+
_connId: string,
|
|
164
|
+
state: ConnectionState,
|
|
165
|
+
now: number,
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
const expr = buildPersistUpdate(state, now);
|
|
168
|
+
const hasNames = Object.keys(expr.expressionAttributeNames).length > 0;
|
|
169
|
+
await dynamo.send(
|
|
170
|
+
new UpdateCommand({
|
|
171
|
+
TableName: tables.Connections,
|
|
172
|
+
Key: { connectionId: state.id },
|
|
173
|
+
UpdateExpression: expr.updateExpression,
|
|
174
|
+
ExpressionAttributeValues: expr.expressionAttributeValues,
|
|
175
|
+
...(hasNames ? { ExpressionAttributeNames: expr.expressionAttributeNames } : {}),
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Optional connection fields mirrored into the SET/REMOVE split. */
|
|
181
|
+
const OPTIONAL_FIELDS = [
|
|
182
|
+
'nick',
|
|
183
|
+
'user',
|
|
184
|
+
'host',
|
|
185
|
+
'realname',
|
|
186
|
+
'passAttempt',
|
|
187
|
+
'account',
|
|
188
|
+
'away',
|
|
189
|
+
'saslMech',
|
|
190
|
+
'saslBuffer',
|
|
191
|
+
] as const;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Builds the `UpdateExpression` for {@link persistState}. Always SETs
|
|
195
|
+
* the required fields; SETs optional fields that are present and
|
|
196
|
+
* REMOVEs those that are absent. `joinedChannels` is intentionally
|
|
197
|
+
* excluded — owned by the membership transaction.
|
|
198
|
+
*/
|
|
199
|
+
function buildPersistUpdate(
|
|
200
|
+
state: ConnectionState,
|
|
201
|
+
now: number,
|
|
202
|
+
): {
|
|
203
|
+
updateExpression: string;
|
|
204
|
+
expressionAttributeNames: Record<string, string>;
|
|
205
|
+
expressionAttributeValues: Record<string, unknown>;
|
|
206
|
+
} {
|
|
207
|
+
const values: Record<string, unknown> = {
|
|
208
|
+
':reg': state.registration,
|
|
209
|
+
':capN': state.capNegotiating,
|
|
210
|
+
':caps': [...state.caps],
|
|
211
|
+
':um': { ...state.userModes },
|
|
212
|
+
':ls': state.lastSeen,
|
|
213
|
+
':is': now,
|
|
214
|
+
':v': CONNECTION_VERSION,
|
|
215
|
+
};
|
|
216
|
+
const setNames = [
|
|
217
|
+
'registration = :reg',
|
|
218
|
+
'capNegotiating = :capN',
|
|
219
|
+
'caps = :caps',
|
|
220
|
+
'userModes = :um',
|
|
221
|
+
'lastSeen = :ls',
|
|
222
|
+
'idleSince = :is',
|
|
223
|
+
'version = :v',
|
|
224
|
+
];
|
|
225
|
+
const removeNames: string[] = [];
|
|
226
|
+
const nameMap: Record<string, string> = {};
|
|
227
|
+
|
|
228
|
+
for (const field of OPTIONAL_FIELDS) {
|
|
229
|
+
const placeholder = `:${field}`;
|
|
230
|
+
const value = state[field] as string | undefined;
|
|
231
|
+
const nameKey = `#${field}`;
|
|
232
|
+
nameMap[nameKey] = field;
|
|
233
|
+
if (value !== undefined) {
|
|
234
|
+
values[placeholder] = value;
|
|
235
|
+
setNames.push(`${nameKey} = ${placeholder}`);
|
|
236
|
+
} else {
|
|
237
|
+
removeNames.push(nameKey);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const parts: string[] = [`SET ${setNames.join(', ')}`];
|
|
242
|
+
if (removeNames.length > 0) {
|
|
243
|
+
parts.push(`REMOVE ${removeNames.join(', ')}`);
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
updateExpression: parts.join(' '),
|
|
247
|
+
expressionAttributeNames: nameMap,
|
|
248
|
+
expressionAttributeValues: values,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `ActorChannelAccess` backed by the per-invocation {@link AwsRuntime}.
|
|
254
|
+
*
|
|
255
|
+
* Mirrors `cf-adapter/src/connection-do.ts`'s `PassthroughChannelAccess`:
|
|
256
|
+
* - `getOrCreateChannel(name)` returns a cached or freshly-seeded
|
|
257
|
+
* {@link ChannelState} for the local reducer.
|
|
258
|
+
* - `refreshChannel(name)` pulls the authoritative snapshot from
|
|
259
|
+
* DynamoDB and rebuilds the cache so subsequent reducers observe
|
|
260
|
+
* the current roster / modes / topic.
|
|
261
|
+
*/
|
|
262
|
+
class LambdaChannelAccess implements ActorChannelAccess {
|
|
263
|
+
private readonly cache = new Map<string, ChannelState>();
|
|
264
|
+
constructor(
|
|
265
|
+
private readonly runtime: AwsRuntime,
|
|
266
|
+
private readonly now: number,
|
|
267
|
+
) {}
|
|
268
|
+
|
|
269
|
+
getOrCreateChannel(name: string): ChannelState {
|
|
270
|
+
const key = name.toLowerCase();
|
|
271
|
+
const existing = this.cache.get(key);
|
|
272
|
+
if (existing !== undefined) return existing;
|
|
273
|
+
const created: ChannelState = {
|
|
274
|
+
name,
|
|
275
|
+
nameLower: key,
|
|
276
|
+
modes: {
|
|
277
|
+
inviteOnly: false,
|
|
278
|
+
topicLock: false,
|
|
279
|
+
noExternal: false,
|
|
280
|
+
moderated: false,
|
|
281
|
+
secret: false,
|
|
282
|
+
private: false,
|
|
283
|
+
},
|
|
284
|
+
banMasks: new Set<string>(),
|
|
285
|
+
members: new Map(),
|
|
286
|
+
pendingInvites: new Set<string>(),
|
|
287
|
+
createdAt: this.now,
|
|
288
|
+
};
|
|
289
|
+
this.cache.set(key, created);
|
|
290
|
+
return created;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async refreshChannel(name: string): Promise<void> {
|
|
294
|
+
const key = name.toLowerCase();
|
|
295
|
+
const snap = await this.runtime.getChannelSnapshot(name);
|
|
296
|
+
if (snap === null) return;
|
|
297
|
+
const existing = this.cache.get(key);
|
|
298
|
+
if (snap.members.size === 0) {
|
|
299
|
+
if (existing !== undefined) {
|
|
300
|
+
existing.modes = { ...snap.modes };
|
|
301
|
+
existing.banMasks = new Set(snap.banMasks);
|
|
302
|
+
existing.pendingInvites = new Set(snap.pendingInvites);
|
|
303
|
+
if (snap.topic !== undefined) existing.topic = snap.topic;
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const rebuilt: ChannelState = {
|
|
308
|
+
name,
|
|
309
|
+
nameLower: key,
|
|
310
|
+
modes: { ...snap.modes },
|
|
311
|
+
banMasks: new Set(snap.banMasks),
|
|
312
|
+
members: new Map(snap.members),
|
|
313
|
+
pendingInvites: new Set(snap.pendingInvites),
|
|
314
|
+
createdAt: snap.createdAt,
|
|
315
|
+
};
|
|
316
|
+
if (snap.topic !== undefined) rebuilt.topic = snap.topic;
|
|
317
|
+
this.cache.set(key, rebuilt);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Re-export for type-completeness in callers.
|
|
322
|
+
export { createConnection };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `$disconnect` handler — Lambda entry for `event.requestContext.routeKey === '$disconnect'`.
|
|
3
|
+
*
|
|
4
|
+
* Tears down the connection via {@link cleanupConnection} — deletes the
|
|
5
|
+
* Connections row, every ChannelMembers row owned by it, and the nick
|
|
6
|
+
* reservation if any. Idempotent: every step tolerates a missing row.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
|
|
10
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
11
|
+
import { cleanupConnection } from '../aws-runtime.js';
|
|
12
|
+
import type { TablesConfig } from '../tables.js';
|
|
13
|
+
|
|
14
|
+
/** Parameters accepted by {@link handleDisconnect}. */
|
|
15
|
+
export interface DisconnectParams {
|
|
16
|
+
dynamo: DynamoDBDocumentClient;
|
|
17
|
+
tables: TablesConfig;
|
|
18
|
+
connectionId: string;
|
|
19
|
+
/** Reserved for future "broadcast QUIT to peers" work; unused today. */
|
|
20
|
+
managementApi?: ApiGatewayManagementApi | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Cleans up the connection state. Safe to call after APIGW has already
|
|
25
|
+
* torn the WebSocket down — the management API is best-effort only.
|
|
26
|
+
*/
|
|
27
|
+
export async function handleDisconnect(params: DisconnectParams): Promise<void> {
|
|
28
|
+
await cleanupConnection(params.dynamo, params.tables, params.connectionId, null);
|
|
29
|
+
}
|