serverless-ircd 0.4.0 → 0.6.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 (243) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +435 -0
  6. package/README.md +206 -27
  7. package/apps/aws-stack/README.md +37 -3
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +106 -13
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +49 -3
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +138 -28
  27. package/apps/local-cli/tests/e2e.test.ts +113 -29
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  56. package/packages/aws-adapter/src/handlers/default.ts +66 -5
  57. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
  59. package/packages/aws-adapter/src/index.ts +2 -0
  60. package/packages/aws-adapter/src/serialize.ts +40 -2
  61. package/packages/aws-adapter/src/stats.ts +80 -0
  62. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  63. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  64. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  65. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  66. package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
  67. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  68. package/packages/aws-adapter/tests/connect.test.ts +100 -4
  69. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  70. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  72. package/packages/aws-adapter/tests/handlers.test.ts +238 -4
  73. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  74. package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
  75. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  76. package/packages/cf-adapter/package.json +6 -1
  77. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  78. package/packages/cf-adapter/src/channel-do.ts +2 -2
  79. package/packages/cf-adapter/src/connection-do.ts +185 -54
  80. package/packages/cf-adapter/src/env.ts +25 -6
  81. package/packages/cf-adapter/src/index.ts +2 -0
  82. package/packages/cf-adapter/src/registry-do.ts +22 -3
  83. package/packages/cf-adapter/src/serialize.ts +25 -4
  84. package/packages/cf-adapter/src/sharding.ts +1 -2
  85. package/packages/cf-adapter/src/stats.ts +65 -0
  86. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  87. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  88. package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
  89. package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
  90. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  91. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  92. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  93. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  94. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  95. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  96. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  97. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  98. package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
  99. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  100. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  101. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  102. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  103. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  104. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  105. package/packages/cf-adapter/vitest.config.ts +1 -1
  106. package/packages/cf-adapter/wrangler.test.toml +7 -0
  107. package/packages/in-memory-runtime/package.json +1 -1
  108. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  109. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  110. package/packages/irc-core/package.json +1 -1
  111. package/packages/irc-core/src/admission.ts +16 -15
  112. package/packages/irc-core/src/caps/capabilities.ts +38 -4
  113. package/packages/irc-core/src/caps/index.ts +1 -0
  114. package/packages/irc-core/src/caps/sts.ts +84 -0
  115. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  116. package/packages/irc-core/src/commands/away.ts +9 -3
  117. package/packages/irc-core/src/commands/cap.ts +23 -5
  118. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  119. package/packages/irc-core/src/commands/index.ts +42 -0
  120. package/packages/irc-core/src/commands/invite.ts +2 -4
  121. package/packages/irc-core/src/commands/isupport.ts +59 -2
  122. package/packages/irc-core/src/commands/kick.ts +2 -4
  123. package/packages/irc-core/src/commands/kill.ts +127 -0
  124. package/packages/irc-core/src/commands/list.ts +1 -1
  125. package/packages/irc-core/src/commands/lusers.ts +204 -0
  126. package/packages/irc-core/src/commands/mode.ts +12 -9
  127. package/packages/irc-core/src/commands/monitor.ts +327 -0
  128. package/packages/irc-core/src/commands/multiline.ts +256 -0
  129. package/packages/irc-core/src/commands/names.ts +3 -5
  130. package/packages/irc-core/src/commands/part.ts +2 -4
  131. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  132. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  133. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  134. package/packages/irc-core/src/commands/registration.ts +8 -0
  135. package/packages/irc-core/src/commands/rehash.ts +119 -0
  136. package/packages/irc-core/src/commands/sasl.ts +24 -1
  137. package/packages/irc-core/src/commands/setname.ts +109 -0
  138. package/packages/irc-core/src/commands/stats.ts +152 -0
  139. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  140. package/packages/irc-core/src/commands/topic.ts +2 -4
  141. package/packages/irc-core/src/commands/trace.ts +137 -0
  142. package/packages/irc-core/src/commands/wallops.ts +118 -0
  143. package/packages/irc-core/src/commands/whois.ts +5 -0
  144. package/packages/irc-core/src/config.ts +72 -10
  145. package/packages/irc-core/src/effects.ts +41 -1
  146. package/packages/irc-core/src/index.ts +2 -0
  147. package/packages/irc-core/src/ports.ts +568 -0
  148. package/packages/irc-core/src/protocol/index.ts +14 -0
  149. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  150. package/packages/irc-core/src/protocol/numerics.ts +57 -11
  151. package/packages/irc-core/src/protocol/outbound.ts +36 -4
  152. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  153. package/packages/irc-core/src/state/connection.ts +32 -1
  154. package/packages/irc-core/src/types.ts +120 -1
  155. package/packages/irc-core/src/ws-framing.ts +132 -0
  156. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  157. package/packages/irc-core/stryker.commands.conf.json +1 -2
  158. package/packages/irc-core/tests/admission.test.ts +18 -0
  159. package/packages/irc-core/tests/away-store.test.ts +73 -0
  160. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  161. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  162. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  163. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  164. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  165. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  166. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  167. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  168. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  169. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  170. package/packages/irc-core/tests/commands/mode.test.ts +129 -0
  171. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  172. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  173. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  174. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  175. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  176. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  177. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  178. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  179. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  180. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  181. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  182. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  183. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  184. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  185. package/packages/irc-core/tests/config.test.ts +103 -13
  186. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  187. package/packages/irc-core/tests/effects.test.ts +14 -0
  188. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  189. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  190. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  191. package/packages/irc-core/tests/numerics.test.ts +90 -0
  192. package/packages/irc-core/tests/outbound.test.ts +51 -0
  193. package/packages/irc-core/tests/ports.test.ts +22 -0
  194. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  195. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  196. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  197. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  198. package/packages/irc-core/tests/types.test.ts +27 -0
  199. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  200. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  201. package/packages/irc-server/package.json +1 -1
  202. package/packages/irc-server/src/actor.ts +699 -19
  203. package/packages/irc-server/src/dispatch.ts +109 -16
  204. package/packages/irc-server/src/routing.ts +3 -0
  205. package/packages/irc-server/src/runtime.ts +31 -0
  206. package/packages/irc-server/src/transport.ts +10 -7
  207. package/packages/irc-server/tests/actor.test.ts +2523 -42
  208. package/packages/irc-server/tests/dispatch.test.ts +300 -2
  209. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  210. package/packages/irc-server/tests/routing.test.ts +1 -0
  211. package/packages/irc-server/tests/runtime.test.ts +7 -0
  212. package/packages/irc-test-support/package.json +1 -1
  213. package/packages/irc-test-support/src/index.ts +6 -0
  214. package/packages/irc-test-support/src/scenarios.ts +9 -1
  215. package/packages/irc-test-support/src/test-config.ts +54 -0
  216. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  217. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/tools/ci-hardening/package.json +1 -1
  220. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  221. package/tools/load-test/package.json +33 -0
  222. package/tools/load-test/src/client.ts +351 -0
  223. package/tools/load-test/src/config.ts +313 -0
  224. package/tools/load-test/src/harness.ts +116 -0
  225. package/tools/load-test/src/main.ts +120 -0
  226. package/tools/load-test/src/metrics.ts +168 -0
  227. package/tools/load-test/src/report.ts +106 -0
  228. package/tools/load-test/tests/client.test.ts +212 -0
  229. package/tools/load-test/tests/config.test.ts +152 -0
  230. package/tools/load-test/tests/framing.test.ts +37 -0
  231. package/tools/load-test/tests/harness.test.ts +165 -0
  232. package/tools/load-test/tests/metrics.test.ts +174 -0
  233. package/tools/load-test/tests/report.test.ts +161 -0
  234. package/tools/load-test/tests/smoke.test.ts +67 -0
  235. package/tools/load-test/tsconfig.build.json +12 -0
  236. package/tools/load-test/tsconfig.test.json +10 -0
  237. package/tools/load-test/vitest.config.ts +29 -0
  238. package/tools/package.json +6 -1
  239. package/tools/seed-cf-accounts.ts +4 -1
  240. package/tools/tcp-ws-forwarder/package.json +1 -1
  241. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  242. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  243. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
