serverless-ircd 0.3.0 → 0.5.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 (223) 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 +349 -18
  8. package/README.md +132 -30
  9. package/apps/aws-stack/README.md +6 -5
  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 +150 -10
  13. package/apps/aws-stack/tests/stack.test.ts +145 -4
  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 +149 -24
  38. package/apps/local-cli/tests/e2e.test.ts +1 -1
  39. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  40. package/package.json +14 -10
  41. package/packages/aws-adapter/package.json +3 -3
  42. package/packages/aws-adapter/src/account-store.ts +1 -1
  43. package/packages/aws-adapter/src/admission.ts +74 -0
  44. package/packages/aws-adapter/src/aws-runtime.ts +75 -4
  45. package/packages/aws-adapter/src/config-loader.ts +32 -0
  46. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  47. package/packages/aws-adapter/src/handlers/connect.ts +96 -9
  48. package/packages/aws-adapter/src/handlers/default.ts +98 -7
  49. package/packages/aws-adapter/src/handlers/index.ts +109 -4
  50. package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
  51. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  52. package/packages/aws-adapter/src/index.ts +8 -0
  53. package/packages/aws-adapter/src/serialize.ts +11 -1
  54. package/packages/aws-adapter/src/stats.ts +80 -0
  55. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  56. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  57. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  58. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  59. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  60. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  61. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  62. package/packages/aws-adapter/tests/connect.test.ts +174 -0
  63. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  64. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  65. package/packages/aws-adapter/tests/handlers.test.ts +302 -53
  66. package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
  67. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  68. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +5 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +68 -5
  73. package/packages/cf-adapter/src/channel-do.ts +2 -2
  74. package/packages/cf-adapter/src/config-loader.ts +33 -0
  75. package/packages/cf-adapter/src/connection-do.ts +278 -85
  76. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  77. package/packages/cf-adapter/src/env.ts +54 -11
  78. package/packages/cf-adapter/src/index.ts +11 -8
  79. package/packages/cf-adapter/src/registry-do.ts +22 -3
  80. package/packages/cf-adapter/src/sharding.ts +1 -2
  81. package/packages/cf-adapter/src/stats.ts +65 -0
  82. package/packages/cf-adapter/tests/cf-harness.ts +12 -2
  83. package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
  84. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  85. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  86. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  87. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  88. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  89. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  90. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  91. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  92. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  93. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  94. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  95. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  96. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  97. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  98. package/packages/cf-adapter/tests/worker/main.ts +15 -8
  99. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  100. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  101. package/packages/cf-adapter/wrangler.test.toml +15 -0
  102. package/packages/in-memory-runtime/package.json +1 -1
  103. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  104. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  105. package/packages/irc-core/package.json +6 -1
  106. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  107. package/packages/irc-core/src/admission.ts +16 -15
  108. package/packages/irc-core/src/caps/capabilities.ts +24 -3
  109. package/packages/irc-core/src/cloak.ts +1 -1
  110. package/packages/irc-core/src/commands/cap.ts +8 -1
  111. package/packages/irc-core/src/commands/index.ts +17 -0
  112. package/packages/irc-core/src/commands/invite.ts +2 -4
  113. package/packages/irc-core/src/commands/ison.ts +61 -0
  114. package/packages/irc-core/src/commands/isupport.ts +6 -2
  115. package/packages/irc-core/src/commands/kick.ts +2 -4
  116. package/packages/irc-core/src/commands/kill.ts +127 -0
  117. package/packages/irc-core/src/commands/list.ts +1 -1
  118. package/packages/irc-core/src/commands/lusers.ts +204 -0
  119. package/packages/irc-core/src/commands/mode.ts +4 -8
  120. package/packages/irc-core/src/commands/names.ts +3 -5
  121. package/packages/irc-core/src/commands/part.ts +2 -4
  122. package/packages/irc-core/src/commands/quit.ts +12 -0
  123. package/packages/irc-core/src/commands/registration.ts +18 -12
  124. package/packages/irc-core/src/commands/rehash.ts +119 -0
  125. package/packages/irc-core/src/commands/sasl.ts +72 -9
  126. package/packages/irc-core/src/commands/server-info.ts +129 -0
  127. package/packages/irc-core/src/commands/setname.ts +109 -0
  128. package/packages/irc-core/src/commands/stats.ts +152 -0
  129. package/packages/irc-core/src/commands/topic.ts +2 -4
  130. package/packages/irc-core/src/commands/trace.ts +137 -0
  131. package/packages/irc-core/src/commands/userhost.ts +84 -0
  132. package/packages/irc-core/src/commands/wallops.ts +118 -0
  133. package/packages/irc-core/src/commands/whowas.ts +113 -0
  134. package/packages/irc-core/src/config.ts +65 -0
  135. package/packages/irc-core/src/credential-hashing.ts +124 -0
  136. package/packages/irc-core/src/effects.ts +33 -30
  137. package/packages/irc-core/src/index.ts +3 -0
  138. package/packages/irc-core/src/ports.ts +360 -12
  139. package/packages/irc-core/src/protocol/numerics.ts +48 -11
  140. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  141. package/packages/irc-core/src/types.ts +46 -2
  142. package/packages/irc-core/src/ws-framing.ts +132 -0
  143. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  144. package/packages/irc-core/tests/account-store.test.ts +45 -2
  145. package/packages/irc-core/tests/admission.test.ts +18 -0
  146. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  147. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  148. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  149. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  150. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  151. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  152. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  153. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  154. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  155. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  156. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  157. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  158. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  159. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  160. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  161. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  162. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  163. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  164. package/packages/irc-core/tests/config.test.ts +95 -1
  165. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  166. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  167. package/packages/irc-core/tests/effects.test.ts +14 -27
  168. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  169. package/packages/irc-core/tests/numerics.test.ts +102 -0
  170. package/packages/irc-core/tests/outbound.test.ts +51 -0
  171. package/packages/irc-core/tests/ports.test.ts +22 -0
  172. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  173. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  174. package/packages/irc-core/tests/types.test.ts +35 -1
  175. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  176. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  177. package/packages/irc-core/tsconfig.build.json +1 -1
  178. package/packages/irc-core/tsconfig.test.json +1 -1
  179. package/packages/irc-server/package.json +1 -1
  180. package/packages/irc-server/src/actor.ts +393 -16
  181. package/packages/irc-server/src/dispatch.ts +1 -3
  182. package/packages/irc-server/src/index.ts +10 -2
  183. package/packages/irc-server/src/routing.ts +15 -0
  184. package/packages/irc-server/src/runtime.ts +31 -0
  185. package/packages/irc-server/src/transport.ts +104 -0
  186. package/packages/irc-server/tests/actor.test.ts +1489 -4
  187. package/packages/irc-server/tests/dispatch.test.ts +37 -17
  188. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  189. package/packages/irc-server/tests/routing.test.ts +5 -0
  190. package/packages/irc-server/tests/runtime.test.ts +7 -0
  191. package/packages/irc-server/tests/transport.test.ts +230 -0
  192. package/packages/irc-test-support/package.json +1 -1
  193. package/packages/irc-test-support/src/harness.ts +44 -9
  194. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  195. package/packages/irc-test-support/src/index.ts +3 -0
  196. package/packages/irc-test-support/src/scenarios.ts +141 -3
  197. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  198. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  199. package/pnpm-workspace.yaml +10 -1
  200. package/tools/ci-hardening/package.json +1 -1
  201. package/tools/package.json +9 -0
  202. package/tools/seed-aws-accounts.ts +5 -6
  203. package/tools/seed-cf-accounts.ts +107 -0
  204. package/tools/tcp-ws-forwarder/package.json +1 -1
  205. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  206. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  207. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
  208. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  209. package/docs/ADR-002-location-of-authority.md +0 -82
  210. package/docs/ADR-003-durable-object-sharding.md +0 -93
  211. package/docs/ADR-004-dynamodb-schema.md +0 -96
  212. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  213. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  214. package/docs/ADR-007-deterministic-ports.md +0 -82
  215. package/docs/ADR-008-monorepo-tooling.md +0 -60
  216. package/docs/AWS-Adapter-Architecture.md +0 -496
  217. package/docs/AWS-Deployment.md +0 -1186
  218. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  219. package/docs/Home.md +0 -11
  220. package/docs/Observability.md +0 -87
  221. package/docs/PlanIRCv3Websocket.md +0 -489
  222. package/docs/PlanWebClient.md +0 -451
  223. package/docs/Release-Process.md +0 -443
