ServerlessIRCd Docs

Docs home

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:


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:

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:


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)