package/README.md CHANGED
@@ -6,20 +6,33 @@ platform-agnostic core, and two thin adapters run it on **Cloudflare Workers**
6
6
 
7
7
  One TypeScript codebase. Two serverless substrates.
8
8
 
9
- > **Status:** **v0.4.0 (preview).** The pure protocol core, the
9
+ > **Status:** **v0.6.0 (preview).** The pure protocol core, the
10
10
  > `IrcRuntime` port + in-memory runtime, a runnable local CLI server,
11
11
  > the **Cloudflare Workers** adapter, and the **AWS** (API Gateway
12
12
  > WebSocket + Lambda + DynamoDB + CDK) adapter are all functional and
13
- > deployed to staging. v0.4.0 ships **dual transport**: both adapters
14
- > now speak a real `irc+tls :6697` (TLS-over-TCP) surface for stock IRC
13
+ > deployed to staging. v0.4.0 shipped **dual transport**: both adapters
14
+ > speak a real `irc+tls :6697` (TLS-over-TCP) surface for stock IRC
15
15
  > clients — Cloudflare via Spectrum + a Container origin, AWS via a
16
16
  > Network Load Balancer + Lambda streaming — alongside the WebSocket
17
- > path. It also lands **SASL `EXTERNAL` via mTLS**, the remaining
18
- > standard IRC verbs (`WHOWAS`, `VERSION`, `TIME`, `ADMIN`, `INFO`,
19
- > `USERHOST`, `ISON`), a **Cloudflare D1-backed SASL account store**,
20
- > config-driven server identity, and a **Node 24 / pnpm 11** toolchain.
21
- > Remaining 0.x work: oper-gated verbs (`KILL`/`REHASH`/…), a
22
- > required-`serverName` config gate, and load/compat testing. See
17
+ > path, plus **SASL `EXTERNAL` via mTLS** and a **Cloudflare D1-backed
18
+ > SASL account store**. v0.5.0 completed the **protocol surface**: the
19
+ > deferred IRC verbs (`KILL`, `REHASH`, `LUSERS` + `STATS`, `TRACE`,
20
+ > `WALLOPS`, `SETNAME`) all land, and the S2S (`CONNECT` / `SQUIT` /
21
+ > `LINKS`) and obsolete RFC 2812 (`SERVICE` / `SUMMON` / `USERS`) verbs
22
+ > are formally dropped. **v0.6.0 lands the IRCv3 extension sweep
23
+ > (Phase 12)** — ten caps (`account-notify`, `msgid`,
24
+ > `standard-replies`, `MONITOR`, `labeled-response`, `sts`,
25
+ > `draft/typing`, `draft/multiline`, `draft/read-marker`,
26
+ > `draft/pre-away`), the ISUPPORT tokens for the new caps, and the
27
+ > read-only user mode `S` (TLS connected) — closes the
28
+ > required-`serverName` config gate, ships a `tools/load-test`
29
+ > 10k-connection harness, and lifts `aws-adapter` / `aws-stack` over
30
+ > the 90% coverage gate (CF packages moved to istanbul coverage,
31
+ > clearing the v0.5.0 workerd-incompatibility caveat). Remaining 0.x
32
+ > work: the client compatibility sweep (WeeChat / HexChat / IRCCloud /
33
+ > TheLounge), IRC services (NickServ / ChanServ / HostServ /
34
+ > OperServ / MemoServ), a web client (vendored Kiwi IRC), and driving
35
+ > the remaining sub-100% packages to full coverage. See
23
36
  > `CHANGELOG.md` for the per-release manifests.
24
37
 
25
38
  ---
