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,330 @@
|
|
|
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 AccountStore,
|
|
16
|
+
type ChannelState,
|
|
17
|
+
type Clock,
|
|
18
|
+
type ConnectionState,
|
|
19
|
+
type IdFactory,
|
|
20
|
+
type MessageStore,
|
|
21
|
+
type MotdProvider,
|
|
22
|
+
type ParsedServerConfig,
|
|
23
|
+
SystemClock,
|
|
24
|
+
UuidIdFactory,
|
|
25
|
+
createConnection,
|
|
26
|
+
} from '@serverless-ircd/irc-core';
|
|
27
|
+
import { type ActorChannelAccess, ConnectionActor } from '@serverless-ircd/irc-server';
|
|
28
|
+
import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
|
|
29
|
+
import {
|
|
30
|
+
CONNECTION_VERSION,
|
|
31
|
+
type MarshalledConnection,
|
|
32
|
+
unmarshalConnection,
|
|
33
|
+
} from '../serialize.js';
|
|
34
|
+
import type { TablesConfig } from '../tables.js';
|
|
35
|
+
|
|
36
|
+
/** Parameters accepted by {@link handleDefault}. */
|
|
37
|
+
export interface DefaultParams {
|
|
38
|
+
dynamo: DynamoDBDocumentClient;
|
|
39
|
+
tables: TablesConfig;
|
|
40
|
+
connectionId: string;
|
|
41
|
+
body: string;
|
|
42
|
+
serverConfig: ParsedServerConfig;
|
|
43
|
+
motd: MotdProvider;
|
|
44
|
+
/**
|
|
45
|
+
* Chat-history persistence source. When omitted the actor runs with
|
|
46
|
+
* chathistory disabled (no recording, no playback) — preserves the
|
|
47
|
+
* behaviour of callers that have not opted in.
|
|
48
|
+
*/
|
|
49
|
+
messages?: MessageStore;
|
|
50
|
+
/**
|
|
51
|
+
* SASL account verification source. When omitted the actor runs with
|
|
52
|
+
* `ctx.accounts` undefined so `AUTHENTICATE PLAIN` fails with `904`
|
|
53
|
+
* — preserves the behaviour of callers that have not opted in.
|
|
54
|
+
*/
|
|
55
|
+
accounts?: AccountStore;
|
|
56
|
+
managementApi: ApiGatewayManagementApi | PostToConnection | null;
|
|
57
|
+
/** Injected for tests; defaults to {@link SystemClock}. */
|
|
58
|
+
clock?: Clock;
|
|
59
|
+
/** Injected for tests; defaults to {@link UuidIdFactory}. */
|
|
60
|
+
ids?: IdFactory;
|
|
61
|
+
/** Optional management endpoint URL used when constructing a default client. */
|
|
62
|
+
managementEndpoint?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Loads the Connections row, runs the actor over `body`, persists state.
|
|
67
|
+
*
|
|
68
|
+
* Returns the status code the Lambda should return to APIGW:
|
|
69
|
+
* - `200` on success,
|
|
70
|
+
* - `410` if the connection row is gone (client should reconnect),
|
|
71
|
+
* - `500` for any unhandled error.
|
|
72
|
+
*/
|
|
73
|
+
export async function handleDefault(params: DefaultParams): Promise<{ statusCode: number }> {
|
|
74
|
+
const clock = params.clock ?? SystemClock;
|
|
75
|
+
const ids = params.ids ?? new UuidIdFactory();
|
|
76
|
+
// Load persisted state.
|
|
77
|
+
const result = await params.dynamo.send(
|
|
78
|
+
new GetCommand({
|
|
79
|
+
TableName: params.tables.Connections,
|
|
80
|
+
Key: { connectionId: params.connectionId },
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
if (result.Item === undefined) {
|
|
84
|
+
return { statusCode: 410 };
|
|
85
|
+
}
|
|
86
|
+
const persisted = result.Item as unknown as MarshalledConnection;
|
|
87
|
+
const state = unmarshalConnection(persisted);
|
|
88
|
+
|
|
89
|
+
// Mark this frame's activity.
|
|
90
|
+
state.lastSeen = clock.now();
|
|
91
|
+
|
|
92
|
+
// Build the runtime + handlers.
|
|
93
|
+
const outbound: string[] = [];
|
|
94
|
+
const handlers: AwsRuntimeHandlers = {
|
|
95
|
+
send: (lines) => {
|
|
96
|
+
// Self-send: APIGW echoes back to the caller's open socket via the
|
|
97
|
+
// management API in production; in tests we collect for assertions.
|
|
98
|
+
for (const l of lines) outbound.push(l.text);
|
|
99
|
+
},
|
|
100
|
+
disconnect: () => {
|
|
101
|
+
// Lambda cannot close its own socket from inside the handler
|
|
102
|
+
// (the `$disconnect` route fires when APIGW observes the close).
|
|
103
|
+
},
|
|
104
|
+
snapshot: () => state,
|
|
105
|
+
};
|
|
106
|
+
const runtime = new AwsRuntime({
|
|
107
|
+
dynamo: params.dynamo,
|
|
108
|
+
tables: params.tables,
|
|
109
|
+
connId: params.connectionId,
|
|
110
|
+
handlers,
|
|
111
|
+
managementApi: params.managementApi,
|
|
112
|
+
clock,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const channelAccess = new LambdaChannelAccess(runtime, clock.now());
|
|
116
|
+
|
|
117
|
+
const actor = new ConnectionActor({
|
|
118
|
+
state,
|
|
119
|
+
runtime,
|
|
120
|
+
channels: channelAccess,
|
|
121
|
+
serverConfig: params.serverConfig,
|
|
122
|
+
clock,
|
|
123
|
+
ids,
|
|
124
|
+
motd: params.motd,
|
|
125
|
+
...(params.messages !== undefined ? { messages: params.messages } : {}),
|
|
126
|
+
...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await actor.receiveTextFrame(params.body);
|
|
131
|
+
} catch (err: unknown) {
|
|
132
|
+
console.error('[irc-handler] actor.receiveTextFrame failed', err);
|
|
133
|
+
// Still persist the state — partial mutations are valuable for debug.
|
|
134
|
+
await persistState(params.dynamo, params.tables, params.connectionId, state, clock.now());
|
|
135
|
+
return { statusCode: 500 };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Persist the mutated state and flush outbound bytes.
|
|
139
|
+
await persistState(params.dynamo, params.tables, params.connectionId, state, clock.now());
|
|
140
|
+
|
|
141
|
+
// In production, deliver outbound bytes back to the caller via APIGW.
|
|
142
|
+
if (params.managementApi !== null && outbound.length > 0) {
|
|
143
|
+
const data = outbound.join('\r\n');
|
|
144
|
+
try {
|
|
145
|
+
await params.managementApi.postToConnection({
|
|
146
|
+
ConnectionId: params.connectionId,
|
|
147
|
+
Data: data,
|
|
148
|
+
});
|
|
149
|
+
} catch {
|
|
150
|
+
// The handler still returns 200 — the actor's mutations are
|
|
151
|
+
// persisted; the caller just didn't see the reply this frame.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { statusCode: 200 };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Writes the actor's mutated `state` back to the Connections row.
|
|
159
|
+
*
|
|
160
|
+
* Uses an `UpdateCommand` that SETs every actor-owned field EXCEPT
|
|
161
|
+
* `joinedChannels`. The membership transaction (see `AwsRuntime`)
|
|
162
|
+
* owns `joinedChannels` atomically; if this frame's full-state write
|
|
163
|
+
* touched it, a concurrent `$default` for the same connection would
|
|
164
|
+
* lose the other frame's JOIN/PART. Optional fields (nick, user, …)
|
|
165
|
+
* are SET when present and REMOVE'd when absent so a cleared value
|
|
166
|
+
* does not linger from a previous frame.
|
|
167
|
+
*/
|
|
168
|
+
async function persistState(
|
|
169
|
+
dynamo: DynamoDBDocumentClient,
|
|
170
|
+
tables: TablesConfig,
|
|
171
|
+
_connId: string,
|
|
172
|
+
state: ConnectionState,
|
|
173
|
+
now: number,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const expr = buildPersistUpdate(state, now);
|
|
176
|
+
const hasNames = Object.keys(expr.expressionAttributeNames).length > 0;
|
|
177
|
+
await dynamo.send(
|
|
178
|
+
new UpdateCommand({
|
|
179
|
+
TableName: tables.Connections,
|
|
180
|
+
Key: { connectionId: state.id },
|
|
181
|
+
UpdateExpression: expr.updateExpression,
|
|
182
|
+
ExpressionAttributeValues: expr.expressionAttributeValues,
|
|
183
|
+
...(hasNames ? { ExpressionAttributeNames: expr.expressionAttributeNames } : {}),
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Optional connection fields mirrored into the SET/REMOVE split. */
|
|
189
|
+
const OPTIONAL_FIELDS = [
|
|
190
|
+
'nick',
|
|
191
|
+
'user',
|
|
192
|
+
'host',
|
|
193
|
+
'realname',
|
|
194
|
+
'passAttempt',
|
|
195
|
+
'account',
|
|
196
|
+
'away',
|
|
197
|
+
'saslMech',
|
|
198
|
+
'saslBuffer',
|
|
199
|
+
] as const;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Builds the `UpdateExpression` for {@link persistState}. Always SETs
|
|
203
|
+
* the required fields; SETs optional fields that are present and
|
|
204
|
+
* REMOVEs those that are absent. `joinedChannels` is intentionally
|
|
205
|
+
* excluded — owned by the membership transaction.
|
|
206
|
+
*/
|
|
207
|
+
function buildPersistUpdate(
|
|
208
|
+
state: ConnectionState,
|
|
209
|
+
now: number,
|
|
210
|
+
): {
|
|
211
|
+
updateExpression: string;
|
|
212
|
+
expressionAttributeNames: Record<string, string>;
|
|
213
|
+
expressionAttributeValues: Record<string, unknown>;
|
|
214
|
+
} {
|
|
215
|
+
const values: Record<string, unknown> = {
|
|
216
|
+
':reg': state.registration,
|
|
217
|
+
':capN': state.capNegotiating,
|
|
218
|
+
':caps': [...state.caps],
|
|
219
|
+
':um': { ...state.userModes },
|
|
220
|
+
':ls': state.lastSeen,
|
|
221
|
+
':is': now,
|
|
222
|
+
':v': CONNECTION_VERSION,
|
|
223
|
+
};
|
|
224
|
+
const setNames = [
|
|
225
|
+
'registration = :reg',
|
|
226
|
+
'capNegotiating = :capN',
|
|
227
|
+
'caps = :caps',
|
|
228
|
+
'userModes = :um',
|
|
229
|
+
'lastSeen = :ls',
|
|
230
|
+
'idleSince = :is',
|
|
231
|
+
'version = :v',
|
|
232
|
+
];
|
|
233
|
+
const removeNames: string[] = [];
|
|
234
|
+
const nameMap: Record<string, string> = {};
|
|
235
|
+
|
|
236
|
+
for (const field of OPTIONAL_FIELDS) {
|
|
237
|
+
const placeholder = `:${field}`;
|
|
238
|
+
const value = state[field] as string | undefined;
|
|
239
|
+
const nameKey = `#${field}`;
|
|
240
|
+
nameMap[nameKey] = field;
|
|
241
|
+
if (value !== undefined) {
|
|
242
|
+
values[placeholder] = value;
|
|
243
|
+
setNames.push(`${nameKey} = ${placeholder}`);
|
|
244
|
+
} else {
|
|
245
|
+
removeNames.push(nameKey);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const parts: string[] = [`SET ${setNames.join(', ')}`];
|
|
250
|
+
if (removeNames.length > 0) {
|
|
251
|
+
parts.push(`REMOVE ${removeNames.join(', ')}`);
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
updateExpression: parts.join(' '),
|
|
255
|
+
expressionAttributeNames: nameMap,
|
|
256
|
+
expressionAttributeValues: values,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* `ActorChannelAccess` backed by the per-invocation {@link AwsRuntime}.
|
|
262
|
+
*
|
|
263
|
+
* Mirrors `cf-adapter/src/connection-do.ts`'s `PassthroughChannelAccess`:
|
|
264
|
+
* - `getOrCreateChannel(name)` returns a cached or freshly-seeded
|
|
265
|
+
* {@link ChannelState} for the local reducer.
|
|
266
|
+
* - `refreshChannel(name)` pulls the authoritative snapshot from
|
|
267
|
+
* DynamoDB and rebuilds the cache so subsequent reducers observe
|
|
268
|
+
* the current roster / modes / topic.
|
|
269
|
+
*/
|
|
270
|
+
class LambdaChannelAccess implements ActorChannelAccess {
|
|
271
|
+
private readonly cache = new Map<string, ChannelState>();
|
|
272
|
+
constructor(
|
|
273
|
+
private readonly runtime: AwsRuntime,
|
|
274
|
+
private readonly now: number,
|
|
275
|
+
) {}
|
|
276
|
+
|
|
277
|
+
getOrCreateChannel(name: string): ChannelState {
|
|
278
|
+
const key = name.toLowerCase();
|
|
279
|
+
const existing = this.cache.get(key);
|
|
280
|
+
if (existing !== undefined) return existing;
|
|
281
|
+
const created: ChannelState = {
|
|
282
|
+
name,
|
|
283
|
+
nameLower: key,
|
|
284
|
+
modes: {
|
|
285
|
+
inviteOnly: false,
|
|
286
|
+
topicLock: false,
|
|
287
|
+
noExternal: false,
|
|
288
|
+
moderated: false,
|
|
289
|
+
secret: false,
|
|
290
|
+
private: false,
|
|
291
|
+
},
|
|
292
|
+
banMasks: new Set<string>(),
|
|
293
|
+
members: new Map(),
|
|
294
|
+
pendingInvites: new Set<string>(),
|
|
295
|
+
createdAt: this.now,
|
|
296
|
+
};
|
|
297
|
+
this.cache.set(key, created);
|
|
298
|
+
return created;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async refreshChannel(name: string): Promise<void> {
|
|
302
|
+
const key = name.toLowerCase();
|
|
303
|
+
const snap = await this.runtime.getChannelSnapshot(name);
|
|
304
|
+
if (snap === null) return;
|
|
305
|
+
const existing = this.cache.get(key);
|
|
306
|
+
if (snap.members.size === 0) {
|
|
307
|
+
if (existing !== undefined) {
|
|
308
|
+
existing.modes = { ...snap.modes };
|
|
309
|
+
existing.banMasks = new Set(snap.banMasks);
|
|
310
|
+
existing.pendingInvites = new Set(snap.pendingInvites);
|
|
311
|
+
if (snap.topic !== undefined) existing.topic = snap.topic;
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const rebuilt: ChannelState = {
|
|
316
|
+
name,
|
|
317
|
+
nameLower: key,
|
|
318
|
+
modes: { ...snap.modes },
|
|
319
|
+
banMasks: new Set(snap.banMasks),
|
|
320
|
+
members: new Map(snap.members),
|
|
321
|
+
pendingInvites: new Set(snap.pendingInvites),
|
|
322
|
+
createdAt: snap.createdAt,
|
|
323
|
+
};
|
|
324
|
+
if (snap.topic !== undefined) rebuilt.topic = snap.topic;
|
|
325
|
+
this.cache.set(key, rebuilt);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Re-export for type-completeness in callers.
|
|
330
|
+
export { createConnection };
|
|
@@ -0,0 +1,48 @@
|
|
|
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, the nick
|
|
6
|
+
* reservation if any, and broadcasts a `QUIT` line to every peer sharing
|
|
7
|
+
* a channel with the disconnecting connection. Idempotent: every step
|
|
8
|
+
* tolerates a missing row.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
|
|
12
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
13
|
+
import { type PostToConnection, cleanupConnection } from '../aws-runtime.js';
|
|
14
|
+
import type { TablesConfig } from '../tables.js';
|
|
15
|
+
|
|
16
|
+
/** Parameters accepted by {@link handleDisconnect}. */
|
|
17
|
+
export interface DisconnectParams {
|
|
18
|
+
dynamo: DynamoDBDocumentClient;
|
|
19
|
+
tables: TablesConfig;
|
|
20
|
+
connectionId: string;
|
|
21
|
+
/**
|
|
22
|
+
* Backend used to push the `QUIT` fanout to peers. `null` skips the
|
|
23
|
+
* fanout (the socket is already gone, but peers still need the QUIT
|
|
24
|
+
* line so their rosters prune the ghost member).
|
|
25
|
+
*/
|
|
26
|
+
managementApi?: ApiGatewayManagementApi | PostToConnection | null;
|
|
27
|
+
/**
|
|
28
|
+
* QUIT reason advertised to peers on fanout. Defaults to
|
|
29
|
+
* `'Client Quit'` (the same default the QUIT reducer uses when a
|
|
30
|
+
* client sends `QUIT` with no reason).
|
|
31
|
+
*/
|
|
32
|
+
quitMessage?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Cleans up the connection state and broadcasts QUIT to shared-channel
|
|
37
|
+
* peers. Safe to call after APIGW has already torn the WebSocket down —
|
|
38
|
+
* the management API is best-effort only.
|
|
39
|
+
*/
|
|
40
|
+
export async function handleDisconnect(params: DisconnectParams): Promise<void> {
|
|
41
|
+
await cleanupConnection(
|
|
42
|
+
params.dynamo,
|
|
43
|
+
params.tables,
|
|
44
|
+
params.connectionId,
|
|
45
|
+
params.managementApi ?? null,
|
|
46
|
+
params.quitMessage,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lambda handler entry point.
|
|
3
|
+
*
|
|
4
|
+
* Dispatches on `event.requestContext.routeKey` to the per-route handlers:
|
|
5
|
+
* - `$connect` → persist a fresh Connections row.
|
|
6
|
+
* - `$disconnect` → tear down the connection + clean up memberships.
|
|
7
|
+
* - `$default` → run the actor over `event.body` and persist state.
|
|
8
|
+
*
|
|
9
|
+
* The handler reads its config from `process.env` (SERVER_NAME, NETWORK_NAME,
|
|
10
|
+
* MOTD, table-name vars) via {@link loadServerConfigFromLambdaEnv}. Tests
|
|
11
|
+
* can inject a fully-formed {@link HandlerDeps} to bypass env loading.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
|
|
15
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
16
|
+
import {
|
|
17
|
+
type AccountStore,
|
|
18
|
+
EmptyMotdProvider,
|
|
19
|
+
type MessageStore,
|
|
20
|
+
type MotdProvider,
|
|
21
|
+
type ParsedServerConfig,
|
|
22
|
+
StaticMotdProvider,
|
|
23
|
+
} from '@serverless-ircd/irc-core';
|
|
24
|
+
import { resolveAccountStore } from '../account-store.js';
|
|
25
|
+
import { type LambdaConfigEnv, loadServerConfigFromLambdaEnv } from '../config-loader.js';
|
|
26
|
+
import { createDynamoDocumentClient } from '../dynamo.js';
|
|
27
|
+
import { bindMessageStore } from '../message-store.js';
|
|
28
|
+
import { TABLE_KEYS, type TablesConfig } from '../tables.js';
|
|
29
|
+
import { handleConnect } from './connect.js';
|
|
30
|
+
import { handleDefault } from './default.js';
|
|
31
|
+
import { handleDisconnect } from './disconnect.js';
|
|
32
|
+
import {
|
|
33
|
+
DEFAULT_PING_INTERVAL_MS,
|
|
34
|
+
DEFAULT_PONG_TIMEOUT_MS,
|
|
35
|
+
type PingCheckParams,
|
|
36
|
+
type PingCheckResult,
|
|
37
|
+
handlePingCheck,
|
|
38
|
+
} from './ping-checker.js';
|
|
39
|
+
import {
|
|
40
|
+
DEFAULT_STALE_AFTER_MS,
|
|
41
|
+
type SweepParams,
|
|
42
|
+
type SweepResult,
|
|
43
|
+
handleSweep,
|
|
44
|
+
} from './sweeper.js';
|
|
45
|
+
|
|
46
|
+
/** API Gateway WebSocket event shape (the bits the handler reads). */
|
|
47
|
+
export interface WebSocketEvent {
|
|
48
|
+
requestContext: {
|
|
49
|
+
routeKey: string;
|
|
50
|
+
connectionId: string;
|
|
51
|
+
domainName?: string;
|
|
52
|
+
stage?: string;
|
|
53
|
+
};
|
|
54
|
+
body?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Lambda response shape. */
|
|
58
|
+
export interface LambdaResponse {
|
|
59
|
+
statusCode: number;
|
|
60
|
+
body?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Injectable dependencies. Tests construct one of these to bypass env loading. */
|
|
64
|
+
export interface HandlerDeps {
|
|
65
|
+
dynamo: DynamoDBDocumentClient;
|
|
66
|
+
tables: TablesConfig;
|
|
67
|
+
serverConfig: ParsedServerConfig;
|
|
68
|
+
motd: MotdProvider;
|
|
69
|
+
managementApi: ApiGatewayManagementApi | null;
|
|
70
|
+
/**
|
|
71
|
+
* Chat-history persistence source bound to every actor. Constructed
|
|
72
|
+
* once per cold start (memoised via {@link cachedDeps}) so it survives
|
|
73
|
+
* across warm Lambda invocations. The v1 impl is in-memory; a
|
|
74
|
+
* DynamoDB-backed variant is a documented follow-up.
|
|
75
|
+
*/
|
|
76
|
+
messages: MessageStore;
|
|
77
|
+
/**
|
|
78
|
+
* SASL account verification source bound to every actor. Constructed
|
|
79
|
+
* once per cold start (memoised via {@link cachedDeps}) so it survives
|
|
80
|
+
* across warm Lambda invocations. `undefined` when no accounts are
|
|
81
|
+
* configured (the default) so `ctx.accounts` stays unset.
|
|
82
|
+
*/
|
|
83
|
+
accounts?: AccountStore;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Strongly-typed event discriminated by routeKey. Useful for tests that
|
|
88
|
+
* construct events directly.
|
|
89
|
+
*/
|
|
90
|
+
export type ConnectEvent = { requestContext: { routeKey: '$connect'; connectionId: string } };
|
|
91
|
+
export type DisconnectEvent = {
|
|
92
|
+
requestContext: { routeKey: '$disconnect'; connectionId: string };
|
|
93
|
+
};
|
|
94
|
+
export type DefaultEvent = {
|
|
95
|
+
requestContext: { routeKey: '$default'; connectionId: string };
|
|
96
|
+
body: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Memoised `HandlerDeps`; built on the first cold-start invocation. */
|
|
100
|
+
let cachedDeps: HandlerDeps | undefined;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* In-flight cold-start build. `buildDepsFromEnv` is async (it scans the
|
|
104
|
+
* `Accounts` DynamoDB table for SASL credentials), so under concurrent
|
|
105
|
+
* warm-up two events could race past the `cachedDeps` check and each
|
|
106
|
+
* trigger a full scan. Memoising the promise guarantees the scan runs
|
|
107
|
+
* exactly once per cold start.
|
|
108
|
+
*/
|
|
109
|
+
let cachedDepsPromise: Promise<HandlerDeps> | undefined;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The Lambda entry point. Routes on `event.requestContext.routeKey`.
|
|
113
|
+
*
|
|
114
|
+
* Cold-start path reads `process.env` for config + table names. Warm
|
|
115
|
+
* invocations reuse the cached {@link HandlerDeps}.
|
|
116
|
+
*/
|
|
117
|
+
export const handler = async (event: WebSocketEvent): Promise<LambdaResponse> => {
|
|
118
|
+
if (cachedDeps === undefined) {
|
|
119
|
+
if (cachedDepsPromise === undefined) {
|
|
120
|
+
cachedDepsPromise = buildDepsFromEnv();
|
|
121
|
+
}
|
|
122
|
+
cachedDeps = await cachedDepsPromise;
|
|
123
|
+
cachedDepsPromise = undefined;
|
|
124
|
+
}
|
|
125
|
+
const deps = cachedDeps;
|
|
126
|
+
return dispatch(event, deps);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Pure dispatcher — exported for unit tests that inject deps directly.
|
|
131
|
+
*/
|
|
132
|
+
export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promise<LambdaResponse> {
|
|
133
|
+
const connId = event.requestContext.connectionId;
|
|
134
|
+
switch (event.requestContext.routeKey) {
|
|
135
|
+
case '$connect':
|
|
136
|
+
await handleConnect({
|
|
137
|
+
dynamo: deps.dynamo,
|
|
138
|
+
tables: deps.tables,
|
|
139
|
+
connectionId: connId,
|
|
140
|
+
serverConfig: deps.serverConfig,
|
|
141
|
+
});
|
|
142
|
+
return { statusCode: 200 };
|
|
143
|
+
|
|
144
|
+
case '$disconnect':
|
|
145
|
+
await handleDisconnect({
|
|
146
|
+
dynamo: deps.dynamo,
|
|
147
|
+
tables: deps.tables,
|
|
148
|
+
connectionId: connId,
|
|
149
|
+
managementApi: deps.managementApi,
|
|
150
|
+
quitMessage: deps.serverConfig.quitMessage,
|
|
151
|
+
});
|
|
152
|
+
return { statusCode: 200 };
|
|
153
|
+
|
|
154
|
+
case '$default':
|
|
155
|
+
if (event.body === undefined) {
|
|
156
|
+
return { statusCode: 400, body: 'empty body' };
|
|
157
|
+
}
|
|
158
|
+
{
|
|
159
|
+
const result = await handleDefault({
|
|
160
|
+
dynamo: deps.dynamo,
|
|
161
|
+
tables: deps.tables,
|
|
162
|
+
connectionId: connId,
|
|
163
|
+
body: event.body,
|
|
164
|
+
serverConfig: deps.serverConfig,
|
|
165
|
+
motd: deps.motd,
|
|
166
|
+
messages: deps.messages,
|
|
167
|
+
...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
|
|
168
|
+
managementApi: deps.managementApi,
|
|
169
|
+
});
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
default:
|
|
174
|
+
// Unknown routeKey — should never happen (APIGW only forwards the
|
|
175
|
+
// three routes above). Return 200 so APIGW doesn't retry.
|
|
176
|
+
return { statusCode: 200 };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resets the cached {@link HandlerDeps}. Used by tests that mutate env
|
|
182
|
+
* between cases.
|
|
183
|
+
*/
|
|
184
|
+
export function __resetHandlerCacheForTests(): void {
|
|
185
|
+
cachedDeps = undefined;
|
|
186
|
+
cachedDepsPromise = undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Lambda entry for the gone-connection sweeper. Wired to an EventBridge
|
|
191
|
+
* schedule in the CDK stack (`rate(5 minutes)`). Shares the cached
|
|
192
|
+
* {@link HandlerDeps} with {@link handler} — same tables, same config,
|
|
193
|
+
* no `managementApi` needed (the socket is, by definition, gone).
|
|
194
|
+
*
|
|
195
|
+
* `_event` is the EventBridge scheduled-event payload; we ignore it
|
|
196
|
+
* (the sweeper reads authoritative state from DynamoDB, not the event).
|
|
197
|
+
*/
|
|
198
|
+
export async function sweeperHandler(_event: unknown): Promise<SweepResult> {
|
|
199
|
+
if (cachedDeps === undefined) {
|
|
200
|
+
if (cachedDepsPromise === undefined) {
|
|
201
|
+
cachedDepsPromise = buildDepsFromEnv();
|
|
202
|
+
}
|
|
203
|
+
cachedDeps = await cachedDepsPromise;
|
|
204
|
+
cachedDepsPromise = undefined;
|
|
205
|
+
}
|
|
206
|
+
const deps = cachedDeps;
|
|
207
|
+
return handleSweep({ dynamo: deps.dynamo, tables: deps.tables });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Lambda entry for the idle / PING checker. Wired to an EventBridge
|
|
212
|
+
* schedule in the CDK stack (`rate(1 minute)`). Shares the cached
|
|
213
|
+
* {@link HandlerDeps} with {@link handler} — same tables, same config,
|
|
214
|
+
* same `managementApi` (needed to push `PING` bytes back to the idle
|
|
215
|
+
* client via `postToConnection`).
|
|
216
|
+
*
|
|
217
|
+
* `_event` is the EventBridge scheduled-event payload; we ignore it
|
|
218
|
+
* (the checker reads authoritative state from DynamoDB, not the event).
|
|
219
|
+
*/
|
|
220
|
+
export async function pingCheckerHandler(_event: unknown): Promise<PingCheckResult> {
|
|
221
|
+
if (cachedDeps === undefined) {
|
|
222
|
+
if (cachedDepsPromise === undefined) {
|
|
223
|
+
cachedDepsPromise = buildDepsFromEnv();
|
|
224
|
+
}
|
|
225
|
+
cachedDeps = await cachedDepsPromise;
|
|
226
|
+
cachedDepsPromise = undefined;
|
|
227
|
+
}
|
|
228
|
+
const deps = cachedDeps;
|
|
229
|
+
return handlePingCheck({
|
|
230
|
+
dynamo: deps.dynamo,
|
|
231
|
+
tables: deps.tables,
|
|
232
|
+
managementApi: deps.managementApi,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export {
|
|
237
|
+
DEFAULT_PING_INTERVAL_MS,
|
|
238
|
+
DEFAULT_PONG_TIMEOUT_MS,
|
|
239
|
+
DEFAULT_STALE_AFTER_MS,
|
|
240
|
+
handlePingCheck,
|
|
241
|
+
handleSweep,
|
|
242
|
+
type PingCheckParams,
|
|
243
|
+
type PingCheckResult,
|
|
244
|
+
type SweepParams,
|
|
245
|
+
type SweepResult,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Builds a {@link HandlerDeps} from the current `process.env`.
|
|
250
|
+
*
|
|
251
|
+
* Exposed so tests can construct production-shaped deps without re-running
|
|
252
|
+
* the env parsing. Async because it scans the `Accounts` DynamoDB table
|
|
253
|
+
* (via {@link resolveAccountStore}) to pre-load SASL credentials at cold
|
|
254
|
+
* start — the {@link AccountStore} port is synchronous, so the scan must
|
|
255
|
+
* complete before any reducer calls `verify`.
|
|
256
|
+
*/
|
|
257
|
+
export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<HandlerDeps> {
|
|
258
|
+
const cfg = loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
|
|
259
|
+
const tables = tablesConfigFromEnv(env);
|
|
260
|
+
const region = typeof env.AWS_REGION === 'string' ? env.AWS_REGION : undefined;
|
|
261
|
+
const dynamo =
|
|
262
|
+
region !== undefined ? createDynamoDocumentClient({ region }) : createDynamoDocumentClient({});
|
|
263
|
+
let managementApi: ApiGatewayManagementApi | null = null;
|
|
264
|
+
const managementUrl = typeof env.MANAGEMENT_URL === 'string' ? env.MANAGEMENT_URL : undefined;
|
|
265
|
+
if (managementUrl !== undefined) {
|
|
266
|
+
managementApi = new ApiGatewayManagementApi({ endpoint: managementUrl });
|
|
267
|
+
}
|
|
268
|
+
const motd = cfg.motdLines.length > 0 ? new StaticMotdProvider(cfg.motdLines) : EmptyMotdProvider;
|
|
269
|
+
const messages = bindMessageStore(cfg);
|
|
270
|
+
const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
|
|
271
|
+
return {
|
|
272
|
+
dynamo,
|
|
273
|
+
tables,
|
|
274
|
+
serverConfig: cfg,
|
|
275
|
+
motd,
|
|
276
|
+
managementApi,
|
|
277
|
+
messages,
|
|
278
|
+
...(accounts !== undefined ? { accounts } : {}),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function tablesConfigFromEnv(env: NodeJS.ProcessEnv): TablesConfig {
|
|
283
|
+
const out = {} as TablesConfig;
|
|
284
|
+
for (const logical of Object.keys(TABLE_KEYS) as Array<keyof typeof TABLE_KEYS>) {
|
|
285
|
+
const envName = `${logical.toUpperCase()}_TABLE`;
|
|
286
|
+
const physical = env[envName];
|
|
287
|
+
if (typeof physical !== 'string') {
|
|
288
|
+
throw new Error(`Missing env var ${envName} (table name for ${logical})`);
|
|
289
|
+
}
|
|
290
|
+
out[logical] = physical;
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|