package/README.md CHANGED
@@ -4,22 +4,26 @@ A serverless IRC daemon where the IRC protocol logic lives in a pure,
4
4
  platform-agnostic core, and two thin adapters run it on **Cloudflare Workers**
5
5
  (Durable Objects) or **AWS** (API Gateway WebSockets + Lambda + DynamoDB).
6
6
 
7
- One TypeScript codebase. Two serverless substrates. No S2S linking in v1.
7
+ One TypeScript codebase. Two serverless substrates.
8
8
 
9
- > **Status:** **v0.3.0 (preview).** The pure protocol core, the
9
+ > **Status:** **v0.5.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.2.0 delivered SASL (`PLAIN`), token-bucket
14
- > flood control, server-password enforcement, hostmask cloaking,
15
- > connection admission, and IRCv3 chat history; v0.3.0 adds **OPER**
16
- > credential auth, the IRCv3 **TAGMSG** command, **RFC 1459
17
- > case-mapping**, a real **MOTD** in the registration welcome, a
18
- > **ChannelRegistryDO** (4th Cloudflare Durable Object) plus a
19
- > Cloudflare config loader, and **SASL account persistence** in
20
- > DynamoDB (scrypt hashes) with a `seed-aws-accounts` provisioning
21
- > tool. Remaining 0.x work: SASL `EXTERNAL` (mTLS) and production TLS
22
- > transport. See `CHANGELOG.md` for the per-release manifests.
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
+ > clients Cloudflare via Spectrum + a Container origin, AWS via a
16
+ > Network Load Balancer + Lambda streaming alongside the WebSocket
17
+ > path, plus **SASL `EXTERNAL` via mTLS** and a **Cloudflare D1-backed
18
+ > SASL account store**. v0.5.0 completes 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 every standard verb a modern client expects is
23
+ > now implemented. Remaining 0.x work: the load/compat sweep
24
+ > (10k-connection load test + client compatibility matrix) and a
25
+ > required-`serverName` config gate. See `CHANGELOG.md` for the
26
+ > per-release manifests.
23
27
 