@@ -127,6 +140,7 @@ ServerlessIRCd/
127
140
  │ └── local-cli/ runnable WS + TCP server using in-memory-runtime
128
141
  ├── tools/
129
142
  │ ├── tcp-ws-forwarder/ local TCP↔ws/wss bridge for stock IRC clients
143
+ │ ├── load-test/ synthetic WebSocket IRC client farm (10k conns, p50/p95/p99, drop rate)
130
144
  │ ├── ci-hardening/ coverage-gate + mutation-config validators
131
145
  │ ├── seed-aws-accounts.ts scrypt-hash SASL PLAIN accounts into DynamoDB
132
146
  │ └── seed-cf-accounts.ts scrypt-hash SASL PLAIN accounts into Cloudflare D1
@@ -227,6 +241,8 @@ On startup it logs a JSON line like:
227
241
  | `--tcp-port <n>` | `<port> + 1` | TCP port for RFC-style IRC clients. |
228
242
  | `--no-tcp` | | Disable the TCP listener (WebSocket only). |
229
243
  | `--motd-file <p>` | built-in | Read MOTD lines from this file (one per line). |
244
+ | `--server-name <h>`| `irc.localhost` | Server hostname advertised in `001`/`005`. Production MUST override. |
245
+ | `--network-name <n>`| `LocalNet` | Network name advertised in `005 NETWORK=…`. |
230
246
  | `-h, --help` | | Show help and exit. |
231
247
 
232
248
  Pass `--host 0.0.0.0` to expose the server on all interfaces. `SIGINT` /
@@ -299,6 +315,91 @@ against any line-oriented WebSocket endpoint.
299
315
 
300
316
  ---
301
317
 
