serverless-ircd 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/CHANGELOG.md +436 -0
- package/README.md +127 -84
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +5 -1
- package/apps/cf-worker/wrangler.toml +66 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +57 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +267 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +126 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +93 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1186 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +11 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +19 -14
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +871 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
- package/packages/aws-adapter/src/config-loader.ts +126 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +330 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
- package/packages/aws-adapter/src/handlers/index.ts +293 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +74 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
- package/packages/aws-adapter/tests/account-store.test.ts +280 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +473 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/cf-runtime.ts +42 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +135 -0
- package/packages/cf-adapter/src/connection-do.ts +119 -0
- package/packages/cf-adapter/src/env.ts +27 -1
- package/packages/cf-adapter/src/index.ts +2 -1
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
- package/packages/cf-adapter/tests/worker/main.ts +2 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +10 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
- package/packages/irc-core/package.json +13 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +63 -10
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +79 -21
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +270 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +7 -0
- package/packages/irc-core/src/ports.ts +719 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +88 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- package/packages/irc-core/tests/commands/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +380 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/config.test.ts +721 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +186 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +160 -0
- package/packages/irc-server/tests/actor.test.ts +690 -15
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +204 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS Lambda-flavoured {@link IrcRuntime}.
|
|
3
|
+
*
|
|
4
|
+
* Backed by DynamoDB (Connections / ChannelMeta / ChannelMembers / Nicks)
|
|
5
|
+
* for authoritative state and `ApiGatewayManagementApi` for cross-connection
|
|
6
|
+
* WebSocket fanout. Constructed fresh per `$default` Lambda invocation;
|
|
7
|
+
* cheap to build, no caching across events.
|
|
8
|
+
*
|
|
9
|
+
* Pattern mirrors `cf-adapter/src/cf-runtime.ts`:
|
|
10
|
+
* - Calls whose target *is* the bound connection shortcut through the
|
|
11
|
+
* injected {@link AwsRuntimeHandlers} — no APIGW round-trip.
|
|
12
|
+
* - Cross-connection calls hit DynamoDB (the authoritative store).
|
|
13
|
+
* - `send()` to a connection whose APIGW socket is gone catches
|
|
14
|
+
* `GoneException` and triggers {@link cleanupConnection}.
|
|
15
|
+
*
|
|
16
|
+
* Membership atomicity: JOIN/PART/KICK run as a single
|
|
17
|
+
* `TransactWriteItems` spanning `ChannelMembers` and
|
|
18
|
+
* `Connections.joinedChannels` — the two cannot diverge even under
|
|
19
|
+
* concurrent `$default` invocations for the same connection. A
|
|
20
|
+
* `TransactionConflict` (concurrent item access) is retried with a
|
|
21
|
+
* small linear backoff; `ADD`/`DELETE` on the String Set is naturally
|
|
22
|
+
* idempotent so retries never duplicate state.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
type ApiGatewayManagementApi,
|
|
27
|
+
GoneException,
|
|
28
|
+
} from '@aws-sdk/client-apigatewaymanagementapi';
|
|
29
|
+
import type { TransactWriteItem } from '@aws-sdk/client-dynamodb';
|
|
30
|
+
import type { DynamoDBDocumentClient, NativeAttributeValue } from '@aws-sdk/lib-dynamodb';
|
|
31
|
+
import {
|
|
32
|
+
DeleteCommand,
|
|
33
|
+
GetCommand,
|
|
34
|
+
PutCommand,
|
|
35
|
+
QueryCommand,
|
|
36
|
+
ScanCommand,
|
|
37
|
+
TransactWriteCommand,
|
|
38
|
+
UpdateCommand,
|
|
39
|
+
} from '@aws-sdk/lib-dynamodb';
|
|
40
|
+
import {
|
|
41
|
+
type ChanName,
|
|
42
|
+
type ChanSnapshot,
|
|
43
|
+
type ChannelDelta,
|
|
44
|
+
type ChannelModes,
|
|
45
|
+
type ChannelState,
|
|
46
|
+
type ChannelTopic,
|
|
47
|
+
type Clock,
|
|
48
|
+
type ConnId,
|
|
49
|
+
type ConnSnapshot,
|
|
50
|
+
type ConnectionState,
|
|
51
|
+
type Nick,
|
|
52
|
+
type RawLine,
|
|
53
|
+
type RosterEntry,
|
|
54
|
+
SystemClock,
|
|
55
|
+
emptyModes,
|
|
56
|
+
toSnapshot as toConnSnapshot,
|
|
57
|
+
} from '@serverless-ircd/irc-core';
|
|
58
|
+
import type { IrcRuntime } from '@serverless-ircd/irc-server';
|
|
59
|
+
import {
|
|
60
|
+
type MarshalledChannelMember,
|
|
61
|
+
type MarshalledChannelMeta,
|
|
62
|
+
type MarshalledConnection,
|
|
63
|
+
type MarshalledNick,
|
|
64
|
+
marshalChannelMember,
|
|
65
|
+
marshalConnection,
|
|
66
|
+
marshalNick,
|
|
67
|
+
unmarshalChannelMember,
|
|
68
|
+
unmarshalChannelMeta,
|
|
69
|
+
unmarshalConnection,
|
|
70
|
+
} from './serialize.js';
|
|
71
|
+
import type { TablesConfig } from './tables.js';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Per-connection transport callbacks the Lambda handler wires up. Mirrors
|
|
75
|
+
* {@link CfConnectionHandlers} in cf-adapter — same shape, same semantics.
|
|
76
|
+
*
|
|
77
|
+
* - `send(lines)` writes already-framed bytes to the bound socket.
|
|
78
|
+
* In a Lambda `$default` invocation this pushes into the handler's
|
|
79
|
+
* `received[]` (the APIGW echo back to the caller). In tests this
|
|
80
|
+
* is the in-memory `received` array.
|
|
81
|
+
* - `disconnect(reason?)` closes the bound transport. In Lambda this
|
|
82
|
+
* is a no-op — the canonical disconnect path is the APIGW `$disconnect`
|
|
83
|
+
* route, which fires after the socket is already gone.
|
|
84
|
+
* - `snapshot()` returns the in-memory ConnectionState the actor is
|
|
85
|
+
* mutating this frame. Critical so `getConnection(boundId)` returns
|
|
86
|
+
* the live view (and dispatch's reducer-observable effects see
|
|
87
|
+
* mutations made earlier in the same frame).
|
|
88
|
+
*/
|
|
89
|
+
export interface AwsRuntimeHandlers {
|
|
90
|
+
send: (lines: RawLine[]) => void;
|
|
91
|
+
disconnect: (reason?: string) => void;
|
|
92
|
+
snapshot: () => ConnectionState | undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Minimal port for pushing bytes to a remote WebSocket connection. Both
|
|
97
|
+
* `ApiGatewayManagementApi` and test doubles satisfy this structurally —
|
|
98
|
+
* aws-runtime does not care which it gets.
|
|
99
|
+
*/
|
|
100
|
+
export interface PostToConnection {
|
|
101
|
+
postToConnection(input: { ConnectionId: ConnId; Data: string }): Promise<unknown>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Constructor options for {@link AwsRuntime}. */
|
|
105
|
+
export interface AwsRuntimeOptions {
|
|
106
|
+
dynamo: DynamoDBDocumentClient;
|
|
107
|
+
tables: TablesConfig;
|
|
108
|
+
connId: ConnId;
|
|
109
|
+
handlers: AwsRuntimeHandlers;
|
|
110
|
+
/**
|
|
111
|
+
* Backend used by {@link send} for cross-connection fanout. May be:
|
|
112
|
+
* - a real `ApiGatewayManagementApi` client (production),
|
|
113
|
+
* - a test double that satisfies {@link PostToConnection} (integration
|
|
114
|
+
* tests route to other actors' `received` arrays),
|
|
115
|
+
* - `null` when no cross-connection send is ever needed (self-only
|
|
116
|
+
* unit tests). `send` to a non-self connection is a silent no-op
|
|
117
|
+
* in that mode — the caller is asserting on DynamoDB state only.
|
|
118
|
+
*/
|
|
119
|
+
managementApi: ApiGatewayManagementApi | PostToConnection | null;
|
|
120
|
+
clock?: Clock;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* AWS-flavoured {@link IrcRuntime} bound to one connection.
|
|
125
|
+
*
|
|
126
|
+
* Construct one per inbound Lambda event.
|
|
127
|
+
*/
|
|
128
|
+
export class AwsRuntime implements IrcRuntime {
|
|
129
|
+
private readonly dynamo: DynamoDBDocumentClient;
|
|
130
|
+
private readonly tables: TablesConfig;
|
|
131
|
+
private readonly connId: ConnId;
|
|
132
|
+
private readonly handlers: AwsRuntimeHandlers;
|
|
133
|
+
private readonly managementApi: ApiGatewayManagementApi | PostToConnection | null;
|
|
134
|
+
private readonly clock: Clock;
|
|
135
|
+
|
|
136
|
+
constructor(opts: AwsRuntimeOptions) {
|
|
137
|
+
this.dynamo = opts.dynamo;
|
|
138
|
+
this.tables = opts.tables;
|
|
139
|
+
this.connId = opts.connId;
|
|
140
|
+
this.handlers = opts.handlers;
|
|
141
|
+
this.managementApi = opts.managementApi;
|
|
142
|
+
this.clock = opts.clock ?? SystemClock;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// -------------------------------------------------------------------------
|
|
146
|
+
// Transport
|
|
147
|
+
// -------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
async send(to: ConnId, lines: RawLine[]): Promise<void> {
|
|
150
|
+
if (to === this.connId) {
|
|
151
|
+
// Self-send goes through the handler so the actor's mutations are
|
|
152
|
+
// visible within the same frame (no APIGW round-trip).
|
|
153
|
+
this.handlers.send(lines);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (this.managementApi === null) {
|
|
157
|
+
// Cross-connection send with no backend — silent no-op. The caller
|
|
158
|
+
// is asserting on DynamoDB state only (e.g. a Connections row was
|
|
159
|
+
// written, a nick was reserved). Production never hits this path.
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const data = lines.map((l) => l.text).join('\r\n');
|
|
163
|
+
try {
|
|
164
|
+
await this.managementApi.postToConnection({ ConnectionId: to, Data: data });
|
|
165
|
+
} catch (err: unknown) {
|
|
166
|
+
if (err instanceof GoneException || isGone(err)) {
|
|
167
|
+
await cleanupConnection(this.dynamo, this.tables, to, this.managementApi);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async broadcast(chan: ChanName, lines: RawLine[], except?: ConnId): Promise<void> {
|
|
175
|
+
const key = chan.toLowerCase();
|
|
176
|
+
const members = await this.queryMembers(key);
|
|
177
|
+
for (const entry of members) {
|
|
178
|
+
if (entry.connectionId === except) continue;
|
|
179
|
+
await this.send(entry.connectionId, lines);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async disconnect(conn: ConnId, reason?: string): Promise<void> {
|
|
184
|
+
if (conn === this.connId) {
|
|
185
|
+
this.handlers.disconnect(reason);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// Cross-connection disconnect is a no-op in 040: the Lambda runtime
|
|
189
|
+
// cannot directly close another connection's WebSocket. The canonical
|
|
190
|
+
// disconnect path is the APIGW `$disconnect` route, which fires when
|
|
191
|
+
// the socket is already gone. KICK/PART remove membership rows but
|
|
192
|
+
// do not need to force-close the socket.
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async sendToNick(
|
|
196
|
+
nick: Nick,
|
|
197
|
+
sender: ConnId,
|
|
198
|
+
lines: RawLine[],
|
|
199
|
+
notFoundLines?: RawLine[],
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
const owner = await this.lookupNick(nick);
|
|
202
|
+
if (owner !== null) {
|
|
203
|
+
await this.send(owner, lines);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (notFoundLines !== undefined) {
|
|
207
|
+
await this.send(sender, notFoundLines);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// -------------------------------------------------------------------------
|
|
212
|
+
// Nick registry
|
|
213
|
+
// -------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
async reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }> {
|
|
216
|
+
const row = marshalNick(nick, conn, this.clock.now());
|
|
217
|
+
try {
|
|
218
|
+
await this.dynamo.send(
|
|
219
|
+
new PutCommand({
|
|
220
|
+
TableName: this.tables.Nicks,
|
|
221
|
+
Item: row as unknown as Record<string, NativeAttributeValue>,
|
|
222
|
+
ConditionExpression: 'attribute_not_exists(nickLower)',
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
return { ok: true };
|
|
226
|
+
} catch (err: unknown) {
|
|
227
|
+
if (isConditionalCheckFailed(err)) {
|
|
228
|
+
// Re-owning by the same conn is allowed — match the in-memory
|
|
229
|
+
// runtime's idempotent semantics so re-reservation doesn't fail.
|
|
230
|
+
const existing = await this.dynamo.send(
|
|
231
|
+
new GetCommand({ TableName: this.tables.Nicks, Key: { nickLower: nick.toLowerCase() } }),
|
|
232
|
+
);
|
|
233
|
+
if (existing.Item?.connectionId === conn) return { ok: true };
|
|
234
|
+
return { ok: false };
|
|
235
|
+
}
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async changeNick(conn: ConnId, oldNick: Nick, newNick: Nick): Promise<boolean> {
|
|
241
|
+
const reserved = await this.reserveNick(newNick, conn);
|
|
242
|
+
if (!reserved.ok) return false;
|
|
243
|
+
await this.releaseNick(oldNick);
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async releaseNick(nick: Nick): Promise<void> {
|
|
248
|
+
try {
|
|
249
|
+
await this.dynamo.send(
|
|
250
|
+
new DeleteCommand({
|
|
251
|
+
TableName: this.tables.Nicks,
|
|
252
|
+
Key: { nickLower: nick.toLowerCase() },
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
} catch (err: unknown) {
|
|
256
|
+
// Idempotent: ResourceNotFound is the only error we tolerate; the
|
|
257
|
+
// SDK v3 delete is already idempotent on missing rows, but defensive.
|
|
258
|
+
if (!isResourceNotFound(err)) throw err;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// -------------------------------------------------------------------------
|
|
263
|
+
// Channel ownership
|
|
264
|
+
// -------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
async applyChannelDelta(chan: ChanName, delta: ChannelDelta): Promise<void> {
|
|
267
|
+
const key = chan.toLowerCase();
|
|
268
|
+
// Ensure a meta row exists so getChannelSnapshot can find the channel
|
|
269
|
+
// even when the delta only adds memberships (common JOIN case).
|
|
270
|
+
await this.ensureMetaRow(chan, key);
|
|
271
|
+
if (delta.memberships !== undefined && delta.memberships.length > 0) {
|
|
272
|
+
await this.applyMembershipTransaction(key, delta.memberships);
|
|
273
|
+
}
|
|
274
|
+
if (delta.modeChanges !== undefined && delta.modeChanges.length > 0) {
|
|
275
|
+
const baseModes = await this.readMetaModes(key);
|
|
276
|
+
const nextModes = applyModeChanges(baseModes ?? emptyModes(), delta.modeChanges);
|
|
277
|
+
await this.updateMetaModes(key, nextModes);
|
|
278
|
+
}
|
|
279
|
+
if (delta.banMaskChanges !== undefined && delta.banMaskChanges.length > 0) {
|
|
280
|
+
const existing = await this.readBanMasks(key);
|
|
281
|
+
const set = new Set(existing);
|
|
282
|
+
for (const b of delta.banMaskChanges) {
|
|
283
|
+
if (b.type === 'add') set.add(b.mask);
|
|
284
|
+
else set.delete(b.mask);
|
|
285
|
+
}
|
|
286
|
+
await this.updateBanMasks(key, set);
|
|
287
|
+
}
|
|
288
|
+
if (delta.topic !== undefined) {
|
|
289
|
+
await this.updateTopic(key, delta.topic);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// -------------------------------------------------------------------------
|
|
294
|
+
// Lookups
|
|
295
|
+
// -------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
async lookupNick(nick: Nick): Promise<ConnId | null> {
|
|
298
|
+
const result = await this.dynamo.send(
|
|
299
|
+
new GetCommand({ TableName: this.tables.Nicks, Key: { nickLower: nick.toLowerCase() } }),
|
|
300
|
+
);
|
|
301
|
+
if (result.Item === undefined) return null;
|
|
302
|
+
return (result.Item as unknown as MarshalledNick).connectionId;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null> {
|
|
306
|
+
// Self-shortcut: the actor's mutations are visible to effects running
|
|
307
|
+
// in the same frame (cf-runtime mirrors this).
|
|
308
|
+
if (conn === this.connId) {
|
|
309
|
+
const live = this.handlers.snapshot();
|
|
310
|
+
return live === undefined ? null : toConnSnapshot(live);
|
|
311
|
+
}
|
|
312
|
+
const row = await this.readConnectionRow(conn);
|
|
313
|
+
if (row === null) return null;
|
|
314
|
+
return toConnSnapshot(unmarshalConnection(row));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async getConnection(conn: ConnId): Promise<ConnectionState | null> {
|
|
318
|
+
if (conn === this.connId) {
|
|
319
|
+
return this.handlers.snapshot() ?? null;
|
|
320
|
+
}
|
|
321
|
+
const row = await this.readConnectionRow(conn);
|
|
322
|
+
if (row === null) return null;
|
|
323
|
+
return unmarshalConnection(row);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async getChannelSnapshot(chan: ChanName): Promise<ChanSnapshot | null> {
|
|
327
|
+
const key = chan.toLowerCase();
|
|
328
|
+
const meta = await this.readMeta(key);
|
|
329
|
+
if (meta === null) return null;
|
|
330
|
+
const members = await this.queryMembers(key);
|
|
331
|
+
const chanState = unmarshalChannelMeta(
|
|
332
|
+
meta,
|
|
333
|
+
new Map(members.map((m) => [m.connectionId, unmarshalChannelMember(m)] as const)),
|
|
334
|
+
);
|
|
335
|
+
return chanStateToSnapshot(chanState);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
|
|
339
|
+
const key = chan.toLowerCase();
|
|
340
|
+
const members = await this.queryMembers(key);
|
|
341
|
+
const out = new Map<ConnId, ConnectionState>();
|
|
342
|
+
for (const m of members) {
|
|
343
|
+
const row = await this.readConnectionRow(m.connectionId);
|
|
344
|
+
if (row !== null) {
|
|
345
|
+
out.set(m.connectionId, unmarshalConnection(row));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async listChannels(): Promise<ChanSnapshot[]> {
|
|
352
|
+
const result = await this.dynamo.send(new ScanCommand({ TableName: this.tables.ChannelMeta }));
|
|
353
|
+
const items = (result.Items ?? []) as unknown as MarshalledChannelMeta[];
|
|
354
|
+
const out: ChanSnapshot[] = [];
|
|
355
|
+
for (const meta of items) {
|
|
356
|
+
const members = await this.queryMembers(meta.channelName);
|
|
357
|
+
const chanState = unmarshalChannelMeta(
|
|
358
|
+
meta,
|
|
359
|
+
new Map(members.map((m) => [m.connectionId, unmarshalChannelMember(m)] as const)),
|
|
360
|
+
);
|
|
361
|
+
out.push(chanStateToSnapshot(chanState));
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// -------------------------------------------------------------------------
|
|
367
|
+
// Public helpers (re-exported surface)
|
|
368
|
+
// -------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Persists the supplied {@link ConnectionState} as a Connections row,
|
|
372
|
+
* updating `idleSince` to `Date.now()` so the TTL stays fresh on
|
|
373
|
+
* every `$default` invocation.
|
|
374
|
+
*/
|
|
375
|
+
async persistConnectionState(state: ConnectionState): Promise<void> {
|
|
376
|
+
const row = marshalConnection(state, this.clock.now());
|
|
377
|
+
await this.dynamo.send(
|
|
378
|
+
new PutCommand({
|
|
379
|
+
TableName: this.tables.Connections,
|
|
380
|
+
Item: row as unknown as Record<string, NativeAttributeValue>,
|
|
381
|
+
}),
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// -------------------------------------------------------------------------
|
|
386
|
+
// Internal — Connections
|
|
387
|
+
// -------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
private async readConnectionRow(conn: ConnId): Promise<MarshalledConnection | null> {
|
|
390
|
+
const result = await this.dynamo.send(
|
|
391
|
+
new GetCommand({
|
|
392
|
+
TableName: this.tables.Connections,
|
|
393
|
+
Key: { connectionId: conn },
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
if (result.Item === undefined) return null;
|
|
397
|
+
return result.Item as unknown as MarshalledConnection;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// -------------------------------------------------------------------------
|
|
401
|
+
// Internal — Channels
|
|
402
|
+
// -------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Atomically applies a batch of roster mutations for one channel.
|
|
406
|
+
* Each mutation produces:
|
|
407
|
+
* - a `Put`/`Delete` on `ChannelMembers` (the authoritative roster), AND
|
|
408
|
+
* - an `ADD`/`DELETE` on the owning connection's `joinedChannels` set.
|
|
409
|
+
*
|
|
410
|
+
* All items go into ONE `TransactWriteItems` so the two tables cannot
|
|
411
|
+
* diverge — even when two `$default` invocations for the same
|
|
412
|
+
* connection race (API Gateway may deliver a client's frames
|
|
413
|
+
* concurrently). ADD/DELETE on the String Set is idempotent, so the
|
|
414
|
+
* retry-on-conflict loop never doubles state.
|
|
415
|
+
*
|
|
416
|
+
* Per-connection updates are coalesced into a single `Update` item
|
|
417
|
+
* because DynamoDB rejects two transact items targeting the same key.
|
|
418
|
+
* Assumption: ≤ ~50 distinct connections per delta (DynamoDB caps
|
|
419
|
+
* TransactWriteItems at 100 items). Reducers today emit one membership
|
|
420
|
+
* per delta, so this is far below the ceiling.
|
|
421
|
+
*/
|
|
422
|
+
private async applyMembershipTransaction(
|
|
423
|
+
key: string,
|
|
424
|
+
memberships: NonNullable<ChannelDelta['memberships']>,
|
|
425
|
+
): Promise<void> {
|
|
426
|
+
const memberOps: Array<{ type: 'add'; entry: RosterEntry } | { type: 'remove'; conn: ConnId }> =
|
|
427
|
+
[];
|
|
428
|
+
const addsByConn = new Map<ConnId, Set<string>>();
|
|
429
|
+
const removesByConn = new Map<ConnId, Set<string>>();
|
|
430
|
+
|
|
431
|
+
for (const m of memberships) {
|
|
432
|
+
if (m.type === 'add') {
|
|
433
|
+
if (m.nick === undefined) continue;
|
|
434
|
+
const entry: RosterEntry = {
|
|
435
|
+
conn: m.conn,
|
|
436
|
+
nick: m.nick,
|
|
437
|
+
op: m.op ?? false,
|
|
438
|
+
voice: m.voice ?? false,
|
|
439
|
+
};
|
|
440
|
+
memberOps.push({ type: 'add', entry });
|
|
441
|
+
collectChannel(addsByConn, m.conn, key);
|
|
442
|
+
} else {
|
|
443
|
+
memberOps.push({ type: 'remove', conn: m.conn });
|
|
444
|
+
collectChannel(removesByConn, m.conn, key);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (memberOps.length === 0) return;
|
|
449
|
+
|
|
450
|
+
const transactItems: TransactWriteItem[] = [];
|
|
451
|
+
|
|
452
|
+
for (const op of memberOps) {
|
|
453
|
+
if (op.type === 'add') {
|
|
454
|
+
transactItems.push({
|
|
455
|
+
Put: {
|
|
456
|
+
TableName: this.tables.ChannelMembers,
|
|
457
|
+
Item: marshalChannelMember(key, op.entry) as unknown as Record<
|
|
458
|
+
string,
|
|
459
|
+
NativeAttributeValue
|
|
460
|
+
>,
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
} else {
|
|
464
|
+
transactItems.push({
|
|
465
|
+
Delete: {
|
|
466
|
+
TableName: this.tables.ChannelMembers,
|
|
467
|
+
Key: {
|
|
468
|
+
channelName: key,
|
|
469
|
+
connectionId: op.conn,
|
|
470
|
+
} as unknown as Record<string, NativeAttributeValue>,
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
for (const [conn, chans] of addsByConn) {
|
|
477
|
+
transactItems.push({
|
|
478
|
+
Update: {
|
|
479
|
+
TableName: this.tables.Connections,
|
|
480
|
+
Key: { connectionId: conn } as unknown as Record<string, NativeAttributeValue>,
|
|
481
|
+
UpdateExpression: 'ADD joinedChannels :chans',
|
|
482
|
+
ExpressionAttributeValues: {
|
|
483
|
+
':chans': chans as unknown as NativeAttributeValue,
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
for (const [conn, chans] of removesByConn) {
|
|
489
|
+
transactItems.push({
|
|
490
|
+
Update: {
|
|
491
|
+
TableName: this.tables.Connections,
|
|
492
|
+
Key: { connectionId: conn } as unknown as Record<string, NativeAttributeValue>,
|
|
493
|
+
UpdateExpression: 'DELETE joinedChannels :chans',
|
|
494
|
+
ExpressionAttributeValues: {
|
|
495
|
+
':chans': chans as unknown as NativeAttributeValue,
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
await this.sendTransactWithRetry(transactItems);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Runs a `TransactWriteItems` with a bounded retry loop. Only
|
|
506
|
+
* `TransactionConflict` cancellations are retried (concurrent item
|
|
507
|
+
* access); condition/throughput failures rethrow immediately.
|
|
508
|
+
*/
|
|
509
|
+
private async sendTransactWithRetry(items: TransactWriteItem[]): Promise<void> {
|
|
510
|
+
const MAX_ATTEMPTS = 6;
|
|
511
|
+
for (let attempt = 1; ; attempt++) {
|
|
512
|
+
try {
|
|
513
|
+
await this.dynamo.send(new TransactWriteCommand({ TransactItems: items }));
|
|
514
|
+
return;
|
|
515
|
+
} catch (err: unknown) {
|
|
516
|
+
if (isTransactionConflict(err) && attempt < MAX_ATTEMPTS) {
|
|
517
|
+
await sleep(RETRY_BACKOFF_BASE_MS * attempt);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
throw err;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private async ensureMetaRow(displayName: ChanName, key: string): Promise<void> {
|
|
526
|
+
try {
|
|
527
|
+
await this.dynamo.send(
|
|
528
|
+
new PutCommand({
|
|
529
|
+
TableName: this.tables.ChannelMeta,
|
|
530
|
+
Item: {
|
|
531
|
+
channelName: key,
|
|
532
|
+
displayName,
|
|
533
|
+
modes: emptyModes(),
|
|
534
|
+
banMasks: [],
|
|
535
|
+
pendingInvites: [],
|
|
536
|
+
createdAt: this.clock.now(),
|
|
537
|
+
},
|
|
538
|
+
ConditionExpression: 'attribute_not_exists(channelName)',
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
541
|
+
} catch (err: unknown) {
|
|
542
|
+
if (isConditionalCheckFailed(err)) return;
|
|
543
|
+
throw err;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private async readMeta(key: string): Promise<MarshalledChannelMeta | null> {
|
|
548
|
+
const result = await this.dynamo.send(
|
|
549
|
+
new GetCommand({ TableName: this.tables.ChannelMeta, Key: { channelName: key } }),
|
|
550
|
+
);
|
|
551
|
+
if (result.Item === undefined) return null;
|
|
552
|
+
return result.Item as unknown as MarshalledChannelMeta;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private async readMetaModes(key: string): Promise<ChannelModes | null> {
|
|
556
|
+
const meta = await this.readMeta(key);
|
|
557
|
+
return meta?.modes ?? null;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private async readBanMasks(key: string): Promise<string[]> {
|
|
561
|
+
const meta = await this.readMeta(key);
|
|
562
|
+
return meta?.banMasks ?? [];
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private async updateMetaModes(key: string, modes: ChannelModes): Promise<void> {
|
|
566
|
+
await this.dynamo.send(
|
|
567
|
+
new UpdateCommand({
|
|
568
|
+
TableName: this.tables.ChannelMeta,
|
|
569
|
+
Key: { channelName: key },
|
|
570
|
+
UpdateExpression: 'SET modes = :m',
|
|
571
|
+
ExpressionAttributeValues: { ':m': modes },
|
|
572
|
+
}),
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private async updateBanMasks(key: string, masks: Set<string>): Promise<void> {
|
|
577
|
+
await this.dynamo.send(
|
|
578
|
+
new UpdateCommand({
|
|
579
|
+
TableName: this.tables.ChannelMeta,
|
|
580
|
+
Key: { channelName: key },
|
|
581
|
+
UpdateExpression: 'SET banMasks = :b',
|
|
582
|
+
ExpressionAttributeValues: { ':b': [...masks] },
|
|
583
|
+
}),
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private async updateTopic(key: string, topic: ChannelTopic | null): Promise<void> {
|
|
588
|
+
if (topic === null) {
|
|
589
|
+
await this.dynamo.send(
|
|
590
|
+
new UpdateCommand({
|
|
591
|
+
TableName: this.tables.ChannelMeta,
|
|
592
|
+
Key: { channelName: key },
|
|
593
|
+
UpdateExpression: 'REMOVE topicText, topicSetter, topicSetAt',
|
|
594
|
+
}),
|
|
595
|
+
);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
await this.dynamo.send(
|
|
599
|
+
new UpdateCommand({
|
|
600
|
+
TableName: this.tables.ChannelMeta,
|
|
601
|
+
Key: { channelName: key },
|
|
602
|
+
UpdateExpression: 'SET topicText = :t, topicSetter = :s, topicSetAt = :a',
|
|
603
|
+
ExpressionAttributeValues: {
|
|
604
|
+
':t': topic.text,
|
|
605
|
+
':s': topic.setter,
|
|
606
|
+
':a': topic.setAt,
|
|
607
|
+
},
|
|
608
|
+
}),
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private async queryMembers(key: string): Promise<MarshalledChannelMember[]> {
|
|
613
|
+
const result = await this.dynamo.send(
|
|
614
|
+
new QueryCommand({
|
|
615
|
+
TableName: this.tables.ChannelMembers,
|
|
616
|
+
KeyConditionExpression: 'channelName = :k',
|
|
617
|
+
ExpressionAttributeValues: { ':k': key },
|
|
618
|
+
}),
|
|
619
|
+
);
|
|
620
|
+
return (result.Items ?? []) as unknown as MarshalledChannelMember[];
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
// cleanupConnection — re-exported via index.ts and called from $disconnect
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Tears down a connection: deletes its Connections row, removes every
|
|
630
|
+
* ChannelMembers row owned by it, releases its nick (if any), and — when
|
|
631
|
+
* a `managementApi` is supplied — broadcasts a `QUIT` line to every peer
|
|
632
|
+
* that shares a channel with the disconnecting connection.
|
|
633
|
+
*
|
|
634
|
+
* Idempotent: every step tolerates a missing row. Safe to call from
|
|
635
|
+
* `$disconnect`, from the `send`/`broadcast` `GoneException` path, and
|
|
636
|
+
* from the sweeper Lambda.
|
|
637
|
+
*
|
|
638
|
+
* Recursion safety: the Connections row is deleted *before* the QUIT
|
|
639
|
+
* fanout so a recursive `cleanupConnection` triggered by a gone peer
|
|
640
|
+
* during fanout finds the row already gone and returns immediately —
|
|
641
|
+
* no unbounded recursion.
|
|
642
|
+
*
|
|
643
|
+
* `managementApi` is optional because callers that never need fanout
|
|
644
|
+
* (the sweeper Lambda, or a no-op test) may pass `null`.
|
|
645
|
+
*/
|
|
646
|
+
export async function cleanupConnection(
|
|
647
|
+
dynamo: DynamoDBDocumentClient,
|
|
648
|
+
tables: TablesConfig,
|
|
649
|
+
connId: ConnId,
|
|
650
|
+
managementApi: ApiGatewayManagementApi | PostToConnection | null,
|
|
651
|
+
quitMessage = 'Client Quit',
|
|
652
|
+
): Promise<void> {
|
|
653
|
+
const connRow = await dynamo.send(
|
|
654
|
+
new GetCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
|
|
655
|
+
);
|
|
656
|
+
const item = connRow.Item as unknown as MarshalledConnection | undefined;
|
|
657
|
+
if (item === undefined) return; // idempotent — already cleaned up
|
|
658
|
+
|
|
659
|
+
// Delete the Connections row FIRST. A recursive cleanup triggered by
|
|
660
|
+
// a gone peer during QUIT fanout reads this same row, finds it gone,
|
|
661
|
+
// and returns — bounding the recursion at one extra hop.
|
|
662
|
+
await dynamo.send(
|
|
663
|
+
new DeleteCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
if (item.nick !== undefined) {
|
|
667
|
+
try {
|
|
668
|
+
await dynamo.send(
|
|
669
|
+
new DeleteCommand({
|
|
670
|
+
TableName: tables.Nicks,
|
|
671
|
+
Key: { nickLower: item.nick.toLowerCase() },
|
|
672
|
+
}),
|
|
673
|
+
);
|
|
674
|
+
} catch {
|
|
675
|
+
// Idempotent — the nick may have been released already.
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Broadcast QUIT to peers in shared channels *before* removing the
|
|
680
|
+
// disconnecting connection's membership rows — the roster query needs
|
|
681
|
+
// those rows to resolve the fanout targets.
|
|
682
|
+
if (item.nick !== undefined && managementApi !== null) {
|
|
683
|
+
await broadcastQuitToChannels(dynamo, tables, connId, item, managementApi, quitMessage);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const joined = item.joinedChannels ?? [];
|
|
687
|
+
for (const chan of joined) {
|
|
688
|
+
try {
|
|
689
|
+
await dynamo.send(
|
|
690
|
+
new DeleteCommand({
|
|
691
|
+
TableName: tables.ChannelMembers,
|
|
692
|
+
Key: { channelName: chan.toLowerCase(), connectionId: connId },
|
|
693
|
+
}),
|
|
694
|
+
);
|
|
695
|
+
} catch {
|
|
696
|
+
// Idempotent — the membership may have been removed already.
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Builds the canonical `:nick!user@host QUIT :<reason>` line for the
|
|
703
|
+
* disconnecting connection and fans it out to every *other* member of
|
|
704
|
+
* each channel the connection had joined. A peer whose APIGW socket is
|
|
705
|
+
* already gone raises `GoneException`, which is caught and routed back
|
|
706
|
+
* through {@link cleanupConnection} for lazy cleanup — a partially
|
|
707
|
+
* torn-down roster never crashes the fanout loop.
|
|
708
|
+
*
|
|
709
|
+
* Shared by both the `$disconnect` route and the `send()`/`broadcast()`
|
|
710
|
+
* `GoneException` cleanup path.
|
|
711
|
+
*/
|
|
712
|
+
async function broadcastQuitToChannels(
|
|
713
|
+
dynamo: DynamoDBDocumentClient,
|
|
714
|
+
tables: TablesConfig,
|
|
715
|
+
connId: ConnId,
|
|
716
|
+
item: MarshalledConnection,
|
|
717
|
+
managementApi: ApiGatewayManagementApi | PostToConnection,
|
|
718
|
+
quitMessage: string,
|
|
719
|
+
): Promise<void> {
|
|
720
|
+
const hostmask = buildHostmask(item);
|
|
721
|
+
if (hostmask === undefined) return; // no nick → nothing to attribute the QUIT to
|
|
722
|
+
const line = `:${hostmask} QUIT :${quitMessage}`;
|
|
723
|
+
const joined = item.joinedChannels ?? [];
|
|
724
|
+
for (const chan of joined) {
|
|
725
|
+
const members = await queryChannelMembersRows(dynamo, tables, chan.toLowerCase());
|
|
726
|
+
for (const member of members) {
|
|
727
|
+
if (member.connectionId === connId) continue; // never echo to self
|
|
728
|
+
try {
|
|
729
|
+
await managementApi.postToConnection({ ConnectionId: member.connectionId, Data: line });
|
|
730
|
+
} catch (err: unknown) {
|
|
731
|
+
if (err instanceof GoneException || isGone(err)) {
|
|
732
|
+
await cleanupConnection(dynamo, tables, member.connectionId, managementApi, quitMessage);
|
|
733
|
+
} else {
|
|
734
|
+
throw err;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Builds the `nick!user@host` hostmask from a stored connection row,
|
|
743
|
+
* omitting the absent parts. Mirrors {@link hostmaskOf} in irc-core but
|
|
744
|
+
* operates on the marshalled shape (no full unmarshal needed).
|
|
745
|
+
*/
|
|
746
|
+
function buildHostmask(item: MarshalledConnection): string | undefined {
|
|
747
|
+
if (item.nick === undefined) return undefined;
|
|
748
|
+
let out: string = item.nick;
|
|
749
|
+
if (item.user !== undefined) out = `${out}!${item.user}`;
|
|
750
|
+
if (item.host !== undefined) out = `${out}@${item.host}`;
|
|
751
|
+
return out;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Queries the raw `ChannelMembers` rows for one channel (by lowercased key). */
|
|
755
|
+
async function queryChannelMembersRows(
|
|
756
|
+
dynamo: DynamoDBDocumentClient,
|
|
757
|
+
tables: TablesConfig,
|
|
758
|
+
key: string,
|
|
759
|
+
): Promise<MarshalledChannelMember[]> {
|
|
760
|
+
const result = await dynamo.send(
|
|
761
|
+
new QueryCommand({
|
|
762
|
+
TableName: tables.ChannelMembers,
|
|
763
|
+
KeyConditionExpression: 'channelName = :k',
|
|
764
|
+
ExpressionAttributeValues: { ':k': key },
|
|
765
|
+
}),
|
|
766
|
+
);
|
|
767
|
+
return (result.Items ?? []) as unknown as MarshalledChannelMember[];
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ---------------------------------------------------------------------------
|
|
771
|
+
// Helpers
|
|
772
|
+
// ---------------------------------------------------------------------------
|
|
773
|
+
|
|
774
|
+
/** Base backoff (ms) for `TransactWriteItems` conflict retries — linear. */
|
|
775
|
+
const RETRY_BACKOFF_BASE_MS = 5;
|
|
776
|
+
|
|
777
|
+
/** Adds `chan` to the per-connection channel set, creating it if missing. */
|
|
778
|
+
function collectChannel(map: Map<ConnId, Set<string>>, conn: ConnId, chan: string): void {
|
|
779
|
+
const set = map.get(conn) ?? new Set<string>();
|
|
780
|
+
set.add(chan);
|
|
781
|
+
map.set(conn, set);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Resolves after `ms` milliseconds — used by the transact retry loop. */
|
|
785
|
+
function sleep(ms: number): Promise<void> {
|
|
786
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* True iff the SDK error is a `TransactionCanceledException` whose
|
|
791
|
+
* cancellation reasons include a `TransactionConflict` (concurrent
|
|
792
|
+
* item access). Condition-check and validation failures return false
|
|
793
|
+
* so the caller rethrows instead of retrying pointlessly.
|
|
794
|
+
*/
|
|
795
|
+
function isTransactionConflict(err: unknown): boolean {
|
|
796
|
+
if (err === null || typeof err !== 'object') return false;
|
|
797
|
+
const e = err as { name?: string; CancellationReasons?: Array<{ Code?: string }> };
|
|
798
|
+
if (e.name !== 'TransactionCanceledException') return false;
|
|
799
|
+
const reasons = e.CancellationReasons ?? [];
|
|
800
|
+
return reasons.some((r) => r?.Code === 'TransactionConflict');
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Applies a sequence of `ModeChange` to a `ChannelModes` (mutates in place). */
|
|
804
|
+
function applyModeChanges(modes: ChannelModes, changes: ChannelDelta['modeChanges']): ChannelModes {
|
|
805
|
+
if (changes === undefined) return modes;
|
|
806
|
+
for (const change of changes) {
|
|
807
|
+
switch (change.mode) {
|
|
808
|
+
case 'inviteOnly':
|
|
809
|
+
case 'topicLock':
|
|
810
|
+
case 'noExternal':
|
|
811
|
+
case 'moderated':
|
|
812
|
+
case 'secret':
|
|
813
|
+
case 'private':
|
|
814
|
+
modes[change.mode] = change.set;
|
|
815
|
+
break;
|
|
816
|
+
case 'key':
|
|
817
|
+
if (change.set) {
|
|
818
|
+
if (typeof change.arg === 'string') modes.key = change.arg;
|
|
819
|
+
} else {
|
|
820
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
821
|
+
delete modes.key;
|
|
822
|
+
}
|
|
823
|
+
break;
|
|
824
|
+
case 'limit':
|
|
825
|
+
if (change.set) {
|
|
826
|
+
if (typeof change.arg === 'number') modes.limit = change.arg;
|
|
827
|
+
} else {
|
|
828
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
829
|
+
delete modes.limit;
|
|
830
|
+
}
|
|
831
|
+
break;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return modes;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** ChannelState → ChanSnapshot (mirrors irc-core's `toSnapshot`). */
|
|
838
|
+
function chanStateToSnapshot(chan: ChannelState): ChanSnapshot {
|
|
839
|
+
const out: ChanSnapshot = {
|
|
840
|
+
name: chan.name,
|
|
841
|
+
nameLower: chan.nameLower,
|
|
842
|
+
modes: chan.modes,
|
|
843
|
+
banMasks: chan.banMasks,
|
|
844
|
+
members: chan.members,
|
|
845
|
+
pendingInvites: chan.pendingInvites,
|
|
846
|
+
createdAt: chan.createdAt,
|
|
847
|
+
...(chan.topic !== undefined ? { topic: chan.topic } : {}),
|
|
848
|
+
};
|
|
849
|
+
return out;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** True iff the SDK error is a `ConditionalCheckFailedException`. */
|
|
853
|
+
function isConditionalCheckFailed(err: unknown): boolean {
|
|
854
|
+
if (err === null || typeof err !== 'object') return false;
|
|
855
|
+
const name = (err as { name?: string }).name;
|
|
856
|
+
return name === 'ConditionalCheckFailedException';
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** True iff the SDK error is a `ResourceNotFoundException`. */
|
|
860
|
+
function isResourceNotFound(err: unknown): boolean {
|
|
861
|
+
if (err === null || typeof err !== 'object') return false;
|
|
862
|
+
const name = (err as { name?: string }).name;
|
|
863
|
+
return name === 'ResourceNotFoundException';
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** True iff the SDK error indicates the APIGW connection is gone. */
|
|
867
|
+
function isGone(err: unknown): boolean {
|
|
868
|
+
if (err === null || typeof err !== 'object') return false;
|
|
869
|
+
const name = (err as { name?: string }).name;
|
|
870
|
+
return name === 'GoneException' || name === 'ForbiddenException';
|
|
871
|
+
}
|