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
@@ -0,0 +1,490 @@
1
+ /**
2
+ * NLB TCP+TLS Lambda streaming handler.
3
+ *
4
+ * AWS Network Load Balancer with a `tls` listener terminates TLS at the edge
5
+ * and invokes a Lambda function as the target for each chunk of client data.
6
+ * The Lambda response body is streamed back to the client over the same TCP
7
+ * connection the NLB holds open.
8
+ *
9
+ * Connection identity: NLB does not hand out a stable connection id the way
10
+ * API Gateway does. The flow is identified by the 4-tuple
11
+ * (source-IP, source-port), exposed as `x-forwarded-for` /
12
+ * `x-forwarded-port` headers. {@link deriveNlbConnectionId} maps that tuple
13
+ * to the existing `connectionId` scheme (`nlb-<ip>-<port>`) so the same
14
+ * DynamoDB-backed state path serves both the wss and TCP+TLS transports.
15
+ *
16
+ * Transport buffer persistence: the `TcpByteStreamTransport` buffers
17
+ * partial lines (a chunk may split an IRC command at an arbitrary byte
18
+ * boundary). Because each chunk arrives in a fresh Lambda invocation, the
19
+ * buffered tail is snapshotted to a `transportBuffer` attribute on the
20
+ * Connections row and rehydrated on the next invocation — no bytes are lost
21
+ * across the compute boundary.
22
+ *
23
+ * Outbound bytes: self-send lines (PONG, welcome block, error numerics, …)
24
+ * are collected and returned as the Lambda response body (base64-encoded so
25
+ * binary-safe). Cross-connection fanout (PRIVMSG broadcast, QUIT fanout, …)
26
+ * flows through the bound `managementApi` exactly as the wss path does —
27
+ * when the deploy also exposes an APIGW management endpoint the two
28
+ * transports share one fanout backend; when `managementApi` is `null` the
29
+ * AwsRuntime silently no-ops cross-connection sends (documented platform
30
+ * limit, see `docs/AWS-Deployment.md`).
31
+ *
32
+ * NLB + Lambda streaming limits:
33
+ * - Idle timeout: the NLB idle timeout (10–350 s) governs how long a
34
+ * connection with no client data stays open. The sweeper + ping-checker
35
+ * keep the DynamoDB row fresh and eventually prune it via `idleSince` TTL.
36
+ * - No server-push: the Lambda can only respond to a client chunk; it
37
+ * cannot proactively push bytes. Real-time fanout piggybacks on the
38
+ * recipient's next chunk (response) or via the APIGW management API.
39
+ * - Max connection duration: NLB has no hard max for TCP, but the
40
+ * underlying flow may be rebalanced by the NLB at any time.
41
+ */
42
+
43
+ import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
44
+ import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
45
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
46
+ import {
47
+ type AccountStore,
48
+ type ChannelState,
49
+ type Clock,
50
+ type ConnectionState,
51
+ type IdFactory,
52
+ type MessageStore,
53
+ type MotdProvider,
54
+ type NickHistoryStore,
55
+ type ParsedServerConfig,
56
+ type ServerConfig,
57
+ SystemClock,
58
+ UuidIdFactory,
59
+ } from '@serverless-ircd/irc-core';
60
+ import {
61
+ type ActorChannelAccess,
62
+ ConnectionActor,
63
+ WsTextFrameTransport,
64
+ } from '@serverless-ircd/irc-server';
65
+ import { TcpByteStreamTransport } from '@serverless-ircd/irc-server';
66
+ import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
67
+ import type { MarshalledConnection } from '../serialize.js';
68
+ import { CONNECTION_VERSION } from '../serialize.js';
69
+ import type { TablesConfig } from '../tables.js';
70
+ import { createInitialConnectionState } from './state.js';
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Event / response shapes
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * NLB Lambda target event (version 2.0). The `body` carries the client's
78
+ * raw TCP bytes, base64-encoded. Connection-identifying metadata is in
79
+ * the `headers` (`x-forwarded-for`, `x-forwarded-port`).
80
+ */
81
+ export interface NlbStreamEvent {
82
+ requestContext: {
83
+ elb: {
84
+ targetGroupArn: string;
85
+ };
86
+ };
87
+ version: string;
88
+ body: string;
89
+ isBase64Encoded: boolean;
90
+ headers?: Record<string, string>;
91
+ }
92
+
93
+ /**
94
+ * Lambda response consumed by the NLB. The `body` (base64-encoded) is the
95
+ * raw bytes the NLB forwards to the client over the held-open TCP socket.
96
+ */
97
+ export interface NlbStreamResponse {
98
+ statusCode: number;
99
+ body: string;
100
+ isBase64Encoded: boolean;
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Connection identity
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Prefix distinguishing NLB-derived connection ids from APIGW ids in
109
+ * DynamoDB (so a client on both transports cannot collide).
110
+ */
111
+ const NLB_CONN_PREFIX = 'nlb-';
112
+
113
+ /**
114
+ * Derives a stable connection id from the NLB flow metadata (source IP +
115
+ * source port). Dots and colons in the IP are replaced with dashes so the
116
+ * id is safe for DynamoDB partition keys and logging.
117
+ *
118
+ * The NLB holds the TCP connection open for the lifetime of the flow, so
119
+ * the same `(ip, port)` pair maps to the same connection across every
120
+ * Lambda invocation for that flow.
121
+ */
122
+ export function deriveNlbConnectionId(sourceIp: string, sourcePort: string): string {
123
+ const safeIp = sourceIp.replace(/[.:]/g, '-');
124
+ return `${NLB_CONN_PREFIX}${safeIp}-${sourcePort}`;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Handler
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /** Parameters accepted by {@link handleNlbStream}. */
132
+ export interface NlbStreamParams {
133
+ dynamo: DynamoDBDocumentClient;
134
+ tables: TablesConfig;
135
+ serverConfig: ParsedServerConfig;
136
+ motd: MotdProvider;
137
+ /** Chat-history persistence source (same as the wss `$default` path). */
138
+ messages?: MessageStore;
139
+ /** SASL account verification source. */
140
+ accounts?: AccountStore;
141
+ /** mTLS identity source for SASL EXTERNAL. */
142
+ mtlsIdentity?: import('@serverless-ircd/irc-core').MtlsIdentityProvider;
143
+ /** Nick-history source for WHOWAS. */
144
+ history?: NickHistoryStore;
145
+ /**
146
+ * Backend for cross-connection fanout. `null` when the deploy is NLB-only
147
+ * (no APIGW management endpoint) — cross-connection sends are silent
148
+ * no-ops in that mode.
149
+ */
150
+ managementApi: ApiGatewayManagementApi | PostToConnection | null;
151
+ /** Injected for tests; defaults to {@link SystemClock}. */
152
+ clock?: Clock;
153
+ /** Injected for tests; defaults to {@link UuidIdFactory}. */
154
+ ids?: IdFactory;
155
+ /**
156
+ * Config-reload source for `REHASH` (same as the wss `$default` path).
157
+ * When omitted the runtime's `reloadConfig` rejects so the actor emits a
158
+ * graceful error-suffixed `382`.
159
+ */
160
+ configLoader?: () => Promise<ServerConfig>;
161
+ }
162
+
163
+ /**
164
+ * Processes one NLB chunk: decodes the client bytes, frames them through
165
+ * the persistent TCP transport buffer, runs complete lines through the
166
+ * actor, persists state + buffer, and returns the outbound bytes for the
167
+ * NLB to stream back to the client.
168
+ *
169
+ * On the first chunk for a flow (no Connections row), a fresh row is
170
+ * created before processing — there is no separate `$connect` event from
171
+ * the NLB.
172
+ */
173
+ export async function handleNlbStream(
174
+ event: NlbStreamEvent,
175
+ params: NlbStreamParams,
176
+ ): Promise<NlbStreamResponse> {
177
+ const clock = params.clock ?? SystemClock;
178
+ const ids = params.ids ?? new UuidIdFactory();
179
+ const now = clock.now();
180
+
181
+ // 1. Decode the client bytes.
182
+ const data = event.isBase64Encoded
183
+ ? Buffer.from(event.body, 'base64').toString('utf8')
184
+ : event.body;
185
+
186
+ // 2. Derive the connection id from NLB flow metadata.
187
+ const sourceIp = readHeader(event, 'x-forwarded-for') ?? 'unknown';
188
+ const sourcePort = readHeader(event, 'x-forwarded-port') ?? '0';
189
+ const connId = deriveNlbConnectionId(sourceIp, sourcePort);
190
+
191
+ // 3. Load the existing row (if any) and split the transport buffer.
192
+ const existing = await loadRow(params.dynamo, params.tables.Connections, connId);
193
+ let state: ConnectionState;
194
+ let transportBuffer: string;
195
+ if (existing === null) {
196
+ state = createInitialConnectionState(connId, now);
197
+ if (sourceIp !== 'unknown') state.host = sourceIp;
198
+ transportBuffer = '';
199
+ } else {
200
+ state = unmarshalState(existing);
201
+ transportBuffer = typeof existing.transportBuffer === 'string' ? existing.transportBuffer : '';
202
+ }
203
+
204
+ // 4. Frame the chunk through the persistent TCP transport.
205
+ const transport = new TcpByteStreamTransport();
206
+ transport.restore(transportBuffer);
207
+ const lines = transport.feed(data);
208
+ const newBuffer = transport.getBuffer();
209
+
210
+ // Mark activity.
211
+ state.lastSeen = now;
212
+
213
+ // 5. Run complete lines through the actor (if any).
214
+ const outbound: string[] = [];
215
+ const handlers: AwsRuntimeHandlers = {
216
+ send: (sentLines) => {
217
+ for (const l of sentLines) outbound.push(l.text);
218
+ },
219
+ disconnect: () => {
220
+ // The Lambda cannot force-close the NLB-held TCP socket from inside
221
+ // the invocation. The canonical teardown is the sweeper + idleSince
222
+ // TTL (same pattern as the APIGW `$default` path, which also cannot
223
+ // close its own socket — see docs/AWS-Deployment.md §14.1).
224
+ },
225
+ snapshot: () => state,
226
+ };
227
+
228
+ const runtime = new AwsRuntime({
229
+ dynamo: params.dynamo,
230
+ tables: params.tables,
231
+ connId,
232
+ handlers,
233
+ managementApi: params.managementApi,
234
+ clock,
235
+ ...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
236
+ });
237
+
238
+ const channelAccess = new NlbChannelAccess(runtime, now);
239
+
240
+ const actor = new ConnectionActor({
241
+ state,
242
+ runtime,
243
+ channels: channelAccess,
244
+ serverConfig: params.serverConfig,
245
+ configSource: 'Secrets Manager',
246
+ clock,
247
+ ids,
248
+ motd: params.motd,
249
+ // The actor receives already-framed complete lines; a stateless
250
+ // WsTextFrameTransport splits on \r\n (no-op for already-split lines).
251
+ transport: new WsTextFrameTransport(),
252
+ ...(params.messages !== undefined ? { messages: params.messages } : {}),
253
+ ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
254
+ ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
255
+ ...(params.history !== undefined ? { history: params.history } : {}),
256
+ });
257
+
258
+ if (lines.length > 0) {
259
+ try {
260
+ await actor.receiveTextFrame(lines.join('\r\n'));
261
+ } catch (err: unknown) {
262
+ console.error('[nlb-stream] actor.receiveTextFrame failed', err);
263
+ }
264
+ }
265
+
266
+ // 6. Persist state + transport buffer.
267
+ await persistStateAndBuffer(
268
+ params.dynamo,
269
+ params.tables.Connections,
270
+ connId,
271
+ state,
272
+ now,
273
+ newBuffer,
274
+ );
275
+
276
+ // 7. Return outbound bytes as the NLB response body.
277
+ const responseBody = outbound.length > 0 ? `${outbound.join('\r\n')}\r\n` : '';
278
+ return {
279
+ statusCode: 200,
280
+ body: Buffer.from(responseBody, 'utf8').toString('base64'),
281
+ isBase64Encoded: true,
282
+ };
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Internal — DynamoDB helpers
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /** Raw row shape (extends the marshalled connection with the transport buffer). */
290
+ type RawConnectionRow = MarshalledConnection & { transportBuffer?: string };
291
+
292
+ /** Reads a header case-insensitively (NLB lowercases header names). */
293
+ function readHeader(event: NlbStreamEvent, name: string): string | undefined {
294
+ const headers = event.headers;
295
+ if (headers === undefined) return undefined;
296
+ const lower = name.toLowerCase();
297
+ for (const [key, value] of Object.entries(headers)) {
298
+ if (key.toLowerCase() === lower) return value;
299
+ }
300
+ return undefined;
301
+ }
302
+
303
+ /** Loads a raw Connections row (includes transportBuffer if present). */
304
+ async function loadRow(
305
+ dynamo: DynamoDBDocumentClient,
306
+ tableName: string,
307
+ connId: string,
308
+ ): Promise<RawConnectionRow | null> {
309
+ const result = await dynamo.send(
310
+ new GetCommand({ TableName: tableName, Key: { connectionId: connId } }),
311
+ );
312
+ if (result.Item === undefined) return null;
313
+ return result.Item as unknown as RawConnectionRow;
314
+ }
315
+
316
+ /** Unmarshals a raw row into a ConnectionState (ignores transportBuffer). */
317
+ function unmarshalState(row: RawConnectionRow): ConnectionState {
318
+ // Strip transportBuffer before unmarshalling so it is not treated as a
319
+ // stray ConnectionState field.
320
+ const { transportBuffer: _drop, ...connFields } = row;
321
+ void _drop;
322
+ return unmarshalConnectionTyped(connFields);
323
+ }
324
+
325
+ /**
326
+ * Thin wrapper around `unmarshalConnection` that accepts the stripped row.
327
+ * Importing lazily keeps the module boundary clean.
328
+ */
329
+ import { unmarshalConnection } from '../serialize.js';
330
+ function unmarshalConnectionTyped(row: MarshalledConnection): ConnectionState {
331
+ return unmarshalConnection(row);
332
+ }
333
+
334
+ /**
335
+ * Persists the mutated state and the new transport buffer in a single
336
+ * UpdateCommand. The buffer is written as a plain string attribute — when
337
+ * empty it is REMOVE'd so stale buffers from a previous chunk do not
338
+ * linger after the line completes.
339
+ */
340
+ async function persistStateAndBuffer(
341
+ dynamo: DynamoDBDocumentClient,
342
+ tableName: string,
343
+ connId: string,
344
+ state: ConnectionState,
345
+ now: number,
346
+ transportBuffer: string,
347
+ ): Promise<void> {
348
+ // Build the standard persist expression (same fields as handleDefault's
349
+ // buildPersistUpdate, minus the joinedChannels which the membership
350
+ // transaction owns).
351
+ const values: Record<string, unknown> = {
352
+ ':reg': state.registration,
353
+ ':capN': state.capNegotiating,
354
+ ':caps': [...state.caps],
355
+ ':um': { ...state.userModes },
356
+ ':ls': state.lastSeen,
357
+ ':is': now,
358
+ ':v': CONNECTION_VERSION,
359
+ };
360
+ const setNames = [
361
+ 'registration = :reg',
362
+ 'capNegotiating = :capN',
363
+ 'caps = :caps',
364
+ 'userModes = :um',
365
+ 'lastSeen = :ls',
366
+ 'idleSince = :is',
367
+ 'version = :v',
368
+ ];
369
+ const removeNames: string[] = [];
370
+ const nameMap: Record<string, string> = {};
371
+
372
+ for (const field of OPTIONAL_CONN_FIELDS) {
373
+ const placeholder = `:${field}`;
374
+ const value = state[field] as string | undefined;
375
+ const nameKey = `#${field}`;
376
+ nameMap[nameKey] = field;
377
+ if (value !== undefined) {
378
+ values[placeholder] = value;
379
+ setNames.push(`${nameKey} = ${placeholder}`);
380
+ } else {
381
+ removeNames.push(nameKey);
382
+ }
383
+ }
384
+
385
+ // Transport buffer: SET when non-empty, REMOVE when empty.
386
+ nameMap['#tb'] = 'transportBuffer';
387
+ if (transportBuffer.length > 0) {
388
+ values[':tb'] = transportBuffer;
389
+ setNames.push('#tb = :tb');
390
+ } else {
391
+ removeNames.push('#tb');
392
+ }
393
+
394
+ const parts: string[] = [`SET ${setNames.join(', ')}`];
395
+ if (removeNames.length > 0) {
396
+ parts.push(`REMOVE ${removeNames.join(', ')}`);
397
+ }
398
+
399
+ await dynamo.send(
400
+ new UpdateCommand({
401
+ TableName: tableName,
402
+ Key: { connectionId: connId },
403
+ UpdateExpression: parts.join(' '),
404
+ ExpressionAttributeValues: values,
405
+ ExpressionAttributeNames: nameMap,
406
+ }),
407
+ );
408
+ }
409
+
410
+ /** Optional connection fields mirrored into the SET/REMOVE split. */
411
+ const OPTIONAL_CONN_FIELDS = [
412
+ 'nick',
413
+ 'user',
414
+ 'host',
415
+ 'realname',
416
+ 'passAttempt',
417
+ 'account',
418
+ 'away',
419
+ 'saslMech',
420
+ 'saslBuffer',
421
+ ] as const;
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // Channel access (mirrors LambdaChannelAccess from default.ts)
425
+ // ---------------------------------------------------------------------------
426
+
427
+ /**
428
+ * `ActorChannelAccess` backed by the per-invocation {@link AwsRuntime}.
429
+ * Identical to `LambdaChannelAccess` in `default.ts` — duplicated rather
430
+ * than shared so the NLB handler stays self-contained (no cross-handler
431
+ * import coupling).
432
+ */
433
+ class NlbChannelAccess implements ActorChannelAccess {
434
+ private readonly cache = new Map<string, ChannelState>();
435
+ constructor(
436
+ private readonly runtime: AwsRuntime,
437
+ private readonly now: number,
438
+ ) {}
439
+
440
+ getOrCreateChannel(name: string): ChannelState {
441
+ const key = name.toLowerCase();
442
+ const existing = this.cache.get(key);
443
+ if (existing !== undefined) return existing;
444
+ const created: ChannelState = {
445
+ name,
446
+ nameLower: key,
447
+ modes: {
448
+ inviteOnly: false,
449
+ topicLock: false,
450
+ noExternal: false,
451
+ moderated: false,
452
+ secret: false,
453
+ private: false,
454
+ },
455
+ banMasks: new Set<string>(),
456
+ members: new Map(),
457
+ pendingInvites: new Set<string>(),
458
+ createdAt: this.now,
459
+ };
460
+ this.cache.set(key, created);
461
+ return created;
462
+ }
463
+
464
+ async refreshChannel(name: string): Promise<void> {
465
+ const key = name.toLowerCase();
466
+ const snap = await this.runtime.getChannelSnapshot(name);
467
+ if (snap === null) return;
468
+ const existing = this.cache.get(key);
469
+ if (snap.members.size === 0) {
470
+ if (existing !== undefined) {
471
+ existing.modes = { ...snap.modes };
472
+ existing.banMasks = new Set(snap.banMasks);
473
+ existing.pendingInvites = new Set(snap.pendingInvites);
474
+ if (snap.topic !== undefined) existing.topic = snap.topic;
475
+ }
476
+ return;
477
+ }
478
+ const rebuilt: ChannelState = {
479
+ name,
480
+ nameLower: key,
481
+ modes: { ...snap.modes },
482
+ banMasks: new Set(snap.banMasks),
483
+ members: new Map(snap.members),
484
+ pendingInvites: new Set(snap.pendingInvites),
485
+ createdAt: snap.createdAt,
486
+ };
487
+ if (snap.topic !== undefined) rebuilt.topic = snap.topic;
488
+ this.cache.set(key, rebuilt);
489
+ }
490
+ }
@@ -187,7 +187,7 @@ export async function handlePingCheck(params: PingCheckParams): Promise<PingChec
187
187
  // Internals
188
188
  // ---------------------------------------------------------------------------
189
189
 
190
- /** Default token source — Node 20 Lambda runtime exposes `crypto.randomUUID` globally. */
190
+ /** Default token source — Node 24 Lambda runtime exposes `crypto.randomUUID` globally. */
191
191
  function defaultToken(): string {
192
192
  return randomUUID();
193
193
  }
@@ -23,6 +23,8 @@ export {
23
23
  loadDynamoAccountStore,
24
24
  } from './dynamo-account-store.js';
25
25
  export { bindAccountStore, putAccountCredential, resolveAccountStore } from './account-store.js';
26
+ export { AwsStats } from './stats.js';
27
+ export type { AwsStatsOptions } from './stats.js';
26
28
  export {
27
29
  loadServerConfigFromLambdaEnv,
28
30
  type LambdaConfigEnv,
@@ -51,6 +53,9 @@ export {
51
53
  handler,
52
54
  dispatch,
53
55
  buildDepsFromEnv,
56
+ nlbStreamHandler,
57
+ handleNlbStream,
58
+ deriveNlbConnectionId,
54
59
  pingCheckerHandler,
55
60
  sweeperHandler,
56
61
  handlePingCheck,
@@ -66,6 +71,9 @@ export type {
66
71
  DisconnectEvent,
67
72
  HandlerDeps,
68
73
  LambdaResponse,
74
+ NlbStreamEvent,
75
+ NlbStreamParams,
76
+ NlbStreamResponse,
69
77
  PingCheckParams,
70
78
  PingCheckResult,
71
79
  SweepParams,
@@ -30,6 +30,7 @@ import type {
30
30
  RegistrationState,
31
31
  RosterEntry,
32
32
  UserModes,
33
+ WsFrameMode,
33
34
  } from '@serverless-ircd/irc-core';
34
35
 
35
36
  // ---------------------------------------------------------------------------
@@ -76,6 +77,15 @@ export interface MarshalledConnection {
76
77
  away?: string;
77
78
  saslMech?: string;
78
79
  saslBuffer?: string;
80
+ /**
81
+ * Negotiated IRCv3 WebSocket frame mode persisted at `$connect` time.
82
+ * Absent (`undefined`) on legacy connections (no subprotocol agreed) so
83
+ * the DynamoDB column is null by default — mirroring the CF adapter's
84
+ * hibernation-tag default. Set once and never mutated: `$default` uses
85
+ * `UpdateCommand` for its partial writes, so this field survives every
86
+ * subsequent frame.
87
+ */
88
+ wsMode?: WsFrameMode;
79
89
  }
80
90
 
81
91
  /**
@@ -118,7 +128,7 @@ export function marshalConnection(state: ConnectionState, idleSince: number): Ma
118
128
  /**
119
129
  * Materialises a {@link ConnectionState} from a stored row. Rehydrates
120
130
  * `caps` and `joinedChannels` into Sets. Throws on unrecognised
121
- * `version` values (forward migrations live in a future ticket).
131
+ * `version` values (forward migrations are not yet implemented).
122
132
  */
123
133
  export function unmarshalConnection(row: MarshalledConnection): ConnectionState {
124
134
  if (row.version > CONNECTION_VERSION) {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * AWS-flavoured {@link ServerStats} backend.
3
+ *
4
+ * Aggregates the network-wide counts the `LUSERS` / `STATS` reducers need
5
+ * directly from DynamoDB: a single `Scan` over `Connections` (the source
6
+ * of truth for every live connection) and one over `ChannelMeta` (one row
7
+ * per channel). Classification (oper / invisible / unknown) is delegated
8
+ * to the shared {@link computeStatsSnapshot} helper in irc-core so the
9
+ * classification rules live in exactly one place.
10
+ *
11
+ * Constructed per `LUSERS` / `STATS` invocation alongside the
12
+ * {@link AwsRuntime}; cheap to build, no caching. `uptimeStartedAt` is
13
+ * supplied by the caller (typically the Lambda's cold-start timestamp,
14
+ * stashed in module scope at first import).
15
+ */
16
+
17
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
18
+ import { ScanCommand } from '@aws-sdk/lib-dynamodb';
19
+ import { type ServerStatsSnapshot, computeStatsSnapshot } from '@serverless-ircd/irc-core';
20
+ import type { MarshalledConnection } from './serialize.js';
21
+ import { unmarshalConnection } from './serialize.js';
22
+ import type { TablesConfig } from './tables.js';
23
+
24
+ export interface AwsStatsOptions {
25
+ /** DynamoDB DocumentClient (the same client used by {@link AwsRuntime}). */
26
+ readonly dynamo: DynamoDBDocumentClient;
27
+ /** Logical→physical table-name map. */
28
+ readonly tables: TablesConfig;
29
+ /** Epoch-ms the deployment counts uptime from (Lambda cold start). */
30
+ readonly uptimeStartedAt: number;
31
+ /**
32
+ * Optional high-water marks for `265 RPL_LOCALUSERS` / `266
33
+ * RPL_GLOBALUSERS`. When omitted the live count is reported as the max
34
+ * (so the wire numeric never claims a max below the current count).
35
+ */
36
+ readonly maxLocalConns?: number;
37
+ readonly maxGlobalConns?: number;
38
+ }
39
+
40
+ export class AwsStats {
41
+ private readonly dynamo: DynamoDBDocumentClient;
42
+ private readonly tables: TablesConfig;
43
+ private readonly uptimeStartedAt: number;
44
+ private readonly maxLocalConns: number | undefined;
45
+ private readonly maxGlobalConns: number | undefined;
46
+
47
+ constructor(opts: AwsStatsOptions) {
48
+ this.dynamo = opts.dynamo;
49
+ this.tables = opts.tables;
50
+ this.uptimeStartedAt = opts.uptimeStartedAt;
51
+ this.maxLocalConns = opts.maxLocalConns;
52
+ this.maxGlobalConns = opts.maxGlobalConns;
53
+ }
54
+
55
+ async getStats(): Promise<ServerStatsSnapshot> {
56
+ // Scan Connections in a single request. DynamoDB Local + production
57
+ // both paginate at 1MB; for the serverless-IRC scale (≤ tens of
58
+ // thousands of connections per deployment) a single page covers the
59
+ // realistic ceiling. A deployment that outgrows this should maintain
60
+ // rolling count records (updated on connect/disconnect) rather than
61
+ // Scan — a tracked follow-up.
62
+ const connResult = await this.dynamo.send(
63
+ new ScanCommand({ TableName: this.tables.Connections }),
64
+ );
65
+ const connItems = (connResult.Items ?? []) as unknown as MarshalledConnection[];
66
+ const connections = connItems.map(unmarshalConnection);
67
+
68
+ // Channel count: one row per channel in `ChannelMeta`, again in a
69
+ // single page (channel counts are small).
70
+ const chanResult = await this.dynamo.send(
71
+ new ScanCommand({ TableName: this.tables.ChannelMeta, Select: 'COUNT' }),
72
+ );
73
+ const channelCount = chanResult.Count ?? 0;
74
+
75
+ return computeStatsSnapshot(connections, channelCount, this.uptimeStartedAt, {
76
+ ...(this.maxLocalConns !== undefined ? { maxLocalConns: this.maxLocalConns } : {}),
77
+ ...(this.maxGlobalConns !== undefined ? { maxGlobalConns: this.maxGlobalConns } : {}),
78
+ });
79
+ }
80
+ }