24
28
  ---
25
29
 
@@ -70,6 +74,16 @@ Hexagonal / ports-and-adapters. The core reasons about IRC; adapters move bytes.
70
74
  `packages/in-memory-runtime` is the reference implementation of
71
75
  `IrcRuntime`, used by integration tests and the local CLI server.
72
76
 
77
+ **Two transports, one core.** Both adapters accept WebSocket text frames
78
+ (the serverless default) **and** a raw `irc+tls :6697` (TLS-over-TCP)
79
+ surface for stock IRC clients. The TCP+TLS edge is platform-specific —
80
+ Cloudflare **Spectrum** terminates TLS and forwards plaintext TCP to a
81
+ stateful **Container** origin (`apps/cf-tcp-container`); AWS terminates
82
+ TLS at a **Network Load Balancer** and invokes a **Lambda streaming**
83
+ function. Both feed the same `ConnectionActor` through a `Transport`
84
+ seam (`WsTextFrameTransport` vs. `TcpByteStreamTransport`); the
85
+ parser/reducer/dispatch pipeline is identical.
86
+
73
87
  ### The key idea: pure reducers + location-of-authority
74
88
 
75
89
  Each command handler is a pure function:
@@ -111,12 +125,14 @@ ServerlessIRCd/
111
125
  │ └── aws-adapter/ Lambda + DynamoDB + AwsRuntime
112
126
  ├── apps/
113
127
  │ ├── cf-worker/ worker entry, DO migrations, bindings
114
- │ ├── aws-stack/ CDK stack (APIGW WS + Lambda + DynamoDB)
128
+ │ ├── cf-tcp-container/ Spectrum + Container origin for irc+tls :6697
129
+ │ ├── aws-stack/ CDK stack (APIGW WS + NLB + Lambda streaming + DynamoDB)
115
130
  │ └── local-cli/ runnable WS + TCP server using in-memory-runtime
116
131
  ├── tools/
117
132
  │ ├── tcp-ws-forwarder/ local TCP↔ws/wss bridge for stock IRC clients
118
133
  │ ├── ci-hardening/ coverage-gate + mutation-config validators
119
- └── seed-aws-accounts.ts scrypt-hash SASL PLAIN accounts into DynamoDB
134
+ ├── seed-aws-accounts.ts scrypt-hash SASL PLAIN accounts into DynamoDB
135
+ │ └── seed-cf-accounts.ts scrypt-hash SASL PLAIN accounts into Cloudflare D1
120
136
  ├── pnpm-workspace.yaml turbo.json tsconfig.base.json
121
137
  └── README.md CHANGELOG.md
122
138
  ```
@@ -127,7 +143,7 @@ ServerlessIRCd/
127
143
 
128
144
  | Concern | Choice |
129
145
  |------------------|---------------------------------------------------|
130
- | Runtime | Node ≥ 20, ES2022+ |
146
+ | Runtime | Node ≥ 24, ES2022+ |
131
147
  | Package mgr | pnpm workspaces |
132
148
  | Build cache | turbo |
133
149
  | Language | TypeScript (strict, `exactOptionalPropertyTypes`) |
@@ -141,9 +157,12 @@ ServerlessIRCd/
141
157
 
142
158
  ## Getting started
143
159
 
144
- Requires Node ≥ 20 and pnpm 9.
160
+ Requires Node ≥ 24 and pnpm 11. Enable corepack so the `packageManager`
161
+ pin is resolved automatically:
145
162
 
146
163
  ```bash
164
+ corepack enable # lets the pinned pnpm@11 run on any Node ≥ 24
165
+
147
166
  pnpm install # install workspace deps
148
167
 
149
168
  pnpm build # build all packages (turbo)
@@ -283,6 +302,42 @@ against any line-oriented WebSocket endpoint.
283
302
 
284
303
  ---
285
304
 
