AWS Adapter Architecture
Architecture diagrams for the AWS implementation (Phase 4 of PLAN.md).
All diagrams are ASCII art for terminal-friendly viewing. Cross-reference:
PLAN.md §2 (hexagonal architecture), §4 (state model), §6.2 (AWS mapping).
Legend for arrows:
──▶ synchronous call / request
══▶ WebSocket frame (client ↔ APIGW)
─┄┄ scheduled / asynchronous trigger
─│▶ read/write against durable state
1. System overview
The AWS adapter implements the IrcRuntime port (defined in
packages/irc-server) on top of API Gateway WebSockets, Lambda, and
DynamoDB. The pure IRC core in packages/irc-core is cloud-agnostic and
shared with the Cloudflare adapter.
┌─────────────────────────────────────┐
│ IRC clients │
│ WeeChat · HexChat · IRCCloud · │
│ TheLounge · matrix-IRC bridge │
└──────────────────┬──────────────────┘
│ wss (one IRC msg per text frame)
║
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ AWS region (single, v1) │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ API Gateway v2 WebSocket API │ │
│ │ routes: $connect $disconnect $default │ │
│ │ identity: connectionId (stable per WS session, ≤ 2 h) │ │
│ └────────┬──────────────────┬─────────────────────┬────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Lambda (Node 20, esbuild bundle) │ │
│ │ │ │
│ │ handlers: onConnect onDisconnect onMessage │ │
│ │ pipeline: frame → ConnectionActor → reducer → dispatch │ │
│ │ runtime: AwsRuntime implements IrcRuntime │ │
│ └──────┬────────────────────────────────────────────┬──────────┘ │
│ │ │ │
│ │ ┌─────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────┐ ┌──────────────────────────────┐ │
│ │ DynamoDB (on-demand) │ │ ApiGatewayManagementApi │ │
│ │ │ │ postToConnection(connectionId) │
│ │ • Connections │ │ fanout back to clients │ │
│ │ • ChannelMeta │ └──────────────┬───────────────┘ │
│ │ • ChannelMembers │ │ │
│ │ • Nicks │ │ │
│ │ • Accounts │ │ │
│ └──────────────────────────────┘ │ │
│ ▲ │ │
│ │ │ │
│ ┌──────┴───────────────┐ │ │
│ │ EventBridge Scheduler│ idle/PING sweeps │ │
│ │ per-connection jobs │──┄┄ Lambda ──────────────┘ │
│ └──────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Supporting services │ │
│ │ • Secrets Manager / SSM Parameter Store (config, SASL hashes) │ │
│ │ • CloudWatch Logs + Metrics (observability) │ │
│ │ • IAM roles, least-privilege per Lambda (security) │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
║
▼
┌─────────────────────────────────────┐
│ packages/irc-core (shared brain) │
│ pure reducers: (state,msg) → │
│ { state, effects } │
└─────────────────────────────────────┘
The Lambda bundle includes irc-core, irc-server, and aws-adapter.
Only the I/O shell is AWS-specific.
2. Connection lifecycle
API Gateway emits three synthetic route events. Each invokes a Lambda;
the persistent connectionId is the source of truth for identity.
client API Gateway Lambda handler
│ │ │
│ HTTP Upgrade (wss) │ │
│═══════════════════════════════▶│ │
│ │ $connect event │
│ │──▶ onConnect() │
│ │ │ │
│ │ │ PutItem Connections │
│ │ │ (status=connecting) │
│ │ │ │
│ │ │ optional: PASS check │
│ │ ▼ │
│ 101 Switching Protocols │ │
│◀═══════════════════════════════│ │
│ │ │
│ CAP LS / NICK / USER ... │ │
│═══════════════════════════════▶│ $default event │
│ │──▶ onMessage(body) │
│ │ │ │
│ │ │ ConnectionActor │
│ │ │ .ingest(frame) │
│ │ │ parse → route → │
│ │ │ reducer → dispatch │
│ │ │ │
│ │ │ Welcome 001..005 │
│ │ │ via postToConnection │
│ :server 001 nick :Welcome │ │ │
│◀═══════════════════════════════│◀──────┘ │
│ │ │
│ ... PRIVMSG / JOIN / MODE ... │ │
│═══════════════════════════════▶│ $default (repeat per frame) │
│ │ │
│ close / TCP drop / 2h cap │ │
│──── (or idle PING timeout) ───▶│ $disconnect event │
│ │──▶ onDisconnect() │
│ │ │ │
│ │ │ QUIT reducer: │
│ │ │ Broadcast QUIT │
│ │ │ ReleaseNick │
│ │ │ DeleteItem Connections│
│ │ │ Del ChannelMembers │
│ │ ▼ │
Two failure modes worth noting:
- Gone connection (lazy cleanup): if
postToConnectionthrowsGoneException, the Lambda deletes theConnectionsrow and emits a best-effort QUIT broadcast. A separate sweeper (§5) catches rows that never receive another message. - 2-hour APIGW cap: clients MUST tolerate a server-driven reconnect.
The server signals via
PONG-driven reset; the reducer stays pure and only emits the disconnect effect.
3. Request pipeline inside Lambda (onMessage)
One Lambda invocation per WebSocket frame. The same ConnectionActor
used by the in-memory and Cloudflare runtimes drives the work; only
AwsRuntime (the I/O interpreter) differs.
API Gateway $default event
{ connectionId, body }
│
▼
┌────────────────────────────────────────────────────────────┐
│ onMessage(event) │
│ │
│ 1. split body on "\r\n" (tolerant: also accept single │
│ message per frame; see PLAN §9) │
│ │
│ 2. for each line: │
│ parser.parse(line) ◀── irc-core/protocol │
│ │ │
│ ▼ │
│ IrcMessage { command, params, tags } │
│ │ │
│ ▼ │
│ ConnectionActor.ingest(msg) │
│ │ │
│ │ ┌── flood-control wrapper (token bucket) │
│ │ │ (TICKET-028, pure) │
│ │ ▼ │
│ │ location-table lookup ──▶ reducer │
│ │ (irc-core/commands) │
│ │ (state, msg, ctx) │
│ │ │ │
│ │ ▼ │
│ │ { state', effects[] } │
│ │ │ │
│ ▼ ▼ │
│ dispatch(effects, AwsRuntime) ◀── irc-server │
└────────────────────────────┬───────────────────────────────┘
│
┌───────────────┼────────────────┐
▼ ▼ ▼
DynamoDB postToConnection EventBridge
(state writes) (client fanout) (schedule PING)
Key invariant: the reducer never touches AWS. It returns Effect[]
values; dispatch is the only function that awaits I/O. That is what
makes the Phase 2 contract suite (TICKET-032) reusable across runtimes.
4. DynamoDB schema (PLAN §4)
Five tables. Single-table design was rejected: the access patterns (per-connection, per-channel, per-nick) are disjoint enough that splitting yields clearer conditional writes and transactions.
Connections ChannelMeta
┌──────────────┬─────────────┐ ┌──────────────┬────────────┐
│ PK │ connectionId│ │ PK │ channelName│
├──────────────┼─────────────┤ │ (lowercased) │ │
│ nick │ string │ ├──────────────┼────────────┤
│ user │ string │ │ topic │ string │
│ hostmask │ string │ │ modes │ set │
│ caps │ set │ │ key │ string? │
│ account │ string? │ │ limit │ number? │
│ joined[] │ list │ │ banMasks │ list │
│ idleSince │ number │ └──────────────┴────────────┘
│ status │ enum │
│ callbackUrl │ string │ ChannelMembers
└──────────────┴─────────────┘ ┌──────────────┬────────────┐
│ PK │ channelName│
Nicks │ SK │connectionId│
┌──────────────┬────────────┐ ├──────────────┼────────────┤
│ PK │ nickLower │ │ prefixes │ set(@,+) │
│ (lowercased) │ │ │ joinedAt │ number │
├──────────────┼────────────┤ └──────────────┴────────────┘
│ connectionId │ string │ query pattern: PK = chan
│ (unique via │ → returns all members for fanout
│ conditional PutItem) │
└──────────────┴────────────┘ Accounts
┌──────────────┬────────────┐
│ PK │ account │
├──────────────┼────────────┤
│ algorithm │ string │
│ salt │ string │
│ hash │ string │
└──────────────┴────────────┘
Uniqueness invariants are enforced structurally, not by application logic:
Nicks.PK— conditionalPutItem(attribute_not_exists(PK)) makes nick reservation atomic across concurrent Lambdas.ChannelMembersPK+SK composite —TransactWriteItemsfor JOIN/PART updatesConnections.joinedandChannelMembersin one atomic write.Accountsrow schema —{ account (PK), algorithm, salt, hash }, all strings. Never plaintext; PLAIN SASL verifies the scrypt hash server-side.
Bookkeeping rows share the Connections table (prefixed keys never
collide with APIGW/NLB connection ids) and are excluded from stats
scans and the gone-connection sweeper:
__meta:connectionCount__— the O(1) live-connection admission counter shared by both transports: incremented at establishment (wss$connectand the NLB flow's first chunk — NLB connections draw on the samemaxClientsbudget), decremented on every teardown (cleanupConnectionfrom$disconnect/ sweeper / ping-checker /GoneException, plus the NLB buffer-overflow row drop), so each decrement matches a prior increment and the counter tracks the true live-connection set across transports.__operfail__:<ip>:<bucket>— window-bucketed per-IP failed-OPERcounters backing the OPER brute-force lockout on both the wss$defaultand NLB paths: a conditionalADD count :nper committed failure,idleSince(epoch seconds here — the table's DynamoDB-native TTL attribute) stamped past the bucket's read horizon, and both live rows deleted on a successful login. Each invocation hydrates the lockout gate from the current + previous bucket rows, so theoper.maxFailures-within-failureWindowSecondsbudget holds across isolates and transports.
5. Channel fanout (PRIVMSG to #chan)
There is no long-lived per-channel process (contrast with CF's
ChannelDO). Instead, fanout is computed per message by querying
ChannelMembers.
sender's Lambda (onMessage PRIVMSG #foo :hello)
│
│ 1. reducer emits:
│ Broadcast("#foo", [":nick PRIVMSG #foo :hello"],
│ except=sender)
│
│ 2. AwsRuntime.broadcast():
│
│ ┌────────────────────────────────┐
│ │ Query ChannelMembers │
│ │ PK = "#foo" │──┐
│ └────────────────────────────────┘ │
│ │
│ ◀─── [connA, connB, connC, ...] ────┘
│
│ 3. for each member (parallel, batched):
│
│ ┌──────────────────────────────┐
│ │ postToConnection(member, │──▶ client A
│ │ line) │──▶ client B
│ └──────────────────────────────┘──▶ client C
│ (skip sender)
│
│ 4. on GoneException:
│ mark conn gone ──▶ sweeper (§6)
│
▼
Trade-off: this is O(members) API calls per message. For hot channels
this is the main scaling ceiling on AWS (PLAN §10). Mitigations deferred
to post-v1: batched postToConnection calls, per-channel send-list
cache, or a fanout Lambda invoked once per message.
6. Idle / PING sweep (EventBridge Scheduler)
API Gateway WebSockets have no server-side idle timer. We schedule our own per-connection PINGs.
onMessage success path
│
│ schedule one-time EventBridge job:
│ target = ping-lambda
│ input = { connectionId }
│ at = now + PING_INTERVAL
│
▼
┌────────────────────────────┐
│ EventBridge Scheduler │
│ (one schedule per conn) │
└─────────┬──────────────────┘
│ at scheduled time
▼
┌─────────────────────────────────────────────┐
│ ping Lambda │
│ │
│ 1. read Connections[conn].idleSince │
│ │
│ 2. if idleSince older than threshold: │
│ postToConnection(PING <token>) │
│ schedule a 2nd job at + PONG_WINDOW │
│ │
│ 3. 2nd-job fires: │
│ if still no PONG seen │
│ → onDisconnect() cleanup │
│ else │
│ → no-op │
└─────────────────────────────────────────────┘
The sweeper Lambda (TICKET-041) is a separate schedule that scans
Connections for rows past a hard TTL and performs the same cleanup as
onDisconnect. It exists to catch connections where the client
vanished without APIGW ever emitting $disconnect.
7. Membership transaction (JOIN / PART / KICK)
Cross-table consistency uses TransactWriteItems. Up to 100 items per
transaction (AWS limit); channels above that size use a sharded SK
scheme (post-v1).
JOIN #foo
│
▼
AwsRuntime.applyChannelDelta("#foo", { add: sender })
│
│ TransactWriteItems:
│
│ ┌──────────────────────────────────────────────┐
│ │ ConditionCheck ChannelMeta │
│ │ PK = "#foo" exists │
│ │ (not invite-only OR sender has invite) │
│ │ (not full: count < limit) │
│ ├──────────────────────────────────────────────┤
│ │ Put ChannelMembers │
│ │ PK="#foo", SK=sender │
│ │ condition: attribute_not_exists(SK) │
│ ├──────────────────────────────────────────────┤
│ │ Update Connections │
│ │ PK = sender │
│ │ ADD joined "#foo" │
│ │ condition: set size < MAX_CHANNELS_PER_USER│
│ └──────────────────────────────────────────────┘
│
│ all-or-nothing → races structurally impossible
│
▼
dispatch Broadcast(JOIN), 353 NAMES, 366 END-NAMES,
and when a topic is set: 332 RPL_TOPIC, 333 RPL_TOPICWHOTIME
A CancellationException from TransactWriteItems maps to the
appropriate numeric (471 channel full, 473 invite-only,
405 too many channels). Mapping lives in AwsRuntime, not the
reducer — the reducer only knows it asked for a delta and whether it
succeeded.
8. Where each command runs (location-of-authority, AWS)
Mirrors PLAN §2.1. On AWS the "authority" is whichever table owns the state; the same reducer code runs everywhere.
┌──────────────────────────┬────────────────────────────────────┐
│ Command family │ Authority (DynamoDB table) │
├──────────────────────────┼────────────────────────────────────┤
│ NICK / USER / PASS │ Connections (row per conn) │
│ CAP negotiation │ Connections.caps │
│ PING / PONG / QUIT │ Connections │
│ AWAY / SETNAME / user MODE│ Connections │
├──────────────────────────┼────────────────────────────────────┤
│ JOIN / PART │ ChannelMembers (+ Connections.txn) │
│ channel MODE / TOPIC │ ChannelMeta │
│ KICK / INVITE │ ChannelMeta + ChannelMembers │
│ channel PRIVMSG/NOTICE │ Query ChannelMembers → fanout │
├──────────────────────────┼────────────────────────────────────┤
│ Nick collision check │ Nicks (conditional PutItem) │
├──────────────────────────┼────────────────────────────────────┤
│ PRIVMSG/NOTICE to user │ Nicks → Connections → postToConn │
├──────────────────────────┼────────────────────────────────────┤
│ NAMES / WHO / WHOIS │ read-only across tables │
│ LIST │ scan ChannelMeta (throttled) │
├──────────────────────────┼────────────────────────────────────┤
│ SASL AUTHENTICATE │ Accounts (verify hash) │
└──────────────────────────┴────────────────────────────────────┘
9. Deploy topology (CDK, TICKET-039)
┌───────────────────────────────────────────────────────────┐
│ apps/aws-stack (CDK v2 app, TypeScript) │
│ │
│ ┌─ WebSocketApi (AWS::ApiGatewayV2::Api) │
│ │ stage: $default, auto-deploy, throttling │
│ │ │
│ ├─ Lambda: onConnect ─┐ │
│ ├─ Lambda: onDisconnect ├─ shared role │
│ ├─ Lambda: onMessage │ (or one Lambda + alias) │
│ ├─ Lambda: ping │ │
│ ├─ Lambda: sweeper ─┘ │
│ │ │
│ ├─ Integration: aws-proxy (each route → its Lambda) │
│ │ │
│ ├─ Table: Connections (PAY_PER_REQUEST, TTL=idleSince) │
│ ├─ Table: ChannelMeta (PAY_PER_REQUEST) │
│ ├─ Table: ChannelMembers (PAY_PER_REQUEST) │
│ ├─ Table: Nicks (PAY_PER_REQUEST) │
│ ├─ Table: Accounts (PAY_PER_REQUEST) │
│ │ │
│ ├─ Secret: server-config (MOTD, server name, passwords) │
│ ├─ Scheduler: ping-jobs (schedule group per env) │
│ ├─ Scheduler: sweeper (fixed-rate rate(5 minutes)) │
│ │ │
│ └─ CloudWatch dashboard, log groups, alarms │
│ │
└───────────────────────────────────────────────────────────┘
outputs:
ConnectUrl ── wss://<id>.execute-api.<region>.amazonaws.com/<stage>
ManagementUrl ── https://<id>.execute-api.<region>.amazonaws.com/<stage>
(used by Lambda for postToConnection)
Local testing uses localstack (APIGW + Lambda + EventBridge) and
dynamodb-local (real DynamoDB semantics). The Phase 2 contract suite
(tests/integration) runs the same scenarios against AwsRuntime
pointed at these local emulators.
10. AWS vs Cloudflare (side-by-side, from PLAN §6.3)
For context; the Cloudflare diagrams live in docs/architecture-cf.md
(to be written in Phase 3).
┌─────────────────────┬──────────────────────┬──────────────────────┐
│ Capability │ AWS adapter │ CF adapter │
├─────────────────────┼──────────────────────┼──────────────────────┤
│ Connection identity │ APIGW connectionId │ DO idFromName(connId)│
│ Long-lived socket │ APIGW WS (≤ 2 h) │ Hibernatable WS in DO│
│ Send to client │ postToConnection │ ws.send in target DO │
│ Channel authority │ DDB ChannelMembers │ ChannelDO single-thr │
│ Nick uniqueness │ conditional PutItem │ RegistryDO serial │
│ Cross-instance │ DDB query + fanout │ stub() DO→DO │
│ Timers │ EventBridge Scheduler│ DO alarms │
│ Durable storage │ DynamoDB │ DO storage / D1 │
│ Secret config │ Secrets Manager /SSM │ Worker secrets / KV │
└─────────────────────┴──────────────────────┴──────────────────────┘
Open questions (PLAN §9, AWS-specific)
- Region strategy: single region for v1. Multi-region would need DynamoDB global tables + per-region APIGW + a routing layer.
- Hot-channel fanout ceiling: O(members)
postToConnectioncalls per message. Mitigation deferred; revisit at load-test time (TICKET-049). - 2-hour APIGW WS cap: requires a documented reconnect contract.
Clients MUST tolerate a server-initiated close + re-
$connect. - DynamoDB cost runaway: on-demand billing for v1 simplicity; provisioned capacity is a post-launch optimization.