318
+ ## Load testing (`tools/load-test`)
319
+
320
+ A synthetic WebSocket IRC client farm (PLAN §7, Phase 7). It opens N
321
+ connections to a `ws://` / `wss://` target, registers each (NICK/USER),
322
+ joins a channel, and optionally chats. Per-phase **p50 / p95 / p99
323
+ latency** (connect / register / join / message) and the **drop rate**
324
+ are captured, then printed as a markdown report to stdout (and written
325
+ to `--report <path>` when given).
326
+
327
+ Build the workspace, then start a run (from the repo root):
328
+
329
+ ```bash
330
+ pnpm build
331
+
332
+ pnpm --filter load-test start -- \
333
+ --target wss://irc.staging.example.com/ \
334
+ --connections 10000 --concurrency 200 --ramp-ms 60000 \
335
+ --channel '#loadtest' --messages 3 --platform cf-staging \
336
+ --report reports/cf-staging-2026-08-03.md
337
+ ```
338
+
339
+ The harness drives the WebSocket transport only (the serverless default);
340
+ a TCP IRC client is not what a serverless load test exercises. Each
341
+ connection is independent — the package ships no `@serverless-ircd/*`
342
+ runtime dependency (only the IRC line framing shared with the
343
+ forwarder), so it can be pointed at any WebSocket IRC endpoint.
344
+
345
+ ### CLI flags
346
+
347
+ | Flag | Default | Description |
348
+ |-------------------------|-------------|----------------------------------------------------------|
349
+ | `--target <url>` | (required) | Upstream `ws://` or `wss://` URL to load test. |
350
+ | `--connections <n>` | `1000` | Total connections to open. |
351
+ | `--concurrency <n>` | `50` | Concurrent in-flight connection attempts during ramp-up. |
352
+ | `--ramp-ms <ms>` | `30000` | Ramp-up duration over which connections are opened. |
353
+ | `--channel <c>` | `#loadtest` | Channel every client JOINs. |
354
+ | `--messages <n>` | `0` | PRIVMSGs each client sends after JOIN (`0` = connect only). |
355
+ | `--echo-message` | | Negotiate `echo-message` so sent PRIVMSGs round-trip. |
356
+ | `--platform <name>` | `unknown` | Label baked into the report header. |
357
+ | `--report <path>` | | Write the markdown report to this path. |
358
+ | `-h, --help` | | Show help and exit. |
359
+
360
+ `SIGINT` / `SIGTERM` cancel an in-flight run gracefully (drains live
361
+ sockets, prints the partial report). The package ships its own vitest
362
+ suite at ≥90% coverage (config parsing, framing, harness, metrics,
363
+ report).
364
+
365
+ ---
366
+
367
+ ## WebSocket transport (IRCv3 subprotocols)
368
+
369
+ The WebSocket entry points (the local CLI, the Cloudflare Worker, and the
370
+ AWS API Gateway `$connect` route) implement the
371
+ [IRCv3 WebSocket support](https://ircv3.net/specs/websocket.html) subprotocol
372
+ negotiation. A client advertises support by offering one of two registered
373
+ subprotocols in its opening handshake's `Sec-WebSocket-Protocol` header:
374
+
375
+ | Subprotocol | Frames | Notes |
376
+ |--------------------|----------|--------------------------------------------------|
377
+ | `text.ircv3.net` | UTF-8 text | Lone surrogates → U+FFFD; binary frames rejected (close `1003`). |
378
+ | `binary.ircv3.net` | binary | UTF-8 encoded bytes; same framing as `text`. |
379
+
380
+ The server echoes back the first supported entry in client-preference order
381
+ (in the `101` response's `Sec-WebSocket-Protocol` header). Once negotiated,
382
+ the connection uses **spec framing**:
383
+
384
+ - **One IRC message per WebSocket message** — a frame is never split on an
385
+ embedded line break. A single optional trailing `CR-LF` (or bare `LF`) is
386
+ stripped; the remainder is exactly one IRC line.
387
+ - **No trailing `CR-LF` on the wire**, outbound or inbound.
388
+ - **510-byte message budget** — the 512-byte IRC line limit minus the omitted
389
+ `CR-LF`. A message exceeding 510 bytes is a protocol violation and the
390
+ connection is closed with RFC 6455 code `1009` (Message Too Big).
391
+
392
+ **Legacy fallback.** A client that offers no recognized subprotocol (or none
393
+ at all) is still accepted, in **legacy mode**: frames are split on `\r\n` so
394
+ older clients that concatenate several messages into one frame keep working,
395
+ and the parser's existing 512-byte line cap applies. Nothing breaks until a
396
+ client opts into a subprotocol. The in-tree
397
+ [`tcp-ws-forwarder`](#connecting-a-tcp-irc-client-to-a-deployed-stack-tcpws-forwarder)
398
+ offers `binary.ircv3.net` upstream, so a stock TCP IRC client bridged to a
399
+ deployed stack exercises the spec path end-to-end.
400
+
401
+ ---
402
+
302
403
  ## Testing strategy
303
404
 
304
405
  This project follows strict TDD (Red → Green → Refactor) — every reducer is
@@ -334,20 +435,63 @@ cleanly. Per-release manifests live in `CHANGELOG.md`.
334
435
  - **Phase 2** — `IrcRuntime` port, in-memory runtime, `ConnectionActor`,
335
436
  local CLI, parametrized contract suite. ✅ landed in **v0.1.0**.
336
437
  - **Phase 3** — Cloudflare adapter (ConnectionDO / ChannelDO / RegistryDO).
337
- ✅ landed in **v0.1.0** (staging auto-deploy via CI; prod deploy is manual).
438
+ ✅ landed in **v0.1.0** (staging deploy via CI; prod deploy is manual).
338
439
  - **Phase 4** — AWS adapter (APIGW WS + Lambda + DynamoDB + CDK).
339
- ✅ landed in **v0.2.0** (staging auto-deploy via CI; prod deploy is manual).
440
+ ✅ landed in **v0.2.0** (staging deploy via CI; prod deploy is manual).
340
441
  - **Phase 5** — Observability, security hardening, config, CI gates.
341
442
  ✅ landed across v0.2.0–v0.4.0 (logger port, server-password, cloaking,
342
443
  admission limits, coverage + mutation gates, OPER credential auth, the
343
- Cloudflare config loader, and AWS max-clients admission).
444
+ Cloudflare config loader, and AWS max-clients admission); the
445
+ required-`serverName` gate closed in **v0.6.0**.
344
446
  - **Phase 6** — TLS hardening & raw TCP transport (`irc+tls :6697`).
345
447
  ✅ landed in **v0.4.0**: generalized `ConnectionActor` transport seam,
346
448
  Cloudflare Spectrum + Container origin, AWS NLB + Lambda streaming,
347
449
  mTLS → SASL `EXTERNAL`, transport-parametrized contract suite, ADR-009.
348
- - **Phase 7** — Load testing (10k concurrent connections per platform),
349
- client compatibility sweep (WeeChat, HexChat, IRCCloud, TheLounge),
350
- remaining ADRs.
450
+ - **Phase 7 — Load testing & compatibility sweep.** 🔄 partial as of
451
+ **v0.6.0**: the `tools/load-test` 10k-connection harness is in-tree;
452
+ the formal 10k-connection report per platform and the client
453
+ compatibility matrix (WeeChat / HexChat / IRCCloud / TheLounge) are
454
+ still pending.
455
+ - **Phase 8 — PLAN-FIXES remediation.** ✅ landed across v0.3.0–v0.4.0
456
+ (MessageStore wired into all three adapters, AccountStore end-to-end,
457
+ the DynamoDB `Accounts` table decided, CF enumeration RPCs, AWS QUIT
458
+ fanout, `OPER` / `TAGMSG` / `WHOWAS`, RFC 1459 case-mapping, real
459
+ MOTD, config-driven server identity, AWS max-clients admission,
460
+ CF D1 SASL store, and a sweep of dead-code / stale-comment cleanup).
461
+ - **Phase 9 — Runtime & tooling baseline.** ✅ landed in **v0.4.0**
462
+ (Node ≥ 24 / pnpm 11).
463
+ - **Phase 10 — IRCv3 WebSocket extension.** ✅ landed in **v0.4.0**:
464
+ `text.ircv3.net` / `binary.ircv3.net` subprotocol negotiation +
465
+ per-message framing across the CF Worker, AWS APIGW, local CLI, and
466
+ `tcp-ws-forwarder` (510-byte budget, `1009`/`1003` close codes,
467
+ legacy fallback).
468
+ - **Phase 11 — Deferred IRC verbs.** ✅ landed in **v0.5.0**: `KILL`,
469
+ `REHASH`, `LUSERS` + `STATS`, `TRACE`, `WALLOPS`, `SETNAME`, plus
470
+ the formal drop of the S2S (`CONNECT`/`SQUIT`/`LINKS`) and obsolete
471
+ RFC 2812 (`SERVICE`/`SUMMON`/`USERS`) verbs. The protocol surface is
472
+ feature-complete for modern clients.
473
+ - **Phase 12 — IRCv3 extension sweep.** ✅ landed in **v0.6.0**: ten
474
+ negotiated caps (`account-notify`, `msgid`, `standard-replies`,
475
+ `MONITOR`, `labeled-response`, `sts`, `draft/typing`,
476
+ `draft/multiline`, `draft/read-marker`, `draft/pre-away`), the
477
+ ISUPPORT tokens for the new caps (`MONITOR`/`MULTILINE`/`TYPING`/
478
+ `STATUSMSG`/`EXTBAN`/`ACCOUNTEXTBAN`), the read-only user mode `S`
479
+ (TLS connected), and the async push fanout seam (Registry hooks →
480
+ `MONITOR` online/offline, `BroadcastWallops`-style caps-gated
481
+ delivery).
482
+ - **Phase 13 — IRC services.** ⬳ pending: `ServicesStore` port +
483
+ NickServ / ChanServ / HostServ / OperServ / MemoServ. Adapter
484
+ backends (D1 / DynamoDB) ship alongside; until then
485
+ `draft/read-marker` and `draft/pre-away` use in-memory reference
486
+ stores and do not survive an adapter restart.
487
+ - **Phase 14 — Web client.** ⬳ pending: vendored Kiwi IRC SPA served
488
+ from the Worker, WS Origin allowlist (CSWSH defense), landing page,
489
+ headless browser e2e.
490
+ - **Coverage hardening.** 🔄 partial as of **v0.6.0**: `aws-adapter`
491
+ and `aws-stack` now clear the 90% gate (CI boots DynamoDB Local via
492
+ a JRE; the CDK synth no longer re-bundles under coverage), and the
493
+ CF packages moved to istanbul to clear the v0.5.0 workerd-incompat.
494
+ Follow-ups drive each remaining sub-100% package to 100%.
351
495
 
352
496
  ---
353
497
 
@@ -356,21 +500,48 @@ cleanly. Per-release manifests live in `CHANGELOG.md`.
356
500
  **Core (RFC 1459/2812 subset):** registration (`NICK`/`USER`/`CAP`/`PASS`),
357
501
  `PING`/`PONG`, `QUIT`, `JOIN`, `PART`, `PRIVMSG`, `NOTICE`, `MODE` (user +
358
502
  channel), `TOPIC`, `KICK`, `INVITE`, `NAMES`, `LIST`, `WHO`, `WHOIS`,
359
- `WHOWAS`, `MOTD`, `AWAY`, `OPER` (credential auth → `o` user mode), plus
360
- the query verbs `VERSION`, `TIME`, `ADMIN`, `INFO`, `USERHOST`, `ISON`.
361
- (Oper-gated verbs `KILL`/`REHASH`/`CONNECT`/`SQUIT`/`TRACE` remain
362
- deferred.)
503
+ `WHOWAS`, `MOTD`, `AWAY`, `OPER` (credential auth → `o` user mode), the
504
+ query verbs `VERSION`, `TIME`, `ADMIN`, `INFO`, `USERHOST`, `ISON`, the
505
+ modern-online-presence verb `MONITOR` (`+`/`-`/`C`/`L`/`S` with the
506
+ `730`/`731`/`732`/`734` numerics and async online/offline push), the
507
+ network-statistics verbs `LUSERS` and `STATS` (`u` uptime + `l` link-info
508
+ letters; unknown letters get the charybdis-style empty body), the
509
+ routing-diagnostic verb `TRACE` (oper-gated per-connection detail), the
510
+ realname-change verb `SETNAME`, and the oper verbs `KILL` (force-
511
+ disconnect), `REHASH` (config reload via `IrcRuntime.reloadConfig()`),
512
+ `WALLOPS` (global broadcast to every `+w` user).
513
+
514
+ (Out of scope: the S2S verbs `CONNECT`/`SQUIT`/`LINKS` and the obsolete
515
+ RFC 2812 verbs `SERVICE`/`SUMMON`/`USERS` are **formally dropped** as of
516
+ v0.5.0 — they return `421 ERR_UNKNOWNCOMMAND`, and their reserved
517
+ numerics have been removed. S2S linking is a PLAN non-goal.)
363
518
 
364
519
  **Channel modes:** `o v b i k l t n m s p`.
365
- **User modes:** `i`, `o` (local only), `w`, `s`.
520
+ **User modes:** `i`, `o` (local only), `w`, `s`, and the read-only `S`
521
+ (TLS connected — set by the transport at registration, surfaced in WHOIS
522
+ via `276 RPL_WHOISSECURE`, not settable via `MODE`).
366
523
 
367
524
  **IRCv3 extensions (negotiated via `CAP`):** `message-tags` (incl. the
368
- `TAGMSG` command), `server-time`, `account-tag`, `echo-message`, `batch`,
525
+ `TAGMSG` command), `server-time`, `account-tag`, `account-notify`
526
+ (pushes `ACCOUNT` on SASL login/logout), `echo-message`, `batch`,
369
527
  `sasl` (`PLAIN` always; `EXTERNAL` via mTLS when a client-cert trust store
370
- is bound),
371
- `multi-prefix`, `away-notify`, `chghost`, `invite-notify`, `extended-join`,
372
- `draft/chathistory`, `safelist`. Case-insensitive nick/channel comparison
373
- uses **RFC 1459** case-mapping (advertised via `005 CASEMAPPING=rfc1459`).
528
+ is bound), `multi-prefix`, `away-notify`, `chghost`, `invite-notify`,
529
+ `extended-join`, `msgid` (`@+msgid=<id>` on every PRIVMSG/NOTICE/TAGMSG,
530
+ shared between live and `draft/chathistory` replay), `standard-replies`
531
+ (`FAIL`/`WARN`/`NOTE` replacements for a curated numeric subset),
532
+ `MONITOR=<n>` (config-driven ceiling, default 30), `labeled-response`
533
+ (`+label=<id>` → `BATCH +id labeled-response … BATCH -id` wrapping),
534
+ `sts` (Strict Transport Security — conditionally advertised with
535
+ `duration`/`port`/optional `preload` from `ServerConfig` when configured,
536
+ so compliant clients upgrade from plaintext to TLS and pin the secure
537
+ listener), `draft/chathistory`, `safelist`, `draft/typing` (typing-indicator
538
+ broadcast), `draft/multiline=<n>` (multi-line `BATCH`, default 4096-byte
539
+ budget), `draft/read-marker` (per-account persisted last-read), and
540
+ `draft/pre-away` (per-account persisted away reason, replayed at
541
+ identify). Case-insensitive nick/channel comparison uses **RFC 1459**
542
+ case-mapping (advertised via `005 CASEMAPPING=rfc1459`). The new
543
+ `MONITOR` / `MULTILINE` / `TYPING` / `STATUSMSG` / `EXTBAN` /
544
+ `ACCOUNTEXTBAN` ISUPPORT tokens are advertised from `ServerConfig`.
374
545
 
