serverless-ircd 0.3.0 → 0.4.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.
Files changed (159) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +174 -18
  8. package/README.md +63 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +20 -3
  38. package/package.json +14 -10
  39. package/packages/aws-adapter/package.json +3 -3
  40. package/packages/aws-adapter/src/account-store.ts +1 -1
  41. package/packages/aws-adapter/src/admission.ts +74 -0
  42. package/packages/aws-adapter/src/aws-runtime.ts +6 -4
  43. package/packages/aws-adapter/src/config-loader.ts +32 -0
  44. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  45. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  46. package/packages/aws-adapter/src/handlers/default.ts +35 -2
  47. package/packages/aws-adapter/src/handlers/index.ts +69 -3
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  49. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  50. package/packages/aws-adapter/src/index.ts +6 -0
  51. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  52. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  53. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  54. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  55. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  56. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  57. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  58. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  59. package/packages/aws-adapter/tests/handlers.test.ts +154 -53
  60. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  62. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  63. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  64. package/packages/cf-adapter/package.json +1 -1
  65. package/packages/cf-adapter/src/cf-runtime.ts +2 -4
  66. package/packages/cf-adapter/src/config-loader.ts +33 -0
  67. package/packages/cf-adapter/src/connection-do.ts +111 -46
  68. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  69. package/packages/cf-adapter/src/env.ts +33 -9
  70. package/packages/cf-adapter/src/index.ts +9 -8
  71. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  72. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  73. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  74. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  75. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  76. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  78. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  79. package/packages/cf-adapter/tests/worker/main.ts +8 -1
  80. package/packages/cf-adapter/wrangler.test.toml +8 -0
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +6 -1
  83. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  84. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  85. package/packages/irc-core/src/cloak.ts +1 -1
  86. package/packages/irc-core/src/commands/cap.ts +8 -1
  87. package/packages/irc-core/src/commands/index.ts +9 -0
  88. package/packages/irc-core/src/commands/ison.ts +61 -0
  89. package/packages/irc-core/src/commands/quit.ts +12 -0
  90. package/packages/irc-core/src/commands/registration.ts +18 -12
  91. package/packages/irc-core/src/commands/sasl.ts +72 -9
  92. package/packages/irc-core/src/commands/server-info.ts +129 -0
  93. package/packages/irc-core/src/commands/userhost.ts +84 -0
  94. package/packages/irc-core/src/commands/whowas.ts +113 -0
  95. package/packages/irc-core/src/config.ts +58 -0
  96. package/packages/irc-core/src/credential-hashing.ts +124 -0
  97. package/packages/irc-core/src/effects.ts +6 -29
  98. package/packages/irc-core/src/index.ts +1 -0
  99. package/packages/irc-core/src/ports.ts +181 -12
  100. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  101. package/packages/irc-core/src/types.ts +38 -1
  102. package/packages/irc-core/tests/account-store.test.ts +45 -2
  103. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  104. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  105. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  106. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  107. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  108. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  109. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  111. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  112. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  113. package/packages/irc-core/tests/config.test.ts +95 -1
  114. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  115. package/packages/irc-core/tests/effects.test.ts +0 -27
  116. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  117. package/packages/irc-core/tests/numerics.test.ts +12 -0
  118. package/packages/irc-core/tests/types.test.ts +35 -1
  119. package/packages/irc-core/tsconfig.build.json +1 -1
  120. package/packages/irc-core/tsconfig.test.json +1 -1
  121. package/packages/irc-server/package.json +1 -1
  122. package/packages/irc-server/src/actor.ts +158 -14
  123. package/packages/irc-server/src/dispatch.ts +0 -3
  124. package/packages/irc-server/src/index.ts +10 -2
  125. package/packages/irc-server/src/routing.ts +12 -0
  126. package/packages/irc-server/src/transport.ts +101 -0
  127. package/packages/irc-server/tests/actor.test.ts +400 -3
  128. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  129. package/packages/irc-server/tests/routing.test.ts +4 -0
  130. package/packages/irc-server/tests/transport.test.ts +230 -0
  131. package/packages/irc-test-support/package.json +1 -1
  132. package/packages/irc-test-support/src/harness.ts +44 -9
  133. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  134. package/packages/irc-test-support/src/index.ts +3 -0
  135. package/packages/irc-test-support/src/scenarios.ts +132 -2
  136. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  137. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  138. package/pnpm-workspace.yaml +9 -1
  139. package/tools/ci-hardening/package.json +1 -1
  140. package/tools/package.json +4 -0
  141. package/tools/seed-aws-accounts.ts +5 -6
  142. package/tools/seed-cf-accounts.ts +104 -0
  143. package/tools/tcp-ws-forwarder/package.json +1 -1
  144. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  145. package/docs/ADR-002-location-of-authority.md +0 -82
  146. package/docs/ADR-003-durable-object-sharding.md +0 -93
  147. package/docs/ADR-004-dynamodb-schema.md +0 -96
  148. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  149. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  150. package/docs/ADR-007-deterministic-ports.md +0 -82
  151. package/docs/ADR-008-monorepo-tooling.md +0 -60
  152. package/docs/AWS-Adapter-Architecture.md +0 -496
  153. package/docs/AWS-Deployment.md +0 -1186
  154. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  155. package/docs/Home.md +0 -11
  156. package/docs/Observability.md +0 -87
  157. package/docs/PlanIRCv3Websocket.md +0 -489
  158. package/docs/PlanWebClient.md +0 -451
  159. package/docs/Release-Process.md +0 -443
