serverless-ircd 0.1.0 → 0.2.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/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- 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/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -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 +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -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 +67 -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/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 +158 -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 +427 -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/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- 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/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -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/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 +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -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 +66 -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/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -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/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -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 +162 -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 +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -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 +1 -1
- 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/progress.md +107 -0
- package/tickets.md +2485 -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/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/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -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,494 @@
|
|
|
1
|
+
# AWS Adapter Architecture
|
|
2
|
+
|
|
3
|
+
Architecture diagrams for the AWS implementation (Phase 4 of `PLAN.md`).
|
|
4
|
+
All diagrams are ASCII art for terminal-friendly viewing. Cross-reference:
|
|
5
|
+
`PLAN.md` §2 (hexagonal architecture), §4 (state model), §6.2 (AWS mapping).
|
|
6
|
+
|
|
7
|
+
Legend for arrows:
|
|
8
|
+
|
|
9
|
+
──▶ synchronous call / request
|
|
10
|
+
══▶ WebSocket frame (client ↔ APIGW)
|
|
11
|
+
─┄┄ scheduled / asynchronous trigger
|
|
12
|
+
─│▶ read/write against durable state
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 1. System overview
|
|
17
|
+
|
|
18
|
+
The AWS adapter implements the `IrcRuntime` port (defined in
|
|
19
|
+
`packages/irc-server`) on top of API Gateway WebSockets, Lambda, and
|
|
20
|
+
DynamoDB. The pure IRC core in `packages/irc-core` is cloud-agnostic and
|
|
21
|
+
shared with the Cloudflare adapter.
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
┌─────────────────────────────────────┐
|
|
25
|
+
│ IRC clients │
|
|
26
|
+
│ WeeChat · HexChat · IRCCloud · │
|
|
27
|
+
│ TheLounge · matrix-IRC bridge │
|
|
28
|
+
└──────────────────┬──────────────────┘
|
|
29
|
+
│ wss (one IRC msg per text frame)
|
|
30
|
+
║
|
|
31
|
+
▼
|
|
32
|
+
┌──────────────────────────────────────────────────────────────────────────┐
|
|
33
|
+
│ AWS region (single, v1) │
|
|
34
|
+
│ │
|
|
35
|
+
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
|
36
|
+
│ │ API Gateway v2 WebSocket API │ │
|
|
37
|
+
│ │ routes: $connect $disconnect $default │ │
|
|
38
|
+
│ │ identity: connectionId (stable per WS session, ≤ 2 h) │ │
|
|
39
|
+
│ └────────┬──────────────────┬─────────────────────┬────────────────┘ │
|
|
40
|
+
│ │ │ │ │
|
|
41
|
+
│ ▼ ▼ ▼ │
|
|
42
|
+
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
43
|
+
│ │ Lambda (Node 20, esbuild bundle) │ │
|
|
44
|
+
│ │ │ │
|
|
45
|
+
│ │ handlers: onConnect onDisconnect onMessage │ │
|
|
46
|
+
│ │ pipeline: frame → ConnectionActor → reducer → dispatch │ │
|
|
47
|
+
│ │ runtime: AwsRuntime implements IrcRuntime │ │
|
|
48
|
+
│ └──────┬────────────────────────────────────────────┬──────────┘ │
|
|
49
|
+
│ │ │ │
|
|
50
|
+
│ │ ┌─────────────────────────────┘ │
|
|
51
|
+
│ │ │ │
|
|
52
|
+
│ ▼ ▼ │
|
|
53
|
+
│ ┌──────────────────────────────┐ ┌──────────────────────────────┐ │
|
|
54
|
+
│ │ DynamoDB (on-demand) │ │ ApiGatewayManagementApi │ │
|
|
55
|
+
│ │ │ │ postToConnection(connectionId) │
|
|
56
|
+
│ │ • Connections │ │ fanout back to clients │ │
|
|
57
|
+
│ │ • ChannelMeta │ └──────────────┬───────────────┘ │
|
|
58
|
+
│ │ • ChannelMembers │ │ │
|
|
59
|
+
│ │ • Nicks │ │ │
|
|
60
|
+
│ │ • Accounts │ │ │
|
|
61
|
+
│ └──────────────────────────────┘ │ │
|
|
62
|
+
│ ▲ │ │
|
|
63
|
+
│ │ │ │
|
|
64
|
+
│ ┌──────┴───────────────┐ │ │
|
|
65
|
+
│ │ EventBridge Scheduler│ idle/PING sweeps │ │
|
|
66
|
+
│ │ per-connection jobs │──┄┄ Lambda ──────────────┘ │
|
|
67
|
+
│ └──────────────────────┘ │
|
|
68
|
+
│ │
|
|
69
|
+
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
|
70
|
+
│ │ Supporting services │ │
|
|
71
|
+
│ │ • Secrets Manager / SSM Parameter Store (config, SASL hashes) │ │
|
|
72
|
+
│ │ • CloudWatch Logs + Metrics (observability) │ │
|
|
73
|
+
│ │ • IAM roles, least-privilege per Lambda (security) │ │
|
|
74
|
+
│ └──────────────────────────────────────────────────────────────────┘ │
|
|
75
|
+
└──────────────────────────────────────────────────────────────────────────┘
|
|
76
|
+
║
|
|
77
|
+
▼
|
|
78
|
+
┌─────────────────────────────────────┐
|
|
79
|
+
│ packages/irc-core (shared brain) │
|
|
80
|
+
│ pure reducers: (state,msg) → │
|
|
81
|
+
│ { state, effects } │
|
|
82
|
+
└─────────────────────────────────────┘
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The Lambda bundle includes `irc-core`, `irc-server`, and `aws-adapter`.
|
|
86
|
+
Only the I/O shell is AWS-specific.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## 2. Connection lifecycle
|
|
91
|
+
|
|
92
|
+
API Gateway emits three synthetic route events. Each invokes a Lambda;
|
|
93
|
+
the persistent `connectionId` is the source of truth for identity.
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
client API Gateway Lambda handler
|
|
97
|
+
│ │ │
|
|
98
|
+
│ HTTP Upgrade (wss) │ │
|
|
99
|
+
│═══════════════════════════════▶│ │
|
|
100
|
+
│ │ $connect event │
|
|
101
|
+
│ │──▶ onConnect() │
|
|
102
|
+
│ │ │ │
|
|
103
|
+
│ │ │ PutItem Connections │
|
|
104
|
+
│ │ │ (status=connecting) │
|
|
105
|
+
│ │ │ │
|
|
106
|
+
│ │ │ optional: PASS check │
|
|
107
|
+
│ │ ▼ │
|
|
108
|
+
│ 101 Switching Protocols │ │
|
|
109
|
+
│◀═══════════════════════════════│ │
|
|
110
|
+
│ │ │
|
|
111
|
+
│ CAP LS / NICK / USER ... │ │
|
|
112
|
+
│═══════════════════════════════▶│ $default event │
|
|
113
|
+
│ │──▶ onMessage(body) │
|
|
114
|
+
│ │ │ │
|
|
115
|
+
│ │ │ ConnectionActor │
|
|
116
|
+
│ │ │ .ingest(frame) │
|
|
117
|
+
│ │ │ parse → route → │
|
|
118
|
+
│ │ │ reducer → dispatch │
|
|
119
|
+
│ │ │ │
|
|
120
|
+
│ │ │ Welcome 001..005 │
|
|
121
|
+
│ │ │ via postToConnection │
|
|
122
|
+
│ :server 001 nick :Welcome │ │ │
|
|
123
|
+
│◀═══════════════════════════════│◀──────┘ │
|
|
124
|
+
│ │ │
|
|
125
|
+
│ ... PRIVMSG / JOIN / MODE ... │ │
|
|
126
|
+
│═══════════════════════════════▶│ $default (repeat per frame) │
|
|
127
|
+
│ │ │
|
|
128
|
+
│ close / TCP drop / 2h cap │ │
|
|
129
|
+
│──── (or idle PING timeout) ───▶│ $disconnect event │
|
|
130
|
+
│ │──▶ onDisconnect() │
|
|
131
|
+
│ │ │ │
|
|
132
|
+
│ │ │ QUIT reducer: │
|
|
133
|
+
│ │ │ Broadcast QUIT │
|
|
134
|
+
│ │ │ ReleaseNick │
|
|
135
|
+
│ │ │ DeleteItem Connections│
|
|
136
|
+
│ │ │ Del ChannelMembers │
|
|
137
|
+
│ │ ▼ │
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Two failure modes worth noting:
|
|
141
|
+
|
|
142
|
+
- **Gone connection (lazy cleanup):** if `postToConnection` throws
|
|
143
|
+
`GoneException`, the Lambda deletes the `Connections` row and emits a
|
|
144
|
+
best-effort QUIT broadcast. A separate sweeper (§5) catches rows that
|
|
145
|
+
never receive another message.
|
|
146
|
+
- **2-hour APIGW cap:** clients MUST tolerate a server-driven reconnect.
|
|
147
|
+
The server signals via `PONG`-driven reset; the reducer stays pure and
|
|
148
|
+
only emits the disconnect effect.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## 3. Request pipeline inside Lambda (`onMessage`)
|
|
153
|
+
|
|
154
|
+
One Lambda invocation per WebSocket frame. The same `ConnectionActor`
|
|
155
|
+
used by the in-memory and Cloudflare runtimes drives the work; only
|
|
156
|
+
`AwsRuntime` (the I/O interpreter) differs.
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
API Gateway $default event
|
|
160
|
+
{ connectionId, body }
|
|
161
|
+
│
|
|
162
|
+
▼
|
|
163
|
+
┌────────────────────────────────────────────────────────────┐
|
|
164
|
+
│ onMessage(event) │
|
|
165
|
+
│ │
|
|
166
|
+
│ 1. split body on "\r\n" (tolerant: also accept single │
|
|
167
|
+
│ message per frame; see PLAN §9) │
|
|
168
|
+
│ │
|
|
169
|
+
│ 2. for each line: │
|
|
170
|
+
│ parser.parse(line) ◀── irc-core/protocol │
|
|
171
|
+
│ │ │
|
|
172
|
+
│ ▼ │
|
|
173
|
+
│ IrcMessage { command, params, tags } │
|
|
174
|
+
│ │ │
|
|
175
|
+
│ ▼ │
|
|
176
|
+
│ ConnectionActor.ingest(msg) │
|
|
177
|
+
│ │ │
|
|
178
|
+
│ │ ┌── flood-control wrapper (token bucket) │
|
|
179
|
+
│ │ │ (TICKET-028, pure) │
|
|
180
|
+
│ │ ▼ │
|
|
181
|
+
│ │ location-table lookup ──▶ reducer │
|
|
182
|
+
│ │ (irc-core/commands) │
|
|
183
|
+
│ │ (state, msg, ctx) │
|
|
184
|
+
│ │ │ │
|
|
185
|
+
│ │ ▼ │
|
|
186
|
+
│ │ { state', effects[] } │
|
|
187
|
+
│ │ │ │
|
|
188
|
+
│ ▼ ▼ │
|
|
189
|
+
│ dispatch(effects, AwsRuntime) ◀── irc-server │
|
|
190
|
+
└────────────────────────────┬───────────────────────────────┘
|
|
191
|
+
│
|
|
192
|
+
┌───────────────┼────────────────┐
|
|
193
|
+
▼ ▼ ▼
|
|
194
|
+
DynamoDB postToConnection EventBridge
|
|
195
|
+
(state writes) (client fanout) (schedule PING)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Key invariant: the **reducer never touches AWS**. It returns `Effect[]`
|
|
199
|
+
values; `dispatch` is the only function that awaits I/O. That is what
|
|
200
|
+
makes the Phase 2 contract suite (TICKET-032) reusable across runtimes.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## 4. DynamoDB schema (PLAN §4)
|
|
205
|
+
|
|
206
|
+
Five tables. Single-table design was rejected: the access patterns
|
|
207
|
+
(per-connection, per-channel, per-nick) are disjoint enough that
|
|
208
|
+
splitting yields clearer conditional writes and transactions.
|
|
209
|
+
|
|
210
|
+
```
|
|
211
|
+
Connections ChannelMeta
|
|
212
|
+
┌──────────────┬─────────────┐ ┌──────────────┬────────────┐
|
|
213
|
+
│ PK │ connectionId│ │ PK │ channelName│
|
|
214
|
+
├──────────────┼─────────────┤ │ (lowercased) │ │
|
|
215
|
+
│ nick │ string │ ├──────────────┼────────────┤
|
|
216
|
+
│ user │ string │ │ topic │ string │
|
|
217
|
+
│ hostmask │ string │ │ modes │ set │
|
|
218
|
+
│ caps │ set │ │ key │ string? │
|
|
219
|
+
│ account │ string? │ │ limit │ number? │
|
|
220
|
+
│ joined[] │ list │ │ banMasks │ list │
|
|
221
|
+
│ idleSince │ number │ └──────────────┴────────────┘
|
|
222
|
+
│ status │ enum │
|
|
223
|
+
│ callbackUrl │ string │ ChannelMembers
|
|
224
|
+
└──────────────┴─────────────┘ ┌──────────────┬────────────┐
|
|
225
|
+
│ PK │ channelName│
|
|
226
|
+
Nicks │ SK │connectionId│
|
|
227
|
+
┌──────────────┬────────────┐ ├──────────────┼────────────┤
|
|
228
|
+
│ PK │ nickLower │ │ prefixes │ set(@,+) │
|
|
229
|
+
│ (lowercased) │ │ │ joinedAt │ number │
|
|
230
|
+
├──────────────┼────────────┤ └──────────────┴────────────┘
|
|
231
|
+
│ connectionId │ string │ query pattern: PK = chan
|
|
232
|
+
│ (unique via │ → returns all members for fanout
|
|
233
|
+
│ conditional PutItem) │
|
|
234
|
+
└──────────────┴────────────┘ Accounts
|
|
235
|
+
┌──────────────┬────────────┐
|
|
236
|
+
│ PK │ account │
|
|
237
|
+
├──────────────┼────────────┤
|
|
238
|
+
│ passHash │ bytes │
|
|
239
|
+
│ salt │ bytes │
|
|
240
|
+
└──────────────┴────────────┘
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Uniqueness invariants** are enforced structurally, not by application
|
|
244
|
+
logic:
|
|
245
|
+
|
|
246
|
+
- `Nicks.PK` — conditional `PutItem` (`attribute_not_exists(PK)`) makes
|
|
247
|
+
nick reservation atomic across concurrent Lambdas.
|
|
248
|
+
- `ChannelMembers` PK+SK composite — `TransactWriteItems` for JOIN/PART
|
|
249
|
+
updates `Connections.joined` and `ChannelMembers` in one atomic write.
|
|
250
|
+
- `Accounts.passHash` — never plaintext; PLAIN SASL verifies against
|
|
251
|
+
this hash server-side.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## 5. Channel fanout (PRIVMSG to #chan)
|
|
256
|
+
|
|
257
|
+
There is no long-lived per-channel process (contrast with CF's
|
|
258
|
+
`ChannelDO`). Instead, fanout is computed per message by querying
|
|
259
|
+
`ChannelMembers`.
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
sender's Lambda (onMessage PRIVMSG #foo :hello)
|
|
263
|
+
│
|
|
264
|
+
│ 1. reducer emits:
|
|
265
|
+
│ Broadcast("#foo", [":nick PRIVMSG #foo :hello"],
|
|
266
|
+
│ except=sender)
|
|
267
|
+
│
|
|
268
|
+
│ 2. AwsRuntime.broadcast():
|
|
269
|
+
│
|
|
270
|
+
│ ┌────────────────────────────────┐
|
|
271
|
+
│ │ Query ChannelMembers │
|
|
272
|
+
│ │ PK = "#foo" │──┐
|
|
273
|
+
│ └────────────────────────────────┘ │
|
|
274
|
+
│ │
|
|
275
|
+
│ ◀─── [connA, connB, connC, ...] ────┘
|
|
276
|
+
│
|
|
277
|
+
│ 3. for each member (parallel, batched):
|
|
278
|
+
│
|
|
279
|
+
│ ┌──────────────────────────────┐
|
|
280
|
+
│ │ postToConnection(member, │──▶ client A
|
|
281
|
+
│ │ line) │──▶ client B
|
|
282
|
+
│ └──────────────────────────────┘──▶ client C
|
|
283
|
+
│ (skip sender)
|
|
284
|
+
│
|
|
285
|
+
│ 4. on GoneException:
|
|
286
|
+
│ mark conn gone ──▶ sweeper (§6)
|
|
287
|
+
│
|
|
288
|
+
▼
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Trade-off: this is O(members) API calls per message. For hot channels
|
|
292
|
+
this is the main scaling ceiling on AWS (PLAN §10). Mitigations deferred
|
|
293
|
+
to post-v1: batched `postToConnection` calls, per-channel send-list
|
|
294
|
+
cache, or a fanout Lambda invoked once per message.
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## 6. Idle / PING sweep (EventBridge Scheduler)
|
|
299
|
+
|
|
300
|
+
API Gateway WebSockets have no server-side idle timer. We schedule our
|
|
301
|
+
own per-connection PINGs.
|
|
302
|
+
|
|
303
|
+
```
|
|
304
|
+
onMessage success path
|
|
305
|
+
│
|
|
306
|
+
│ schedule one-time EventBridge job:
|
|
307
|
+
│ target = ping-lambda
|
|
308
|
+
│ input = { connectionId }
|
|
309
|
+
│ at = now + PING_INTERVAL
|
|
310
|
+
│
|
|
311
|
+
▼
|
|
312
|
+
┌────────────────────────────┐
|
|
313
|
+
│ EventBridge Scheduler │
|
|
314
|
+
│ (one schedule per conn) │
|
|
315
|
+
└─────────┬──────────────────┘
|
|
316
|
+
│ at scheduled time
|
|
317
|
+
▼
|
|
318
|
+
┌─────────────────────────────────────────────┐
|
|
319
|
+
│ ping Lambda │
|
|
320
|
+
│ │
|
|
321
|
+
│ 1. read Connections[conn].idleSince │
|
|
322
|
+
│ │
|
|
323
|
+
│ 2. if idleSince older than threshold: │
|
|
324
|
+
│ postToConnection(PING <token>) │
|
|
325
|
+
│ schedule a 2nd job at + PONG_WINDOW │
|
|
326
|
+
│ │
|
|
327
|
+
│ 3. 2nd-job fires: │
|
|
328
|
+
│ if still no PONG seen │
|
|
329
|
+
│ → onDisconnect() cleanup │
|
|
330
|
+
│ else │
|
|
331
|
+
│ → no-op │
|
|
332
|
+
└─────────────────────────────────────────────┘
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The sweeper Lambda (TICKET-041) is a separate schedule that scans
|
|
336
|
+
`Connections` for rows past a hard TTL and performs the same cleanup as
|
|
337
|
+
`onDisconnect`. It exists to catch connections where the client
|
|
338
|
+
vanished without APIGW ever emitting `$disconnect`.
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
## 7. Membership transaction (JOIN / PART / KICK)
|
|
343
|
+
|
|
344
|
+
Cross-table consistency uses `TransactWriteItems`. Up to 100 items per
|
|
345
|
+
transaction (AWS limit); channels above that size use a sharded SK
|
|
346
|
+
scheme (post-v1).
|
|
347
|
+
|
|
348
|
+
```
|
|
349
|
+
JOIN #foo
|
|
350
|
+
│
|
|
351
|
+
▼
|
|
352
|
+
AwsRuntime.applyChannelDelta("#foo", { add: sender })
|
|
353
|
+
│
|
|
354
|
+
│ TransactWriteItems:
|
|
355
|
+
│
|
|
356
|
+
│ ┌──────────────────────────────────────────────┐
|
|
357
|
+
│ │ ConditionCheck ChannelMeta │
|
|
358
|
+
│ │ PK = "#foo" exists │
|
|
359
|
+
│ │ (not invite-only OR sender has invite) │
|
|
360
|
+
│ │ (not full: count < limit) │
|
|
361
|
+
│ ├──────────────────────────────────────────────┤
|
|
362
|
+
│ │ Put ChannelMembers │
|
|
363
|
+
│ │ PK="#foo", SK=sender │
|
|
364
|
+
│ │ condition: attribute_not_exists(SK) │
|
|
365
|
+
│ ├──────────────────────────────────────────────┤
|
|
366
|
+
│ │ Update Connections │
|
|
367
|
+
│ │ PK = sender │
|
|
368
|
+
│ │ ADD joined "#foo" │
|
|
369
|
+
│ │ condition: set size < MAX_CHANNELS_PER_USER│
|
|
370
|
+
│ └──────────────────────────────────────────────┘
|
|
371
|
+
│
|
|
372
|
+
│ all-or-nothing → races structurally impossible
|
|
373
|
+
│
|
|
374
|
+
▼
|
|
375
|
+
dispatch Broadcast(JOIN), 353 NAMES, 366 END-NAMES
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
A `CancellationException` from `TransactWriteItems` maps to the
|
|
379
|
+
appropriate numeric (`471` channel full, `473` invite-only,
|
|
380
|
+
`405` too many channels). Mapping lives in `AwsRuntime`, not the
|
|
381
|
+
reducer — the reducer only knows it asked for a delta and whether it
|
|
382
|
+
succeeded.
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
## 8. Where each command runs (location-of-authority, AWS)
|
|
387
|
+
|
|
388
|
+
Mirrors PLAN §2.1. On AWS the "authority" is whichever table owns the
|
|
389
|
+
state; the same reducer code runs everywhere.
|
|
390
|
+
|
|
391
|
+
```
|
|
392
|
+
┌──────────────────────────┬────────────────────────────────────┐
|
|
393
|
+
│ Command family │ Authority (DynamoDB table) │
|
|
394
|
+
├──────────────────────────┼────────────────────────────────────┤
|
|
395
|
+
│ NICK / USER / PASS │ Connections (row per conn) │
|
|
396
|
+
│ CAP negotiation │ Connections.caps │
|
|
397
|
+
│ PING / PONG / QUIT │ Connections │
|
|
398
|
+
│ AWAY / SETNAME / user MODE│ Connections │
|
|
399
|
+
├──────────────────────────┼────────────────────────────────────┤
|
|
400
|
+
│ JOIN / PART │ ChannelMembers (+ Connections.txn) │
|
|
401
|
+
│ channel MODE / TOPIC │ ChannelMeta │
|
|
402
|
+
│ KICK / INVITE │ ChannelMeta + ChannelMembers │
|
|
403
|
+
│ channel PRIVMSG/NOTICE │ Query ChannelMembers → fanout │
|
|
404
|
+
├──────────────────────────┼────────────────────────────────────┤
|
|
405
|
+
│ Nick collision check │ Nicks (conditional PutItem) │
|
|
406
|
+
├──────────────────────────┼────────────────────────────────────┤
|
|
407
|
+
│ PRIVMSG/NOTICE to user │ Nicks → Connections → postToConn │
|
|
408
|
+
├──────────────────────────┼────────────────────────────────────┤
|
|
409
|
+
│ NAMES / WHO / WHOIS │ read-only across tables │
|
|
410
|
+
│ LIST │ scan ChannelMeta (throttled) │
|
|
411
|
+
├──────────────────────────┼────────────────────────────────────┤
|
|
412
|
+
│ SASL AUTHENTICATE │ Accounts (verify hash) │
|
|
413
|
+
└──────────────────────────┴────────────────────────────────────┘
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
## 9. Deploy topology (CDK, TICKET-039)
|
|
419
|
+
|
|
420
|
+
```
|
|
421
|
+
┌───────────────────────────────────────────────────────────┐
|
|
422
|
+
│ apps/aws-stack (CDK v2 app, TypeScript) │
|
|
423
|
+
│ │
|
|
424
|
+
│ ┌─ WebSocketApi (AWS::ApiGatewayV2::Api) │
|
|
425
|
+
│ │ stage: $default, auto-deploy, throttling │
|
|
426
|
+
│ │ │
|
|
427
|
+
│ ├─ Lambda: onConnect ─┐ │
|
|
428
|
+
│ ├─ Lambda: onDisconnect ├─ shared role │
|
|
429
|
+
│ ├─ Lambda: onMessage │ (or one Lambda + alias) │
|
|
430
|
+
│ ├─ Lambda: ping │ │
|
|
431
|
+
│ ├─ Lambda: sweeper ─┘ │
|
|
432
|
+
│ │ │
|
|
433
|
+
│ ├─ Integration: aws-proxy (each route → its Lambda) │
|
|
434
|
+
│ │ │
|
|
435
|
+
│ ├─ Table: Connections (PAY_PER_REQUEST, TTL=idleSince) │
|
|
436
|
+
│ ├─ Table: ChannelMeta (PAY_PER_REQUEST) │
|
|
437
|
+
│ ├─ Table: ChannelMembers (PAY_PER_REQUEST) │
|
|
438
|
+
│ ├─ Table: Nicks (PAY_PER_REQUEST) │
|
|
439
|
+
│ ├─ Table: Accounts (PAY_PER_REQUEST) │
|
|
440
|
+
│ │ │
|
|
441
|
+
│ ├─ Secret: server-config (MOTD, server name, passwords) │
|
|
442
|
+
│ ├─ Scheduler: ping-jobs (schedule group per env) │
|
|
443
|
+
│ ├─ Scheduler: sweeper (fixed-rate rate(5 minutes)) │
|
|
444
|
+
│ │ │
|
|
445
|
+
│ └─ CloudWatch dashboard, log groups, alarms │
|
|
446
|
+
│ │
|
|
447
|
+
└───────────────────────────────────────────────────────────┘
|
|
448
|
+
|
|
449
|
+
outputs:
|
|
450
|
+
ConnectUrl ── wss://<id>.execute-api.<region>.amazonaws.com/<stage>
|
|
451
|
+
ManagementUrl ── https://<id>.execute-api.<region>.amazonaws.com/<stage>
|
|
452
|
+
(used by Lambda for postToConnection)
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Local testing uses `localstack` (APIGW + Lambda + EventBridge) and
|
|
456
|
+
`dynamodb-local` (real DynamoDB semantics). The Phase 2 contract suite
|
|
457
|
+
(`tests/integration`) runs the same scenarios against `AwsRuntime`
|
|
458
|
+
pointed at these local emulators.
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
## 10. AWS vs Cloudflare (side-by-side, from PLAN §6.3)
|
|
463
|
+
|
|
464
|
+
For context; the Cloudflare diagrams live in `docs/architecture-cf.md`
|
|
465
|
+
(to be written in Phase 3).
|
|
466
|
+
|
|
467
|
+
```
|
|
468
|
+
┌─────────────────────┬──────────────────────┬──────────────────────┐
|
|
469
|
+
│ Capability │ AWS adapter │ CF adapter │
|
|
470
|
+
├─────────────────────┼──────────────────────┼──────────────────────┤
|
|
471
|
+
│ Connection identity │ APIGW connectionId │ DO idFromName(connId)│
|
|
472
|
+
│ Long-lived socket │ APIGW WS (≤ 2 h) │ Hibernatable WS in DO│
|
|
473
|
+
│ Send to client │ postToConnection │ ws.send in target DO │
|
|
474
|
+
│ Channel authority │ DDB ChannelMembers │ ChannelDO single-thr │
|
|
475
|
+
│ Nick uniqueness │ conditional PutItem │ RegistryDO serial │
|
|
476
|
+
│ Cross-instance │ DDB query + fanout │ stub() DO→DO │
|
|
477
|
+
│ Timers │ EventBridge Scheduler│ DO alarms │
|
|
478
|
+
│ Durable storage │ DynamoDB │ DO storage / D1 │
|
|
479
|
+
│ Secret config │ Secrets Manager /SSM │ Worker secrets / KV │
|
|
480
|
+
└─────────────────────┴──────────────────────┴──────────────────────┘
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
---
|
|
484
|
+
|
|
485
|
+
## Open questions (PLAN §9, AWS-specific)
|
|
486
|
+
|
|
487
|
+
- **Region strategy**: single region for v1. Multi-region would need
|
|
488
|
+
DynamoDB global tables + per-region APIGW + a routing layer.
|
|
489
|
+
- **Hot-channel fanout ceiling**: O(members) `postToConnection` calls
|
|
490
|
+
per message. Mitigation deferred; revisit at load-test time (TICKET-049).
|
|
491
|
+
- **2-hour APIGW WS cap**: requires a documented reconnect contract.
|
|
492
|
+
Clients MUST tolerate a server-initiated close + re-`$connect`.
|
|
493
|
+
- **DynamoDB cost runaway**: on-demand billing for v1 simplicity;
|
|
494
|
+
provisioned capacity is a post-launch optimization.
|