375
546
  **Transport:** deployed stacks (Cloudflare Workers, AWS API Gateway) speak
376
547
  WebSocket text frames by default (one IRC message per frame, with tolerance
@@ -388,9 +559,17 @@ bridges a stock TCP client to a deployed WebSocket endpoint.
388
559
  - `CHANGELOG.md` — per-release manifests (Keep a Changelog format), including
389
560
  the v0.2.0 work (AWS adapter, SASL, flood control, security hardening, CI
390
561
  gates), v0.3.0 (OPER, TAGMSG, RFC 1459 case-mapping, real MOTD,
391
- ChannelRegistryDO, SASL account persistence), and v0.4.0 (dual transport
562
+ ChannelRegistryDO, SASL account persistence), v0.4.0 (dual transport
392
563
  `irc+tls :6697`, SASL `EXTERNAL`/mTLS, WHOWAS + remaining verbs, D1 account
393
- store, Node 24/pnpm 11).
564
+ store, Node 24/pnpm 11), v0.5.0 (deferred IRC verbs `KILL` / `REHASH`
565
+ / `LUSERS` + `STATS` / `TRACE` / `WALLOPS` / `SETNAME`, plus the formal
566
+ drop of the S2S and obsolete RFC 2812 verbs), and **v0.6.0** (the IRCv3
567
+ extension sweep — `account-notify`, `msgid`, `standard-replies`,
568
+ `MONITOR`, `labeled-response`, `sts`, `draft/typing`, `draft/multiline`,
569
+ `draft/read-marker`, `draft/pre-away`, plus user mode `S`; the
570
+ required-`serverName` gate; `tools/load-test`; aws-adapter / aws-stack
571
+ coverage unblocks; CF packages moved to istanbul; deploys now
572
+ manual-only).
394
573
  - `docs/Cloudflare-TCP-Deployment.md` and `docs/AWS-TCP-Deployment.md` —