@@ -1,496 +0,0 @@
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
- │ algorithm │ string │
239
- │ salt │ string │
240
- │ hash │ string │
241
- └──────────────┴────────────┘
242
- ```
243
-
244
- **Uniqueness invariants** are enforced structurally, not by application
245
- logic:
246
-
247
- - `Nicks.PK` — conditional `PutItem` (`attribute_not_exists(PK)`) makes
248
- nick reservation atomic across concurrent Lambdas.
249
- - `ChannelMembers` PK+SK composite — `TransactWriteItems` for JOIN/PART
250
- updates `Connections.joined` and `ChannelMembers` in one atomic write.
251
- - `Accounts` row schema — `{ account (PK), algorithm, salt, hash }`,
252
- all strings. Never plaintext; PLAIN SASL verifies the scrypt hash
253
- server-side.
254
-
255
- ---
256
-
257
- ## 5. Channel fanout (PRIVMSG to #chan)
258
-
259
- There is no long-lived per-channel process (contrast with CF's
260
- `ChannelDO`). Instead, fanout is computed per message by querying
261
- `ChannelMembers`.
262
-
263
- ```
264
- sender's Lambda (onMessage PRIVMSG #foo :hello)
265
-
266
- │ 1. reducer emits:
267
- │ Broadcast("#foo", [":nick PRIVMSG #foo :hello"],
268
- │ except=sender)
269
-
270
- │ 2. AwsRuntime.broadcast():
271
-
272
- │ ┌────────────────────────────────┐
273
- │ │ Query ChannelMembers │
274
- │ │ PK = "#foo" │──┐
275
- │ └────────────────────────────────┘ │
276
- │ │
277
- │ ◀─── [connA, connB, connC, ...] ────┘
278
-
279
- │ 3. for each member (parallel, batched):
280
-
281
- │ ┌──────────────────────────────┐
282
- │ │ postToConnection(member, │──▶ client A
283
- │ │ line) │──▶ client B
284
- │ └──────────────────────────────┘──▶ client C
285
- │ (skip sender)
286
-
287
- │ 4. on GoneException:
288
- │ mark conn gone ──▶ sweeper (§6)
289
-
290
-
291
- ```
292
-
293
- Trade-off: this is O(members) API calls per message. For hot channels
294
- this is the main scaling ceiling on AWS (PLAN §10). Mitigations deferred
295
- to post-v1: batched `postToConnection` calls, per-channel send-list
296
- cache, or a fanout Lambda invoked once per message.
297
-
298
- ---
299
-
300
- ## 6. Idle / PING sweep (EventBridge Scheduler)
301
-
302
- API Gateway WebSockets have no server-side idle timer. We schedule our
303
- own per-connection PINGs.
304
-
305
- ```
306
- onMessage success path
307
-
308
- │ schedule one-time EventBridge job:
309
- │ target = ping-lambda
310
- │ input = { connectionId }
311
- │ at = now + PING_INTERVAL
312
-
313
-
314
- ┌────────────────────────────┐
315
- │ EventBridge Scheduler │
316
- │ (one schedule per conn) │
317
- └─────────┬──────────────────┘
318
- │ at scheduled time
319
-
320
- ┌─────────────────────────────────────────────┐
321
- │ ping Lambda │
322
- │ │
323
- │ 1. read Connections[conn].idleSince │
324
- │ │
325
- │ 2. if idleSince older than threshold: │
326
- │ postToConnection(PING <token>) │
327
- │ schedule a 2nd job at + PONG_WINDOW │
328
- │ │
329
- │ 3. 2nd-job fires: │
330
- │ if still no PONG seen │
331
- │ → onDisconnect() cleanup │
332
- │ else │
333
- │ → no-op │
334
- └─────────────────────────────────────────────┘
335
- ```
336
-
337
- The sweeper Lambda (TICKET-041) is a separate schedule that scans
338
- `Connections` for rows past a hard TTL and performs the same cleanup as
339
- `onDisconnect`. It exists to catch connections where the client
340
- vanished without APIGW ever emitting `$disconnect`.
341
-
342
- ---
343
-
344
- ## 7. Membership transaction (JOIN / PART / KICK)
345
-
346
- Cross-table consistency uses `TransactWriteItems`. Up to 100 items per
347
- transaction (AWS limit); channels above that size use a sharded SK
348
- scheme (post-v1).
349
-
350
- ```
351
- JOIN #foo
352
-
353
-
354
- AwsRuntime.applyChannelDelta("#foo", { add: sender })
355
-
356
- │ TransactWriteItems:
357
-
358
- │ ┌──────────────────────────────────────────────┐
359
- │ │ ConditionCheck ChannelMeta │
360
- │ │ PK = "#foo" exists │
361
- │ │ (not invite-only OR sender has invite) │
362
- │ │ (not full: count < limit) │
363
- │ ├──────────────────────────────────────────────┤
364
- │ │ Put ChannelMembers │
365
- │ │ PK="#foo", SK=sender │
366
- │ │ condition: attribute_not_exists(SK) │
367
- │ ├──────────────────────────────────────────────┤
368
- │ │ Update Connections │
369
- │ │ PK = sender │
370
- │ │ ADD joined "#foo" │
371
- │ │ condition: set size < MAX_CHANNELS_PER_USER│
372
- │ └──────────────────────────────────────────────┘
373
-
374
- │ all-or-nothing → races structurally impossible
375
-
376
-
377
- dispatch Broadcast(JOIN), 353 NAMES, 366 END-NAMES
378
- ```
379
-
380
- A `CancellationException` from `TransactWriteItems` maps to the
381
- appropriate numeric (`471` channel full, `473` invite-only,
382
- `405` too many channels). Mapping lives in `AwsRuntime`, not the
383
- reducer — the reducer only knows it asked for a delta and whether it
384
- succeeded.
385
-
386
- ---
387
-
388
- ## 8. Where each command runs (location-of-authority, AWS)
389
-
390
- Mirrors PLAN §2.1. On AWS the "authority" is whichever table owns the
391
- state; the same reducer code runs everywhere.
392
-
393
- ```
394
- ┌──────────────────────────┬────────────────────────────────────┐
395
- │ Command family │ Authority (DynamoDB table) │
396
- ├──────────────────────────┼────────────────────────────────────┤
397
- │ NICK / USER / PASS │ Connections (row per conn) │
398
- │ CAP negotiation │ Connections.caps │
399
- │ PING / PONG / QUIT │ Connections │
400
- │ AWAY / SETNAME / user MODE│ Connections │
401
- ├──────────────────────────┼────────────────────────────────────┤
402
- │ JOIN / PART │ ChannelMembers (+ Connections.txn) │
403
- │ channel MODE / TOPIC │ ChannelMeta │
404
- │ KICK / INVITE │ ChannelMeta + ChannelMembers │
405
- │ channel PRIVMSG/NOTICE │ Query ChannelMembers → fanout │
406
- ├──────────────────────────┼────────────────────────────────────┤
407
- │ Nick collision check │ Nicks (conditional PutItem) │
408
- ├──────────────────────────┼────────────────────────────────────┤
409
- │ PRIVMSG/NOTICE to user │ Nicks → Connections → postToConn │
410
- ├──────────────────────────┼────────────────────────────────────┤
411
- │ NAMES / WHO / WHOIS │ read-only across tables │
412
- │ LIST │ scan ChannelMeta (throttled) │
413
- ├──────────────────────────┼────────────────────────────────────┤
414
- │ SASL AUTHENTICATE │ Accounts (verify hash) │
415
- └──────────────────────────┴────────────────────────────────────┘
416
- ```
417
-
418
- ---
419
-
420
- ## 9. Deploy topology (CDK, TICKET-039)
421
-
422
- ```
423
- ┌───────────────────────────────────────────────────────────┐
424
- │ apps/aws-stack (CDK v2 app, TypeScript) │
425
- │ │
426
- │ ┌─ WebSocketApi (AWS::ApiGatewayV2::Api) │
427
- │ │ stage: $default, auto-deploy, throttling │
428
- │ │ │
429
- │ ├─ Lambda: onConnect ─┐ │
430
- │ ├─ Lambda: onDisconnect ├─ shared role │
431
- │ ├─ Lambda: onMessage │ (or one Lambda + alias) │
432
- │ ├─ Lambda: ping │ │
433
- │ ├─ Lambda: sweeper ─┘ │
434
- │ │ │
435
- │ ├─ Integration: aws-proxy (each route → its Lambda) │
436
- │ │ │
437
- │ ├─ Table: Connections (PAY_PER_REQUEST, TTL=idleSince) │
438
- │ ├─ Table: ChannelMeta (PAY_PER_REQUEST) │
439
- │ ├─ Table: ChannelMembers (PAY_PER_REQUEST) │
440
- │ ├─ Table: Nicks (PAY_PER_REQUEST) │
441
- │ ├─ Table: Accounts (PAY_PER_REQUEST) │
442
- │ │ │
443
- │ ├─ Secret: server-config (MOTD, server name, passwords) │
444
- │ ├─ Scheduler: ping-jobs (schedule group per env) │
445
- │ ├─ Scheduler: sweeper (fixed-rate rate(5 minutes)) │
446
- │ │ │
447
- │ └─ CloudWatch dashboard, log groups, alarms │
448
- │ │
449
- └───────────────────────────────────────────────────────────┘
450
-
451
- outputs:
452
- ConnectUrl ── wss://<id>.execute-api.<region>.amazonaws.com/<stage>
453
- ManagementUrl ── https://<id>.execute-api.<region>.amazonaws.com/<stage>
454
- (used by Lambda for postToConnection)
455
- ```
456
-
457
- Local testing uses `localstack` (APIGW + Lambda + EventBridge) and
458
- `dynamodb-local` (real DynamoDB semantics). The Phase 2 contract suite
459
- (`tests/integration`) runs the same scenarios against `AwsRuntime`
460
- pointed at these local emulators.
461
-
462
- ---
463
-
464
- ## 10. AWS vs Cloudflare (side-by-side, from PLAN §6.3)
465
-
466
- For context; the Cloudflare diagrams live in `docs/architecture-cf.md`
467
- (to be written in Phase 3).
468
-
469
- ```
470
- ┌─────────────────────┬──────────────────────┬──────────────────────┐
471
- │ Capability │ AWS adapter │ CF adapter │
472
- ├─────────────────────┼──────────────────────┼──────────────────────┤
473
- │ Connection identity │ APIGW connectionId │ DO idFromName(connId)│
474
- │ Long-lived socket │ APIGW WS (≤ 2 h) │ Hibernatable WS in DO│
475
- │ Send to client │ postToConnection │ ws.send in target DO │
476
- │ Channel authority │ DDB ChannelMembers │ ChannelDO single-thr │
477
- │ Nick uniqueness │ conditional PutItem │ RegistryDO serial │
478
- │ Cross-instance │ DDB query + fanout │ stub() DO→DO │
479
- │ Timers │ EventBridge Scheduler│ DO alarms │
480
- │ Durable storage │ DynamoDB │ DO storage / D1 │
481
- │ Secret config │ Secrets Manager /SSM │ Worker secrets / KV │
482
- └─────────────────────┴──────────────────────┴──────────────────────┘
483
- ```
484
-
485
- ---
486
-
487
- ## Open questions (PLAN §9, AWS-specific)
488
-
489
- - **Region strategy**: single region for v1. Multi-region would need
490
- DynamoDB global tables + per-region APIGW + a routing layer.
491
- - **Hot-channel fanout ceiling**: O(members) `postToConnection` calls
492
- per message. Mitigation deferred; revisit at load-test time (TICKET-049).
493
- - **2-hour APIGW WS cap**: requires a documented reconnect contract.
494
- Clients MUST tolerate a server-initiated close + re-`$connect`.
495
- - **DynamoDB cost runaway**: on-demand billing for v1 simplicity;
496
- provisioned capacity is a post-launch optimization.