305
+ ## WebSocket transport (IRCv3 subprotocols)
306
+
307
+ The WebSocket entry points (the local CLI, the Cloudflare Worker, and the
308
+ AWS API Gateway `$connect` route) implement the
309
+ [IRCv3 WebSocket support](https://ircv3.net/specs/websocket.html) subprotocol
310
+ negotiation. A client advertises support by offering one of two registered
311
+ subprotocols in its opening handshake's `Sec-WebSocket-Protocol` header:
312
+
313
+ | Subprotocol | Frames | Notes |
314
+ |--------------------|----------|--------------------------------------------------|
315
+ | `text.ircv3.net` | UTF-8 text | Lone surrogates → U+FFFD; binary frames rejected (close `1003`). |
316
+ | `binary.ircv3.net` | binary | UTF-8 encoded bytes; same framing as `text`. |
317
+
318
+ The server echoes back the first supported entry in client-preference order
319
+ (in the `101` response's `Sec-WebSocket-Protocol` header). Once negotiated,
320
+ the connection uses **spec framing**:
321
+
322
+ - **One IRC message per WebSocket message** — a frame is never split on an
323
+ embedded line break. A single optional trailing `CR-LF` (or bare `LF`) is
324
+ stripped; the remainder is exactly one IRC line.
325
+ - **No trailing `CR-LF` on the wire**, outbound or inbound.
326
+ - **510-byte message budget** — the 512-byte IRC line limit minus the omitted
327
+ `CR-LF`. A message exceeding 510 bytes is a protocol violation and the
328
+ connection is closed with RFC 6455 code `1009` (Message Too Big).
329
+
330
+ **Legacy fallback.** A client that offers no recognized subprotocol (or none
331
+ at all) is still accepted, in **legacy mode**: frames are split on `\r\n` so
332
+ older clients that concatenate several messages into one frame keep working,
333
+ and the parser's existing 512-byte line cap applies. Nothing breaks until a
334
+ client opts into a subprotocol. The in-tree
335
+ [`tcp-ws-forwarder`](#connecting-a-tcp-irc-client-to-a-deployed-stack-tcpws-forwarder)
336
+ offers `binary.ircv3.net` upstream, so a stock TCP IRC client bridged to a
337
+ deployed stack exercises the spec path end-to-end.
338
+
339
+ ---
340
+
286
341
  ## Testing strategy
287
342
 
288
343
  This project follows strict TDD (Red → Green → Refactor) — every reducer is
@@ -322,10 +377,35 @@ cleanly. Per-release manifests live in `CHANGELOG.md`.
322
377
  - **Phase 4** — AWS adapter (APIGW WS + Lambda + DynamoDB + CDK).
323
378
  ✅ landed in **v0.2.0** (staging auto-deploy via CI; prod deploy is manual).
324
379
  - **Phase 5** — Observability, security hardening, config, CI gates.
325
- ✅ landed (logger port, server-password, cloaking, admission limits,
326
- coverage + mutation gates, OPER credential auth, and the Cloudflare
327
- config loader all shipped across v0.2.0–v0.3.0).
328
- - **Phase 6** — Load testing, client compatibility sweep, ADRs.
380
+ ✅ landed across v0.2.0–v0.4.0 (logger port, server-password, cloaking,
381
+ admission limits, coverage + mutation gates, OPER credential auth, the
382
+ Cloudflare config loader, and AWS max-clients admission).
383
+ - **Phase 6** — TLS hardening & raw TCP transport (`irc+tls :6697`).
384
+ ✅ landed in **v0.4.0**: generalized `ConnectionActor` transport seam,
385
+ Cloudflare Spectrum + Container origin, AWS NLB + Lambda streaming,
386
+ mTLS → SASL `EXTERNAL`, transport-parametrized contract suite, ADR-009.
387
+ - **Phase 7** — Load testing (10k concurrent connections per platform)
388
+ and client compatibility sweep (WeeChat, HexChat, IRCCloud, TheLounge).
389
+ ⬳ pending; the dual-transport surface it needs
390
+ landed in v0.4.0.
391
+ - **Phase 8 — PLAN-FIXES remediation.** ✅ landed across v0.3.0–v0.4.0
392
+ (MessageStore wired into all three adapters, AccountStore end-to-end,
393
+ the DynamoDB `Accounts` table decided, CF enumeration RPCs, AWS QUIT
394
+ fanout, `OPER` / `TAGMSG` / `WHOWAS`, RFC 1459 case-mapping, real
395
+ MOTD, config-driven server identity, AWS max-clients admission,
396
+ CF D1 SASL store, and a sweep of dead-code / stale-comment cleanup).
397
+ - **Phase 9 — Runtime & tooling baseline.** ✅ landed in **v0.4.0**
398
+ (Node ≥ 24 / pnpm 11).
399
+ - **Phase 10 — IRCv3 WebSocket extension.** ✅ landed in **v0.4.0**:
400
+ `text.ircv3.net` / `binary.ircv3.net` subprotocol negotiation +
401
+ per-message framing across the CF Worker, AWS APIGW, local CLI, and
402
+ `tcp-ws-forwarder` (510-byte budget, `1009`/`1003` close codes,
403
+ legacy fallback).
404
+ - **Phase 11 — Deferred IRC verbs.** ✅ landed in **v0.5.0**: `KILL`,
405
+ `REHASH`, `LUSERS` + `STATS`, `TRACE`, `WALLOPS`, `SETNAME`, plus
406
+ the formal drop of the S2S (`CONNECT`/`SQUIT`/`LINKS`) and obsolete
407
+ RFC 2812 (`SERVICE`/`SUMMON`/`USERS`) verbs. The protocol surface is
408
+ feature-complete for modern clients.
329
409
 
330
410
  ---
331
411
 
@@ -333,25 +413,40 @@ cleanly. Per-release manifests live in `CHANGELOG.md`.
333
413
 
334
414
  **Core (RFC 1459/2812 subset):** registration (`NICK`/`USER`/`CAP`/`PASS`),
335
415
  `PING`/`PONG`, `QUIT`, `JOIN`, `PART`, `PRIVMSG`, `NOTICE`, `MODE` (user +
336
- channel), `TOPIC`, `KICK`, `INVITE`, `NAMES`, `LIST`, `WHO`, `WHOIS`, `MOTD`,
337
- `AWAY`, `OPER` (credential auth → `o` user mode).
416
+ channel), `TOPIC`, `KICK`, `INVITE`, `NAMES`, `LIST`, `WHO`, `WHOIS`,
417
+ `WHOWAS`, `MOTD`, `AWAY`, `OPER` (credential auth → `o` user mode), the
418
+ query verbs `VERSION`, `TIME`, `ADMIN`, `INFO`, `USERHOST`, `ISON`, the
419
+ network-statistics verbs `LUSERS` and `STATS` (`u` uptime + `l` link-info
420
+ letters; unknown letters get the charybdis-style empty body), the
421
+ routing-diagnostic verb `TRACE` (oper-gated per-connection detail), the
422
+ realname-change verb `SETNAME`, and the oper verbs `KILL` (force-
423
+ disconnect), `REHASH` (config reload via `IrcRuntime.reloadConfig()`),
424
+ `WALLOPS` (global broadcast to every `+w` user).
425
+
426
+ (Out of scope: the S2S verbs `CONNECT`/`SQUIT`/`LINKS` and the obsolete
427
+ RFC 2812 verbs `SERVICE`/`SUMMON`/`USERS` are **formally dropped** as of
428
+ v0.5.0 — they return `421 ERR_UNKNOWNCOMMAND`, and their reserved
429
+ numerics have been removed. S2S linking is a PLAN non-goal.)
338
430
 
339
431
  **Channel modes:** `o v b i k l t n m s p`.
340
432
  **User modes:** `i`, `o` (local only), `w`, `s`.
341
433
 
342
434
  **IRCv3 extensions (negotiated via `CAP`):** `message-tags` (incl. the
343
435
  `TAGMSG` command), `server-time`, `account-tag`, `echo-message`, `batch`,
344
- `sasl` (`PLAIN` implemented, `EXTERNAL` reserved pending mTLS),
436
+ `sasl` (`PLAIN` always; `EXTERNAL` via mTLS when a client-cert trust store
437
+ is bound),
345
438
  `multi-prefix`, `away-notify`, `chghost`, `invite-notify`, `extended-join`,
346
439
  `draft/chathistory`, `safelist`. Case-insensitive nick/channel comparison
347
440
  uses **RFC 1459** case-mapping (advertised via `005 CASEMAPPING=rfc1459`).
348
441
 
349
442
  **Transport:** deployed stacks (Cloudflare Workers, AWS API Gateway) speak
350
- WebSocket text frames only (one IRC message per frame, with tolerance for
351
- `\r\n`-joined frames). The local CLI additionally binds a plain RFC TCP
352
- listener for direct IRC-client access. Stock TCP IRC clients reach a deployed
353
- WebSocket endpoint through the local
354
- [`tcp-ws-forwarder`](#connecting-a-tcp-irc-client-to-a-deployed-stack-tcpws-forwarder).
443
+ WebSocket text frames by default (one IRC message per frame, with tolerance
444
+ for `\r\n`-joined frames), and optionally expose a raw `irc+tls :6697`
445
+ (TLS-over-TCP) surface Cloudflare via **Spectrum + Container**, AWS via a
446
+ **Network Load Balancer + Lambda streaming** function. The local CLI
447
+ additionally binds a plain RFC TCP listener for direct IRC-client access,
448
+ and the [`tcp-ws-forwarder`](#connecting-a-tcp-irc-client-to-a-deployed-stack-tcpws-forwarder)
449
+ bridges a stock TCP client to a deployed WebSocket endpoint.
355
450
 
356
451
  ---
357
452
 
@@ -359,8 +454,15 @@ WebSocket endpoint through the local
359
454
 
360
455
  - `CHANGELOG.md` — per-release manifests (Keep a Changelog format), including
361
456
  the v0.2.0 work (AWS adapter, SASL, flood control, security hardening, CI
362
- gates) and v0.3.0 (OPER, TAGMSG, RFC 1459 case-mapping, real MOTD,
363
- ChannelRegistryDO, SASL account persistence).
457
+ gates), v0.3.0 (OPER, TAGMSG, RFC 1459 case-mapping, real MOTD,
458
+ ChannelRegistryDO, SASL account persistence), v0.4.0 (dual transport
459
+ `irc+tls :6697`, SASL `EXTERNAL`/mTLS, WHOWAS + remaining verbs, D1 account
460
+ store, Node 24/pnpm 11), and v0.5.0 (deferred IRC verbs `KILL` / `REHASH`
461
+ / `LUSERS` + `STATS` / `TRACE` / `WALLOPS` / `SETNAME`, plus the formal
462
+ drop of the S2S and obsolete RFC 2812 verbs).
463
+ - `docs/Cloudflare-TCP-Deployment.md` and `docs/AWS-TCP-Deployment.md` —
464
+ end-to-end guides for the `:6697` TCP+TLS variants (Spectrum/Container on
465
+ CF, NLB + Lambda streaming on AWS), including mTLS trust-store setup.
364
466
  - `README.md` in each `packages/*` and `apps/*` — per-package notes (e.g.
365
467
  `packages/aws-adapter/README.md` for the DynamoDB-Local test setup,
366
468
  `apps/aws-stack/README.md` for CDK commands and localstack validation).
@@ -1,18 +1,18 @@
1
1
  # `@serverless-ircd/aws-stack`
2
2
 
3
3
  AWS CDK v2 stack that provisions the AWS substrate for ServerlessIRCd
4
- (PLAN §6.2): an API Gateway v2 WebSocket API, a single Node 20 Lambda wired
4
+ (PLAN §6.2): an API Gateway v2 WebSocket API, a single Node 24 Lambda wired
5
5
  to the `$connect` / `$disconnect` / `$default` routes, five DynamoDB tables
6
6
  (`Connections`, `ChannelMeta`, `ChannelMembers`, `Nicks`, `Accounts`), and the
7
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
 
15
- - Node.js 20+
15
+ - Node.js 24+
16
16
  - pnpm (workspace root installs this package)
17
17
  - An AWS account with credentials configured
18
18
  (`aws configure`, `AWS_PROFILE`, or `SSO`)
@@ -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
@@ -70,4 +70,5 @@ LOCALSTACK=1 pnpm test
70
70
  ## Handoff
71
71
 
72
72
  Lambda runtime logic, DynamoDB access patterns, and the `AwsRuntime`
73
- implementation are TICKET-040. The scheduler/sweeper is TICKET-041/042.
73
+ implementation land with the AWS runtime work. The scheduler/sweeper is
74
+ the EventBridge sweeper work.
@@ -38,11 +38,18 @@ const motdLines = Array.isArray(rawMotd)
38
38
  ? rawMotd.split('\n')
39
39
  : undefined;
40
40
 
41
+ // TCP+TLS (irc+tls :6697) is opt-in: only provisioned when this context
42
+ // var is supplied. When omitted, only the wss (API Gateway WebSocket)
43
+ // path deploys (the default documented in docs/AWS-Deployment.md). See
44
+ // docs/AWS-TCP-Deployment.md.
45
+ const tcpTlsDomainName = app.node.tryGetContext('tcpTlsDomainName') as string | undefined;
46
+
41
47
  const stackProps: IrcStackProps = {
42
48
  environmentName,
43
49
  ...(serverName !== undefined ? { serverName } : {}),
44
50
  ...(networkName !== undefined ? { networkName } : {}),
45
51
  ...(motdLines !== undefined ? { motdLines } : {}),
52
+ ...(tcpTlsDomainName !== undefined ? { tcpTlsDomainName } : {}),
46
53
  ...(account && region ? { env: { account, region } } : {}),
47
54
  };
48
55
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-stack",
3
- "version": "0.2.0",
3
+ "version": "0.5.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",
@@ -19,8 +19,8 @@
19
19
  "smoke:staging": "node scripts/smoke.mjs"
20
20
  },
21
21
  "devDependencies": {
22
- "aws-cdk": "^2.160.0",
23
- "aws-cdk-lib": "^2.160.0",
22
+ "aws-cdk": "^2.262.0",
23
+ "aws-cdk-lib": "^2.262.0",
24
24
  "constructs": "^10.4.0",
25
25
  "@types/node": "^26.1.1",
26
26
  "@types/ws": "^8.5.13",
@@ -36,6 +36,6 @@
36
36
  "@serverless-ircd/aws-adapter": "workspace:*"
37
37
  },
38
38
  "engines": {
39
- "node": ">=20"
39
+ "node": ">=24"
40
40
  }
41
41
  }
@@ -3,10 +3,10 @@
3
3
  *
4
4
  * • 5 DynamoDB tables sourced from `@serverless-ircd/aws-adapter/cdk-table-defs`
5
5
  * (Connections, ChannelMeta, ChannelMembers, Nicks, Accounts).
6
- * • ONE Node 20 `NodejsFunction` bundling the AWS Lambda handler
6
+ * • ONE Node 24 `NodejsFunction` bundling the AWS Lambda handler
7
7
  * (`$connect` / `$disconnect` / `$default` dispatched on
8
8
  * `event.requestContext.routeKey`).
9
- * • ONE Node 20 `NodejsFunction` for the gone-connection sweeper
9
+ * • ONE Node 24 `NodejsFunction` for the gone-connection sweeper
10
10
  * (`sweeperHandler`), invoked on a fixed EventBridge schedule.
11
11
  * • API Gateway v2 WebSocket API + auto-deploying `prod` stage.
12
12
  * • Least-privilege IAM: scoped DynamoDB data-plane grants per table +
@@ -17,9 +17,24 @@ 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';
27
+ import { Certificate } from 'aws-cdk-lib/aws-certificatemanager';
22
28
  import { Table } from 'aws-cdk-lib/aws-dynamodb';
29
+ import { Vpc } from 'aws-cdk-lib/aws-ec2';
30
+ import {
31
+ NetworkListener,
32
+ NetworkLoadBalancer,
33
+ NetworkTargetGroup,
34
+ Protocol,
35
+ TargetType,
36
+ } from 'aws-cdk-lib/aws-elasticloadbalancingv2';
37
+ import { LambdaTarget } from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets';
23
38
  import { Rule, Schedule } from 'aws-cdk-lib/aws-events';
24
39
  import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets';
25
40
  import { Runtime } from 'aws-cdk-lib/aws-lambda';
@@ -29,6 +44,9 @@ import type { Construct } from 'constructs';
29
44
  /** Stage name served by the auto-deploying WebSocket stage. */
30
45
  const STAGE_NAME = 'prod';
31
46
 
47
+ /** IRC over TLS port served by the NLB listener (RFC-compliant :6697). */
48
+ const IRC_TLS_PORT = 6697;
49
+
32
50
  /** Fixed rate for the gone-connection sweeper (matches the architecture doc). */
33
51
  const SWEEPER_SCHEDULE_RATE = Duration.minutes(5);
34
52
 
@@ -91,6 +109,21 @@ export interface IrcStackProps extends StackProps {
91
109
  * a `string[]` correctly. Defaults to a short two-line banner.
92
110
  */
93
111
  readonly motdLines?: string[];
112
+
113
+ /**
114
+ * Domain name for the TCP+TLS (IRC `:6697`) endpoint.
115
+ *
116
+ * When provided, the stack provisions:
117
+ * - An ACM certificate for this domain.
118
+ * - A Network Load Balancer with a `TLS` listener on `:6697`.
119
+ * - A Lambda streaming target (the `nlbStreamHandler` export).
120
+ *
121
+ * The NLB terminates TLS at the edge and forwards plaintext TCP
122
+ * invocations to the Lambda. Connection identity is derived from
123
+ * NLB flow metadata (source IP + port). When omitted, only the wss
124
+ * (WebSocket) transport is deployed.
125
+ */
126
+ readonly tcpTlsDomainName?: string;
94
127
  }
95
128
 
96
129
  export class IrcAwsStack extends Stack {
@@ -118,11 +151,11 @@ export class IrcAwsStack extends Stack {
118
151
  );
119
152
 
120
153
  const handler = new NodejsFunction(this, 'IrcHandler', {
121
- runtime: Runtime.NODEJS_20_X,
154
+ runtime: Runtime.NODEJS_24_X,
122
155
  entry: handlerEntry(),
123
156
  handler: 'handler',
124
157
  bundling: {
125
- // AWS SDK v3 is in the Lambda Node 20 runtime; exclude it so the
158
+ // AWS SDK v3 is in the Lambda Node 24 runtime; exclude it so the
126
159
  // bundle stays small. irc-core / irc-server are bundled because
127
160
  // they are first-party.
128
161
  externalModules: ['@aws-sdk/*'],
@@ -134,7 +167,7 @@ export class IrcAwsStack extends Stack {
134
167
  // Catches connections that vanished without APIGW emitting $disconnect;
135
168
  // see packages/aws-adapter/src/handlers/sweeper.ts.
136
169
  const sweeper = new NodejsFunction(this, 'IrcSweeper', {
137
- runtime: Runtime.NODEJS_20_X,
170
+ runtime: Runtime.NODEJS_24_X,
138
171
  entry: handlerEntry(),
139
172
  handler: 'sweeperHandler',
140
173
  bundling: {
@@ -148,7 +181,7 @@ export class IrcAwsStack extends Stack {
148
181
  // packages/aws-adapter/src/handlers/ping-checker.ts. The Cloudflare
149
182
  // adapter implements the equivalent flow inside `ConnectionDO.alarm()`.
150
183
  const pingChecker = new NodejsFunction(this, 'IrcPingChecker', {
151
- runtime: Runtime.NODEJS_20_X,
184
+ runtime: Runtime.NODEJS_24_X,
152
185
  entry: handlerEntry(),
153
186
  handler: 'pingCheckerHandler',
154
187
  bundling: {
@@ -163,9 +196,6 @@ export class IrcAwsStack extends Stack {
163
196
  }
164
197
 
165
198
  const webSocketApi = new WebSocketApi(this, 'IrcWsApi', {
166
- connectRouteOptions: {
167
- integration: new WebSocketLambdaIntegration('ConnectIntegration', handler),
168
- },
169
199
  disconnectRouteOptions: {
170
200
  integration: new WebSocketLambdaIntegration('DisconnectIntegration', handler),
171
201
  },
@@ -174,6 +204,32 @@ export class IrcAwsStack extends Stack {
174
204
  },
175
205
  });
176
206
 
207
+ // The `$connect` route is added explicitly (rather than via
208
+ // `connectRouteOptions`) so the negotiated IRCv3 WebSocket subprotocol can
209
+ // be echoed back to the client. API Gateway selects the WebSocket
210
+ // subprotocol from the `$connect` integration response, so the route is
211
+ // configured to return a response and an `IntegrationResponse` maps the
212
+ // `Sec-WebSocket-Protocol` header from the Lambda's reply onto the
213
+ // handshake. The Lambda reads the client's offer, selects the first
214
+ // supported entry, and returns it in its response header (see
215
+ // `handleConnect` / `dispatch`).
216
+ const connectRoute = webSocketApi.addRoute('$connect', {
217
+ integration: new WebSocketLambdaIntegration('ConnectIntegration', handler),
218
+ returnResponse: true,
219
+ });
220
+ const connectIntegration = connectRoute.node.findChild(
221
+ 'ConnectIntegration',
222
+ ) as WebSocketIntegration;
223
+ new CfnIntegrationResponse(this, 'ConnectSubprotocolResponse', {
224
+ apiId: webSocketApi.apiId,
225
+ integrationId: connectIntegration.integrationId,
226
+ integrationResponseKey: '$default',
227
+ responseParameters: {
228
+ 'method.response.header.Sec-WebSocket-Protocol':
229
+ 'integration.response.header.Sec-WebSocket-Protocol',
230
+ },
231
+ });
232
+
177
233
  // The stage name is intentionally NOT suffixed with the environment:
178
234
  // each environment is its own CloudFormation stack (and therefore its
179
235
  // own WebSocket API), so `prod` in every env resolves to a distinct
@@ -232,6 +288,90 @@ export class IrcAwsStack extends Stack {
232
288
  targets: [new LambdaFunction(pingChecker)],
233
289
  });
234
290
 
291
+ // -------------------------------------------------------------------------
292
+ // TCP+TLS adapter via Network Load Balancer.
293
+ //
294
+ // An NLB with a `TLS` listener terminates TLS at the edge and invokes a
295
+ // Lambda streaming function (`nlbStreamHandler`) for each chunk of client
296
+ // data. The Lambda response body is streamed back over the held-open TCP
297
+ // connection. Only provisioned when `tcpTlsDomainName` is supplied.
298
+ // -------------------------------------------------------------------------
299
+ if (props.tcpTlsDomainName !== undefined) {
300
+ const tcpTlsDomain = props.tcpTlsDomainName;
301
+
302
+ // ACM certificate for TLS termination at the NLB.
303
+ const cert = new Certificate(this, 'TlsCertificate', {
304
+ domainName: tcpTlsDomain,
305
+ });
306
+
307
+ // NLB requires a VPC. A dedicated two-AZ VPC keeps the NLB path
308
+ // isolated from the serverless (APIGW) path which has no VPC.
309
+ // Cast bypasses a CDK `exactOptionalPropertyTypes` friction between
310
+ // the concrete `Vpc` and the `IVpc` interface (optional vs required
311
+ // properties in the CDK jsii layer).
312
+ const vpc = new Vpc(this, 'NlbVpc', {
313
+ maxAzs: 2,
314
+ natGateways: 0,
315
+ }) as never;
316
+
317
+ // The NLB streaming Lambda — same entry, different handler export.
318
+ // Shares the bundled aws-adapter code (tree-shaken to the nlb-stream
319
+ // path by esbuild at synth time). Reuses the same DynamoDB tables,
320
+ // server identity, and MANAGEMENT_URL as the wss handler.
321
+ const nlbHandler = new NodejsFunction(this, 'IrcNlbHandler', {
322
+ runtime: Runtime.NODEJS_24_X,
323
+ entry: handlerEntry(),
324
+ handler: 'nlbStreamHandler',
325
+ bundling: {
326
+ externalModules: ['@aws-sdk/*'],
327
+ },
328
+ });
329
+
330
+ // Grant the NLB handler the same DynamoDB access as the wss handler.
331
+ for (const table of Object.values(tables)) {
332
+ table.grantReadWriteData(nlbHandler);
333
+ }
334
+
335
+ // Inject the same table names, server identity, and management URL.
336
+ for (const logicalId of Object.keys(TABLE_DEFS)) {
337
+ const envName = `${logicalId.toUpperCase()}_TABLE`;
338
+ nlbHandler.addEnvironment(envName, prefixedTableName(environmentName, logicalId));
339
+ }
340
+ nlbHandler.addEnvironment('SERVER_NAME', serverName);
341
+ nlbHandler.addEnvironment('NETWORK_NAME', networkName);
342
+ nlbHandler.addEnvironment('MOTD', motd);
343
+ nlbHandler.addEnvironment('MANAGEMENT_URL', stage.callbackUrl);
344
+
345
+ const nlb = new NetworkLoadBalancer(this, 'IrcNlb', {
346
+ vpc,
347
+ internetFacing: true,
348
+ });
349
+
350
+ const targetGroup = new NetworkTargetGroup(this, 'IrcNlbTargetGroup', {
351
+ port: IRC_TLS_PORT,
352
+ protocol: Protocol.TCP,
353
+ targetType: TargetType.LAMBDA,
354
+ targets: [new LambdaTarget(nlbHandler)],
355
+ });
356
+
357
+ new NetworkListener(this, 'IrcNlbTlsListener', {
358
+ // Cast bypasses a CDK `exactOptionalPropertyTypes` friction between
359
+ // the concrete `NetworkLoadBalancer` and the `INetworkLoadBalancer`
360
+ // interface (optional vs required `securityGroups`).
361
+ loadBalancer: nlb as never,
362
+ port: IRC_TLS_PORT,
363
+ protocol: Protocol.TLS,
364
+ certificates: [{ certificateArn: cert.certificateArn }],
365
+ defaultTargetGroups: [targetGroup],
366
+ });
367
+
368
+ new CfnOutput(this, 'TcpConnectUrl', {
369
+ description:
370
+ 'TCP+TLS connect endpoint (irc+tls :6697). Connect via the NLB DNS name on port 6697.',
371
+ value: `${nlb.loadBalancerDnsName}:6697`,
372
+ });
373
+ }
374
+
235
375
  new CfnOutput(this, 'ConnectUrl', {
236
376
  description: 'WebSocket connect endpoint clients dial (wss).',
237
377
  value: stage.url,