395
574
  end-to-end guides for the `:6697` TCP+TLS variants (Spectrum/Container on
396
575
  CF, NLB + Lambda streaming on AWS), including mTLS trust-store setup.
@@ -8,7 +8,7 @@ least-privilege IAM glue to let the Lambda read/write the tables and post back
8
8
  to connected clients via the management API.
9
9
 
10
10
  The Lambda handler is currently a stub (`{ statusCode: 200 }`); the real
11
- `AwsRuntime` dispatch lands in TICKET-040.
11
+ `AwsRuntime` dispatch lands with the AWS runtime work.
12
12
 
13
13
  ## Prerequisites
14
14
 
@@ -32,7 +32,7 @@ Repo-level shortcuts (from the monorepo root):
32
32
  ```sh
33
33
  pnpm deploy:aws:staging
34
34
  pnpm deploy:aws:prod
35
- pnpm smoke:aws:staging # deferred to TICKET-043
35
+ pnpm smoke:aws:staging # deferred (smoke harness not yet wired)
36
36
  ```
37
37
 
38
38
  ## Optional localstack validation
@@ -60,6 +60,39 @@ export AWS_DEFAULT_REGION=us-east-1
60
60
  LOCALSTACK=1 pnpm test
61
61
  ```
62
62
 
63
+ ## Test-mode bundle stub (why coverage does not re-bundle)
64
+
65
+ The stack ships four `NodejsFunction` constructs (the wss `IrcHandler`,
66
+ `IrcSweeper`, `IrcPingChecker`, and the optional NLB `IrcNlbHandler`).
67
+ Each one invokes esbuild at synth time to bundle the shared handler
68
+ entry. Repeated across the ~80 synth assertions in `tests/stack.test.ts`,
69
+ that meant the bundler ran **dozens to hundreds of times per coverage
70
+ invocation**, exhausting CI memory before vitest could print its
71
+ coverage table — the failure mode presented as a wall of
72
+ `Bundling asset TestStack/IrcHandler/Code/Stage…` lines followed by
73
+ `[ELIFECYCLE] Command failed with exit code N` and no report.
74
+
75
+ The fix is a single env-var toggle consumed by `makeLambda()` in
76
+ `src/aws-stack.ts`:
77
+
78
+ | Env var | Effect on the synth |
79
+ |-----------------------------------|-----------------------------------------------------------------------|
80
+ | `IRC_AWS_STACK_TEST_NO_BUNDLE=1` | Every Lambda is emitted as an inline `Code.fromInline('void 0')` zip; esbuild is not invoked at all. CloudFormation shape (runtime, env vars, IAM grants, route wiring, …) is identical to the production synth. |
81
+ | _(unset)_ | Production path: every Lambda is a real `NodejsFunction` whose asset is esbuild-bundled at synth time. This is what `cdk synth` / `cdk deploy` run. |
82
+
83
+ `tests/vitest.setup.ts` sets the flag once per test file so the entire
84
+ `pnpm test` / `pnpm coverage` run stays on the stub path. Production
85
+ synth (`bin/aws.ts` via the CDK CLI) never loads vitest, so it cannot
86
+ accidentally inherit the flag.
87
+
88
+ A small `tests/synth-no-bundle.test.ts` locks both paths in place: it
89
+ asserts the stub emits `Code: { ZipFile }` when the flag is set, and
90
+ exercises the production `NodejsFunction` return once (with the flag
91
+ explicitly unset) so the production branch stays covered. The
92
+ end-to-end guard lives in `tests/coverage-command.test.ts`, which
93
+ spawns `pnpm --filter @serverless-ircd/aws-stack coverage` in a
94
+ subprocess and asserts the report prints an `All files` row.
95
+
63
96
  ## Stack outputs
64
97
 
65
98
  | Output | Description |
@@ -70,4 +103,5 @@ LOCALSTACK=1 pnpm test
70
103
  ## Handoff
71
104
 
72
105
  Lambda runtime logic, DynamoDB access patterns, and the `AwsRuntime`
73
- implementation are TICKET-040. The scheduler/sweeper is TICKET-041/042.
106
+ implementation land with the AWS runtime work. The scheduler/sweeper is
107
+ the EventBridge sweeper work.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-stack",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "private": true,
5
5
  "description": "AWS CDK v2 stack: API Gateway v2 WebSocket API, Lambda, DynamoDB tables, least-privilege IAM",
6
6
  "license": "BSD-3-Clause",
@@ -17,7 +17,12 @@ import { resolve } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { TABLE_DEFS } from '@serverless-ircd/aws-adapter/cdk-table-defs';
19
19
  import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
20
- import { WebSocketApi, WebSocketStage } from 'aws-cdk-lib/aws-apigatewayv2';
20
+ import {
21
+ CfnIntegrationResponse,
22
+ WebSocketApi,
23
+ type WebSocketIntegration,
24
+ WebSocketStage,
25
+ } from 'aws-cdk-lib/aws-apigatewayv2';
21
26
  import { WebSocketLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
22
27
  import { Certificate } from 'aws-cdk-lib/aws-certificatemanager';
23
28
  import { Table } from 'aws-cdk-lib/aws-dynamodb';
@@ -32,8 +37,8 @@ import {
32
37
  import { LambdaTarget } from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets';
33
38
  import { Rule, Schedule } from 'aws-cdk-lib/aws-events';
34
39
  import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets';
35
- import { Runtime } from 'aws-cdk-lib/aws-lambda';
36
- import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
40
+ import { Code, Function as Lambda, Runtime } from 'aws-cdk-lib/aws-lambda';
41
+ import { NodejsFunction, type NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs';
37
42
  import type { Construct } from 'constructs';
38
43
 
39
44
  /** Stage name served by the auto-deploying WebSocket stage. */
@@ -59,8 +64,14 @@ const PING_CHECKER_SCHEDULE_RATE = Duration.minutes(1);
59
64
  */
60
65
  const DEFAULT_ENVIRONMENT_NAME = 'staging';
61
66
 
62
- /** Default server identity, used when the corresponding prop is omitted. */
63
- const DEFAULT_SERVER_NAME = 'irc.example.com';
67
+ /**
68
+ * Fallback server identity, used when the corresponding prop is omitted.
69
+ * Deliberately not `irc.example.com` (the retired placeholder) so a
70
+ * deployment that forgets to override advertises an obviously-local name
71
+ * instead of a deceptive example domain. Production stacks MUST pass
72
+ * `serverName` explicitly.
73
+ */
74
+ const DEFAULT_SERVER_NAME = 'irc.localhost';
64
75
  const DEFAULT_NETWORK_NAME = 'ExampleNet';
65
76
  const DEFAULT_MOTD_LINES = [
66
77
  'Welcome to the ServerlessIRCd deployment.',
@@ -69,6 +80,22 @@ const DEFAULT_MOTD_LINES = [
69
80
 
70
81
  const HERE = fileURLToPath(new URL('.', import.meta.url));
71
82
 
83
+ /**
84
+ * Env-var flag that flips {@link makeLambda} onto a stub code path which
85
+ * emits an inline Lambda zip and skips esbuild entirely. The IrcAwsStack
86
+ * test suite synthesizes the stack dozens of times per coverage run; if
87
+ * each synth invoked esbuild the runner exhausted its memory budget
88
+ * before the vitest coverage table could print. The stub preserves every
89
+ * CloudFormation shape the rest of the test suite asserts on (runtime,
90
+ * env vars, IAM grants, route wiring, …) while making the bundling step
91
+ * a no-op.
92
+ *
93
+ * Intentionally a plain `process.env` read (not a CDK context var or
94
+ * construct prop) so the toggle lives in one place — `tests/vitest.setup.ts`
95
+ * — and cannot leak into a production synth driven by `bin/aws.ts`.
96
+ */
97
+ const TEST_NO_BUNDLE_FLAG = 'IRC_AWS_STACK_TEST_NO_BUNDLE';
98
+
72
99
  /**
73
100
  * Construct props for {@link IrcAwsStack}. Acts as the single
74
101
  * configuration bag extended by deploy-time knobs (environment name,
@@ -88,7 +115,8 @@ export interface IrcStackProps extends StackProps {
88
115
 
89
116
  /**
90
117
  * Server name advertised to clients (`SERVER_NAME` / the `001`
91
- * welcome source). Defaults to `irc.example.com`.
118
+ * welcome source). Falls back to `irc.localhost` when omitted; production
119
+ * stacks MUST pass an explicit value.
92
120
  */
93
121
  readonly serverName?: string;
94
122
 
@@ -145,7 +173,7 @@ export class IrcAwsStack extends Stack {
145
173
  }),
146
174
  );
147
175
 
148
- const handler = new NodejsFunction(this, 'IrcHandler', {
176
+ const handler = makeLambda(this, 'IrcHandler', {
149
177
  runtime: Runtime.NODEJS_24_X,
150
178
  entry: handlerEntry(),
151
179
  handler: 'handler',
@@ -161,7 +189,7 @@ export class IrcAwsStack extends Stack {
161
189
  // bundle (tree-shaken to the sweeper path by esbuild at synth time).
162
190
  // Catches connections that vanished without APIGW emitting $disconnect;
163
191
  // see packages/aws-adapter/src/handlers/sweeper.ts.
164
- const sweeper = new NodejsFunction(this, 'IrcSweeper', {
192
+ const sweeper = makeLambda(this, 'IrcSweeper', {
165
193
  runtime: Runtime.NODEJS_24_X,
166
194
  entry: handlerEntry(),
167
195
  handler: 'sweeperHandler',
@@ -175,7 +203,7 @@ export class IrcAwsStack extends Stack {
175
203
  // those that never answer; see
176
204
  // packages/aws-adapter/src/handlers/ping-checker.ts. The Cloudflare
177
205
  // adapter implements the equivalent flow inside `ConnectionDO.alarm()`.
178
- const pingChecker = new NodejsFunction(this, 'IrcPingChecker', {
206
+ const pingChecker = makeLambda(this, 'IrcPingChecker', {
179
207
  runtime: Runtime.NODEJS_24_X,
180
208
  entry: handlerEntry(),
181
209
  handler: 'pingCheckerHandler',
@@ -191,9 +219,6 @@ export class IrcAwsStack extends Stack {
191
219
  }
192
220
 
193
221
  const webSocketApi = new WebSocketApi(this, 'IrcWsApi', {
194
- connectRouteOptions: {
195
- integration: new WebSocketLambdaIntegration('ConnectIntegration', handler),
196
- },
197
222
  disconnectRouteOptions: {
198
223
  integration: new WebSocketLambdaIntegration('DisconnectIntegration', handler),
199
224
  },
@@ -202,6 +227,32 @@ export class IrcAwsStack extends Stack {
202
227
  },
203
228
  });
204
229
 
230
+ // The `$connect` route is added explicitly (rather than via
231
+ // `connectRouteOptions`) so the negotiated IRCv3 WebSocket subprotocol can
232
+ // be echoed back to the client. API Gateway selects the WebSocket
233
+ // subprotocol from the `$connect` integration response, so the route is
234
+ // configured to return a response and an `IntegrationResponse` maps the
235
+ // `Sec-WebSocket-Protocol` header from the Lambda's reply onto the
236
+ // handshake. The Lambda reads the client's offer, selects the first
237
+ // supported entry, and returns it in its response header (see
238
+ // `handleConnect` / `dispatch`).
239
+ const connectRoute = webSocketApi.addRoute('$connect', {
240
+ integration: new WebSocketLambdaIntegration('ConnectIntegration', handler),
241
+ returnResponse: true,
242
+ });
243
+ const connectIntegration = connectRoute.node.findChild(
244
+ 'ConnectIntegration',
245
+ ) as WebSocketIntegration;
246
+ new CfnIntegrationResponse(this, 'ConnectSubprotocolResponse', {
247
+ apiId: webSocketApi.apiId,
248
+ integrationId: connectIntegration.integrationId,
249
+ integrationResponseKey: '$default',
250
+ responseParameters: {
251
+ 'method.response.header.Sec-WebSocket-Protocol':
252
+ 'integration.response.header.Sec-WebSocket-Protocol',
253
+ },
254
+ });
255
+
205
256
  // The stage name is intentionally NOT suffixed with the environment:
206
257
  // each environment is its own CloudFormation stack (and therefore its
207
258
  // own WebSocket API), so `prod` in every env resolves to a distinct
@@ -290,7 +341,7 @@ export class IrcAwsStack extends Stack {
290
341
  // Shares the bundled aws-adapter code (tree-shaken to the nlb-stream
291
342
  // path by esbuild at synth time). Reuses the same DynamoDB tables,
292
343
  // server identity, and MANAGEMENT_URL as the wss handler.
293
- const nlbHandler = new NodejsFunction(this, 'IrcNlbHandler', {
344
+ const nlbHandler = makeLambda(this, 'IrcNlbHandler', {
294
345
  runtime: Runtime.NODEJS_24_X,
295
346
  entry: handlerEntry(),
296
347
  handler: 'nlbStreamHandler',
@@ -363,6 +414,48 @@ function handlerEntry(): string {
363
414
  return resolve(HERE, '..', '..', '..', 'packages', 'aws-adapter', 'src', 'handlers', 'index.ts');
364
415
  }
365
416
 
417
+ /**
418
+ * Internal Lambda-construct props: `NodejsFunctionProps` narrowed so the
419
+ * runtime and handler are required. Every call site in the stack already
420
+ * passes both explicitly; this type just makes that invariant visible to
421
+ * the compiler so {@link makeLambda} can hand them straight through to
422
+ * the stub `Function` constructor without a non-null assertion.
423
+ */
424
+ type StrictLambdaProps = NodejsFunctionProps & {
425
+ readonly runtime: Runtime;
426
+ readonly handler: string;
427
+ };
428
+
429
+ /**
430
+ * Builds a Lambda function for the IRC stack, selecting between the real
431
+ * esbuild-bundled `NodejsFunction` and a no-op stub `Function` based on
432
+ * {@link TEST_NO_BUNDLE_FLAG}.
433
+ *
434
+ * Production synth (`bin/aws.ts`) never sets the flag, so it always lands
435
+ * on the `NodejsFunction` branch and produces a real bundled asset. The
436
+ * test suite sets the flag via `tests/vitest.setup.ts`, which routes
437
+ * every Lambda onto the inline-zip stub so the synth does not invoke
438
+ * esbuild and the coverage pipeline can complete.
439
+ *
440
+ * The stub mirrors only the fields the rest of the stack mutates
441
+ * post-construction (`runtime`, `handler`, env vars, IAM grants) — it
442
+ * deliberately ignores `entry` and `bundling` because both are
443
+ * meaningless once esbuild is bypassed.
444
+ *
445
+ * Returns the base `Function` type (not `NodejsFunction`) so callers
446
+ * cannot reach for bundler-only APIs on the stub path.
447
+ */
448
+ function makeLambda(scope: Construct, id: string, props: StrictLambdaProps): Lambda {
449
+ if (process.env[TEST_NO_BUNDLE_FLAG] === '1') {
450
+ return new Lambda(scope, id, {
451
+ runtime: props.runtime,
452
+ handler: props.handler,
453
+ code: Code.fromInline('void 0'),
454
+ });
455
+ }
456
+ return new NodejsFunction(scope, id, props);
457
+ }
458
+
366
459
  /**
367
460
  * Builds the physical DynamoDB table name for a logical id under the
368
461
  * given environment. Capitalises the environment's first letter so it