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,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `$ping` handler — Lambda entry invoked on a fixed EventBridge schedule
|
|
3
|
+
* (rate(1 minute) in the CDK stack). Scans `Connections` for rows past
|
|
4
|
+
* the configured idle thresholds and either:
|
|
5
|
+
*
|
|
6
|
+
* • posts a server-initiated `PING :<token>` to the client via
|
|
7
|
+
* `ApiGatewayManagementApi.postToConnection`, or
|
|
8
|
+
* • tears the connection down via {@link cleanupConnection} when the
|
|
9
|
+
* client has been silent past the PONG window.
|
|
10
|
+
*
|
|
11
|
+
* Why a batch-scan Lambda (not per-connection EventBridge one-shots):
|
|
12
|
+
* the architecture sketch in `docs/AWS-Adapter-Architecture.md` §6
|
|
13
|
+
* mentions per-connection Scheduler entries, but that model scales to
|
|
14
|
+
* N scheduler entries per active user and complicates cancellation
|
|
15
|
+
* on every `$default`. A single scheduled Lambda that scans and acts
|
|
16
|
+
* is simpler, cheaper, and matches how the gone-connection sweeper
|
|
17
|
+
* already works.
|
|
18
|
+
*
|
|
19
|
+
* Decision parity with the Cloudflare adapter: this is the AWS
|
|
20
|
+
* equivalent of `ConnectionDO.alarm()` in `cf-adapter/src/connection-do.ts`,
|
|
21
|
+
* which sends a PING every `pingIntervalMs` and disconnects when
|
|
22
|
+
* `lastSeen + pongTimeoutMs < now`. The AWS batch Lambda makes the
|
|
23
|
+
* same per-connection decision for every connection in a single pass.
|
|
24
|
+
*
|
|
25
|
+
* Idle timer: every `$default` invocation refreshes `idleSince` to
|
|
26
|
+
* `Date.now()` (see `handleDefault`'s `persistState`), so any frame
|
|
27
|
+
* from the client — including the `PONG` reply this handler's PING
|
|
28
|
+
* elicits — resets the idle window. `idleSince` is also the DynamoDB
|
|
29
|
+
* TTL attribute, so connections that slip past both this Lambda and
|
|
30
|
+
* the sweeper are eventually reaped by DynamoDB itself.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { randomUUID } from 'node:crypto';
|
|
34
|
+
import {
|
|
35
|
+
type ApiGatewayManagementApi,
|
|
36
|
+
GoneException,
|
|
37
|
+
} from '@aws-sdk/client-apigatewaymanagementapi';
|
|
38
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
39
|
+
import { ScanCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
|
|
40
|
+
import type { ConnId, RawLine } from '@serverless-ircd/irc-core';
|
|
41
|
+
import { cleanupConnection } from '../aws-runtime.js';
|
|
42
|
+
import type { PostToConnection } from '../aws-runtime.js';
|
|
43
|
+
import type { TablesConfig } from '../tables.js';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Default PING cadence — matches `cf-adapter`'s
|
|
47
|
+
* `DEFAULT_PING_INTERVAL_MS` so a connection sees the same liveness
|
|
48
|
+
* pressure on both substrates.
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULT_PING_INTERVAL_MS = 60_000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Default no-PONG disconnect window — total idle to disconnect equals
|
|
54
|
+
* `DEFAULT_PING_INTERVAL_MS + DEFAULT_PONG_TIMEOUT_MS` (150 s), giving
|
|
55
|
+
* the client ~90 s after the first PING to reply. Mirrors the CF
|
|
56
|
+
* adapter's `DEFAULT_PONG_TIMEOUT_MS`.
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_PONG_TIMEOUT_MS = 90_000;
|
|
59
|
+
|
|
60
|
+
/** Parameters accepted by {@link handlePingCheck}. */
|
|
61
|
+
export interface PingCheckParams {
|
|
62
|
+
dynamo: DynamoDBDocumentClient;
|
|
63
|
+
tables: TablesConfig;
|
|
64
|
+
/**
|
|
65
|
+
* Backend used to push `PING` bytes to the remote socket. May be:
|
|
66
|
+
* - a real `ApiGatewayManagementApi` client (production),
|
|
67
|
+
* - a test double that satisfies {@link PostToConnection},
|
|
68
|
+
* - `null` when running in a context that has no APIGW endpoint
|
|
69
|
+
* (the PING path becomes a silent no-op and only the disconnect
|
|
70
|
+
* branch can fire). Used by the sweeper-style unit tests.
|
|
71
|
+
*/
|
|
72
|
+
managementApi: ApiGatewayManagementApi | PostToConnection | null;
|
|
73
|
+
/** Wall-clock "now" (default: `Date.now()`). */
|
|
74
|
+
now?: number;
|
|
75
|
+
/** A connection is PINGed when `idleFor >= pingIntervalMs` (default 60 s). */
|
|
76
|
+
pingIntervalMs?: number;
|
|
77
|
+
/**
|
|
78
|
+
* A connection is disconnected when `idleFor >= pingIntervalMs + pongTimeoutMs`
|
|
79
|
+
* (default 90 s, so the total idle-to-disconnect is 150 s). The
|
|
80
|
+
* client gets roughly this long after the first PING to send PONG.
|
|
81
|
+
*/
|
|
82
|
+
pongTimeoutMs?: number;
|
|
83
|
+
/**
|
|
84
|
+
* Overrides the PING token source. Defaults to `crypto.randomUUID()`.
|
|
85
|
+
* Exposed for determinism in tests; production callers should not
|
|
86
|
+
* set this.
|
|
87
|
+
*/
|
|
88
|
+
makeToken?: () => string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Result returned by {@link handlePingCheck}. */
|
|
92
|
+
export interface PingCheckResult {
|
|
93
|
+
/** Total rows examined (post-pagination sum). */
|
|
94
|
+
scanned: number;
|
|
95
|
+
/** Rows that received a `PING` this pass. */
|
|
96
|
+
pinged: number;
|
|
97
|
+
/** Rows torn down this pass. */
|
|
98
|
+
disconnected: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Scans `Connections` and applies the PING / disconnect policy per row.
|
|
103
|
+
*
|
|
104
|
+
* Idempotent: running twice pings 0 the second time once the disconnect
|
|
105
|
+
* threshold has elapsed (the row is gone), and PINGing the same live
|
|
106
|
+
* client twice is harmless (clients reply to every PING with a PONG).
|
|
107
|
+
*/
|
|
108
|
+
export async function handlePingCheck(params: PingCheckParams): Promise<PingCheckResult> {
|
|
109
|
+
const now = params.now ?? Date.now();
|
|
110
|
+
const pingIntervalMs = params.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
|
|
111
|
+
const pongTimeoutMs = params.pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT_MS;
|
|
112
|
+
const disconnectAfter = pingIntervalMs + pongTimeoutMs;
|
|
113
|
+
const makeToken = params.makeToken ?? defaultToken;
|
|
114
|
+
|
|
115
|
+
let scanned = 0;
|
|
116
|
+
let pinged = 0;
|
|
117
|
+
let disconnected = 0;
|
|
118
|
+
let lastKey: unknown = undefined;
|
|
119
|
+
|
|
120
|
+
do {
|
|
121
|
+
const res = await params.dynamo.send(
|
|
122
|
+
new ScanCommand({
|
|
123
|
+
TableName: params.tables.Connections,
|
|
124
|
+
...(lastKey !== undefined ? { ExclusiveStartKey: lastKey as never } : {}),
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
lastKey = res.LastEvaluatedKey;
|
|
128
|
+
for (const item of res.Items ?? []) {
|
|
129
|
+
scanned++;
|
|
130
|
+
const row = item as { connectionId?: unknown; idleSince?: unknown };
|
|
131
|
+
const connId = row.connectionId;
|
|
132
|
+
const idleSince = row.idleSince;
|
|
133
|
+
if (typeof connId !== 'string' || typeof idleSince !== 'number') continue;
|
|
134
|
+
const idleFor = now - idleSince;
|
|
135
|
+
|
|
136
|
+
if (idleFor >= disconnectAfter) {
|
|
137
|
+
// Silent past the PONG window: tear down. cleanupConnection is
|
|
138
|
+
// idempotent and tolerates the row being concurrently removed
|
|
139
|
+
// by `$disconnect` or the sweeper.
|
|
140
|
+
await cleanupConnection(params.dynamo, params.tables, connId, params.managementApi);
|
|
141
|
+
disconnected++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (idleFor >= pingIntervalMs) {
|
|
146
|
+
// managementApi === null means the PING path is a silent no-op
|
|
147
|
+
// (e.g. the sweeper-style unit tests). We do not count it as a
|
|
148
|
+
// pinged connection because no bytes went out.
|
|
149
|
+
if (params.managementApi === null) continue;
|
|
150
|
+
const token = makeToken();
|
|
151
|
+
const line: RawLine = { text: `PING :${token}` };
|
|
152
|
+
const data = `${line.text}\r\n`;
|
|
153
|
+
try {
|
|
154
|
+
await params.managementApi.postToConnection({ ConnectionId: connId, Data: data });
|
|
155
|
+
} catch (err: unknown) {
|
|
156
|
+
if (err instanceof GoneException || isGone(err)) {
|
|
157
|
+
// The socket is gone but the row is still there. Reap it
|
|
158
|
+
// directly so the client's silent death doesn't have to
|
|
159
|
+
// wait for the next sweeper cycle.
|
|
160
|
+
await cleanupConnection(params.dynamo, params.tables, connId, params.managementApi);
|
|
161
|
+
// The PING attempt counts: the Lambda did the work of
|
|
162
|
+
// deciding to ping, even though the send threw. The row
|
|
163
|
+
// is also counted as disconnected by the cleanup path, but
|
|
164
|
+
// we only bump `disconnected` from the idle-threshold
|
|
165
|
+
// branch above; the gone-cleanup is reported via `pinged`
|
|
166
|
+
// so callers can tell the two cases apart.
|
|
167
|
+
pinged++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
// Stamp the row so subsequent passes can distinguish "PING sent,
|
|
173
|
+
// awaiting PONG" from "freshly idle, never pinged" if a future
|
|
174
|
+
// revision wants to avoid duplicate PINGs. Today the Lambda
|
|
175
|
+
// simply re-pings on every fire; the stamp is bookkeeping for
|
|
176
|
+
// observers (and tests).
|
|
177
|
+
await markPingSent(params.dynamo, params.tables.Connections, connId, now);
|
|
178
|
+
pinged++;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} while (lastKey !== undefined);
|
|
182
|
+
|
|
183
|
+
return { scanned, pinged, disconnected };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Internals
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/** Default token source — Node 20 Lambda runtime exposes `crypto.randomUUID` globally. */
|
|
191
|
+
function defaultToken(): string {
|
|
192
|
+
return randomUUID();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Stamps `pingSentAt = now` on the connection row (side-channel attribute). */
|
|
196
|
+
async function markPingSent(
|
|
197
|
+
dynamo: DynamoDBDocumentClient,
|
|
198
|
+
connectionsTable: string,
|
|
199
|
+
connId: ConnId,
|
|
200
|
+
now: number,
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
await dynamo.send(
|
|
203
|
+
new UpdateCommand({
|
|
204
|
+
TableName: connectionsTable,
|
|
205
|
+
Key: { connectionId: connId },
|
|
206
|
+
UpdateExpression: 'SET pingSentAt = :t',
|
|
207
|
+
ExpressionAttributeValues: { ':t': now },
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** True iff the SDK error indicates the APIGW connection is gone. */
|
|
213
|
+
function isGone(err: unknown): boolean {
|
|
214
|
+
if (err === null || typeof err !== 'object') return false;
|
|
215
|
+
const name = (err as { name?: string }).name;
|
|
216
|
+
return name === 'GoneException' || name === 'ForbiddenException';
|
|
217
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initial ConnectionState for a fresh `$connect` invocation.
|
|
3
|
+
*
|
|
4
|
+
* Centralised so the `$connect` handler and the test harness construct
|
|
5
|
+
* the same canonical pre-registration state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type ConnectionState, createConnection } from '@serverless-ircd/irc-core';
|
|
9
|
+
|
|
10
|
+
/** Builds the canonical pre-registration state for `connectionId`. */
|
|
11
|
+
export function createInitialConnectionState(connectionId: string, now: number): ConnectionState {
|
|
12
|
+
return createConnection({ id: connectionId, connectedSince: now });
|
|
13
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `$sweeper` handler — Lambda entry invoked on a fixed EventBridge
|
|
3
|
+
* schedule. Scans `Connections` for rows whose `idleSince` is older
|
|
4
|
+
* than a TTL threshold and runs {@link cleanupConnection} on each.
|
|
5
|
+
*
|
|
6
|
+
* Catches connections that vanished without API Gateway ever emitting
|
|
7
|
+
* `$disconnect` (mobile backgrounding, NAT timeouts, client crashes).
|
|
8
|
+
* The DynamoDB TTL on `idleSince` also expires those rows eventually,
|
|
9
|
+
* but TTL reaping is eventual (up to ~48h) and does NOT clean up the
|
|
10
|
+
* matching `ChannelMembers` / `Nicks` rows — the sweeper does, via
|
|
11
|
+
* the same idempotent teardown path used on `$disconnect`.
|
|
12
|
+
*
|
|
13
|
+
* Threshold default matches the APIGW WebSocket 2-hour hard cap: any
|
|
14
|
+
* connection idle for ≥2h is definitionally gone (APIGW itself will
|
|
15
|
+
* have closed the socket). PING timers handle the shorter-window
|
|
16
|
+
* liveness checks; this is the long-tail backstop.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
20
|
+
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
21
|
+
import { cleanupConnection } from '../aws-runtime.js';
|
|
22
|
+
import type { TablesConfig } from '../tables.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default stale threshold — twice the APIGW WebSocket 2-hour hard cap
|
|
26
|
+
* would be excessive; we use the cap itself. A connection idle for two
|
|
27
|
+
* hours cannot still have an open socket (APIGW enforces the cap).
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_STALE_AFTER_MS = 2 * 60 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
/** Parameters accepted by {@link handleSweep}. */
|
|
32
|
+
export interface SweepParams {
|
|
33
|
+
dynamo: DynamoDBDocumentClient;
|
|
34
|
+
tables: TablesConfig;
|
|
35
|
+
/** Wall-clock "now" (default: `Date.now()`). */
|
|
36
|
+
now?: number;
|
|
37
|
+
/** A connection is stale when `idleSince < now - staleAfterMs`. */
|
|
38
|
+
staleAfterMs?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Result returned by {@link handleSweep}. */
|
|
42
|
+
export interface SweepResult {
|
|
43
|
+
/** Total rows examined (post-pagination sum). */
|
|
44
|
+
scanned: number;
|
|
45
|
+
/** Rows torn down this pass. */
|
|
46
|
+
swept: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Scans `Connections` and tears down every row past the staleness
|
|
51
|
+
* threshold. Idempotent: running twice sweeps 0 the second time
|
|
52
|
+
* (cleanupConnection tolerates missing rows).
|
|
53
|
+
*/
|
|
54
|
+
export async function handleSweep(params: SweepParams): Promise<SweepResult> {
|
|
55
|
+
const now = params.now ?? Date.now();
|
|
56
|
+
const staleAfterMs = params.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
57
|
+
const cutoff = now - staleAfterMs;
|
|
58
|
+
|
|
59
|
+
let scanned = 0;
|
|
60
|
+
let swept = 0;
|
|
61
|
+
let lastKey: unknown = undefined;
|
|
62
|
+
|
|
63
|
+
do {
|
|
64
|
+
const res = await params.dynamo.send(
|
|
65
|
+
new ScanCommand({
|
|
66
|
+
TableName: params.tables.Connections,
|
|
67
|
+
...(lastKey !== undefined ? { ExclusiveStartKey: lastKey as never } : {}),
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
lastKey = res.LastEvaluatedKey;
|
|
71
|
+
for (const item of res.Items ?? []) {
|
|
72
|
+
scanned++;
|
|
73
|
+
const row = item as { connectionId?: string; idleSince?: number };
|
|
74
|
+
const idleSince = row.idleSince;
|
|
75
|
+
if (typeof idleSince !== 'number' || idleSince >= cutoff) continue;
|
|
76
|
+
const connId = row.connectionId;
|
|
77
|
+
if (typeof connId !== 'string') continue;
|
|
78
|
+
await cleanupConnection(params.dynamo, params.tables, connId, null);
|
|
79
|
+
swept++;
|
|
80
|
+
}
|
|
81
|
+
} while (lastKey !== undefined);
|
|
82
|
+
|
|
83
|
+
return { scanned, swept };
|
|
84
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @serverless-ircd/aws-adapter — entry point.
|
|
3
|
+
*
|
|
4
|
+
* Exports accumulate as the TDD cycles land. The CDK-coupled `TABLE_DEFS`
|
|
5
|
+
* lives at `@serverless-ircd/aws-adapter/cdk-table-defs` so the Lambda
|
|
6
|
+
* bundle does not pull in `aws-cdk-lib`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { AwsRuntime } from './aws-runtime.js';
|
|
10
|
+
export type {
|
|
11
|
+
AwsRuntimeHandlers,
|
|
12
|
+
AwsRuntimeOptions,
|
|
13
|
+
PostToConnection,
|
|
14
|
+
} from './aws-runtime.js';
|
|
15
|
+
export { cleanupConnection } from './aws-runtime.js';
|
|
16
|
+
export { TABLE_KEYS, TABLE_NAMES, tablesConfigFromNames } from './tables.js';
|
|
17
|
+
export type { TableName, TablesConfig } from './tables.js';
|
|
18
|
+
export { createDynamoDocumentClient } from './dynamo.js';
|
|
19
|
+
export {
|
|
20
|
+
DynamoAccountStore,
|
|
21
|
+
type HashedAccountCredential,
|
|
22
|
+
hashAccountCredential,
|
|
23
|
+
loadDynamoAccountStore,
|
|
24
|
+
} from './dynamo-account-store.js';
|
|
25
|
+
export { bindAccountStore, putAccountCredential, resolveAccountStore } from './account-store.js';
|
|
26
|
+
export {
|
|
27
|
+
loadServerConfigFromLambdaEnv,
|
|
28
|
+
type LambdaConfigEnv,
|
|
29
|
+
} from './config-loader.js';
|
|
30
|
+
export {
|
|
31
|
+
CONNECTION_VERSION,
|
|
32
|
+
marshalChannelMember,
|
|
33
|
+
marshalChannelMeta,
|
|
34
|
+
marshalConnection,
|
|
35
|
+
marshalNick,
|
|
36
|
+
unmarshalChannelMember,
|
|
37
|
+
unmarshalChannelMeta,
|
|
38
|
+
unmarshalConnection,
|
|
39
|
+
unmarshalNick,
|
|
40
|
+
} from './serialize.js';
|
|
41
|
+
export type {
|
|
42
|
+
MarshalledChannelMember,
|
|
43
|
+
MarshalledChannelMeta,
|
|
44
|
+
MarshalledConnection,
|
|
45
|
+
MarshalledNick,
|
|
46
|
+
} from './serialize.js';
|
|
47
|
+
|
|
48
|
+
// Lambda handler entry — exported so `apps/aws-stack`'s NodejsFunction and
|
|
49
|
+
// downstream tooling can import from a single package surface.
|
|
50
|
+
export {
|
|
51
|
+
handler,
|
|
52
|
+
dispatch,
|
|
53
|
+
buildDepsFromEnv,
|
|
54
|
+
pingCheckerHandler,
|
|
55
|
+
sweeperHandler,
|
|
56
|
+
handlePingCheck,
|
|
57
|
+
handleSweep,
|
|
58
|
+
DEFAULT_PING_INTERVAL_MS,
|
|
59
|
+
DEFAULT_PONG_TIMEOUT_MS,
|
|
60
|
+
DEFAULT_STALE_AFTER_MS,
|
|
61
|
+
__resetHandlerCacheForTests,
|
|
62
|
+
} from './handlers/index.js';
|
|
63
|
+
export type {
|
|
64
|
+
ConnectEvent,
|
|
65
|
+
DefaultEvent,
|
|
66
|
+
DisconnectEvent,
|
|
67
|
+
HandlerDeps,
|
|
68
|
+
LambdaResponse,
|
|
69
|
+
PingCheckParams,
|
|
70
|
+
PingCheckResult,
|
|
71
|
+
SweepParams,
|
|
72
|
+
SweepResult,
|
|
73
|
+
WebSocketEvent,
|
|
74
|
+
} from './handlers/index.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat-history store construction for the AWS adapter.
|
|
3
|
+
*
|
|
4
|
+
* Centralises `MessageStore` creation so the actor-construction sites
|
|
5
|
+
* (`handleDefault`) bind a single helper rather than constructing the
|
|
6
|
+
* store inline. The v1 implementation is an in-memory ring buffer
|
|
7
|
+
* (`InMemoryMessageStore`); a DynamoDB-backed persistent variant (new
|
|
8
|
+
* `Messages` table, partition key `chan`, sort key `time`, GSI on
|
|
9
|
+
* `msgid`) is a documented follow-up — swapping it in means replacing
|
|
10
|
+
* this one function.
|
|
11
|
+
*
|
|
12
|
+
* The in-memory v1 store is scoped to the Lambda execution context:
|
|
13
|
+
* it persists across warm invocations (via the memoised `HandlerDeps`)
|
|
14
|
+
* but is lost on cold start. Within a single multi-line `$default`
|
|
15
|
+
* frame (PRIVMSG + CHATHISTORY joined by `\r\n`) recording and
|
|
16
|
+
* playback work fully end-to-end.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
InMemoryMessageStore,
|
|
21
|
+
type MessageStore,
|
|
22
|
+
type ParsedServerConfig,
|
|
23
|
+
} from '@serverless-ircd/irc-core';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Constructs the chat-history store for a deployment.
|
|
27
|
+
*
|
|
28
|
+
* @param _serverConfig Reserved for the future persistent variant
|
|
29
|
+
* (retention cap, table-name resolution); the in-memory v1 impl
|
|
30
|
+
* uses the `InMemoryMessageStore` default retention.
|
|
31
|
+
*/
|
|
32
|
+
export function bindMessageStore(_serverConfig?: ParsedServerConfig): MessageStore {
|
|
33
|
+
return new InMemoryMessageStore();
|
|
34
|
+
}
|