serverless-ircd 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +174 -18
  8. package/README.md +63 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +20 -3
  38. package/package.json +14 -10
  39. package/packages/aws-adapter/package.json +3 -3
  40. package/packages/aws-adapter/src/account-store.ts +1 -1
  41. package/packages/aws-adapter/src/admission.ts +74 -0
  42. package/packages/aws-adapter/src/aws-runtime.ts +6 -4
  43. package/packages/aws-adapter/src/config-loader.ts +32 -0
  44. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  45. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  46. package/packages/aws-adapter/src/handlers/default.ts +35 -2
  47. package/packages/aws-adapter/src/handlers/index.ts +69 -3
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  49. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  50. package/packages/aws-adapter/src/index.ts +6 -0
  51. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  52. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  53. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  54. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  55. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  56. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  57. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  58. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  59. package/packages/aws-adapter/tests/handlers.test.ts +154 -53
  60. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  62. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  63. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  64. package/packages/cf-adapter/package.json +1 -1
  65. package/packages/cf-adapter/src/cf-runtime.ts +2 -4
  66. package/packages/cf-adapter/src/config-loader.ts +33 -0
  67. package/packages/cf-adapter/src/connection-do.ts +111 -46
  68. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  69. package/packages/cf-adapter/src/env.ts +33 -9
  70. package/packages/cf-adapter/src/index.ts +9 -8
  71. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  72. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  73. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  74. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  75. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  76. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  78. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  79. package/packages/cf-adapter/tests/worker/main.ts +8 -1
  80. package/packages/cf-adapter/wrangler.test.toml +8 -0
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +6 -1
  83. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  84. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  85. package/packages/irc-core/src/cloak.ts +1 -1
  86. package/packages/irc-core/src/commands/cap.ts +8 -1
  87. package/packages/irc-core/src/commands/index.ts +9 -0
  88. package/packages/irc-core/src/commands/ison.ts +61 -0
  89. package/packages/irc-core/src/commands/quit.ts +12 -0
  90. package/packages/irc-core/src/commands/registration.ts +18 -12
  91. package/packages/irc-core/src/commands/sasl.ts +72 -9
  92. package/packages/irc-core/src/commands/server-info.ts +129 -0
  93. package/packages/irc-core/src/commands/userhost.ts +84 -0
  94. package/packages/irc-core/src/commands/whowas.ts +113 -0
  95. package/packages/irc-core/src/config.ts +58 -0
  96. package/packages/irc-core/src/credential-hashing.ts +124 -0
  97. package/packages/irc-core/src/effects.ts +6 -29
  98. package/packages/irc-core/src/index.ts +1 -0
  99. package/packages/irc-core/src/ports.ts +181 -12
  100. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  101. package/packages/irc-core/src/types.ts +38 -1
  102. package/packages/irc-core/tests/account-store.test.ts +45 -2
  103. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  104. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  105. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  106. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  107. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  108. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  109. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  111. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  112. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  113. package/packages/irc-core/tests/config.test.ts +95 -1
  114. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  115. package/packages/irc-core/tests/effects.test.ts +0 -27
  116. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  117. package/packages/irc-core/tests/numerics.test.ts +12 -0
  118. package/packages/irc-core/tests/types.test.ts +35 -1
  119. package/packages/irc-core/tsconfig.build.json +1 -1
  120. package/packages/irc-core/tsconfig.test.json +1 -1
  121. package/packages/irc-server/package.json +1 -1
  122. package/packages/irc-server/src/actor.ts +158 -14
  123. package/packages/irc-server/src/dispatch.ts +0 -3
  124. package/packages/irc-server/src/index.ts +10 -2
  125. package/packages/irc-server/src/routing.ts +12 -0
  126. package/packages/irc-server/src/transport.ts +101 -0
  127. package/packages/irc-server/tests/actor.test.ts +400 -3
  128. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  129. package/packages/irc-server/tests/routing.test.ts +4 -0
  130. package/packages/irc-server/tests/transport.test.ts +230 -0
  131. package/packages/irc-test-support/package.json +1 -1
  132. package/packages/irc-test-support/src/harness.ts +44 -9
  133. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  134. package/packages/irc-test-support/src/index.ts +3 -0
  135. package/packages/irc-test-support/src/scenarios.ts +132 -2
  136. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  137. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  138. package/pnpm-workspace.yaml +9 -1
  139. package/tools/ci-hardening/package.json +1 -1
  140. package/tools/package.json +4 -0
  141. package/tools/seed-aws-accounts.ts +5 -6
  142. package/tools/seed-cf-accounts.ts +104 -0
  143. package/tools/tcp-ws-forwarder/package.json +1 -1
  144. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  145. package/docs/ADR-002-location-of-authority.md +0 -82
  146. package/docs/ADR-003-durable-object-sharding.md +0 -93
  147. package/docs/ADR-004-dynamodb-schema.md +0 -96
  148. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  149. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  150. package/docs/ADR-007-deterministic-ports.md +0 -82
  151. package/docs/ADR-008-monorepo-tooling.md +0 -60
  152. package/docs/AWS-Adapter-Architecture.md +0 -496
  153. package/docs/AWS-Deployment.md +0 -1186
  154. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  155. package/docs/Home.md +0 -11
  156. package/docs/Observability.md +0 -87
  157. package/docs/PlanIRCv3Websocket.md +0 -489
  158. package/docs/PlanWebClient.md +0 -451
  159. package/docs/Release-Process.md +0 -443
@@ -0,0 +1,481 @@
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
+ SystemClock,
57
+ UuidIdFactory,
58
+ } from '@serverless-ircd/irc-core';
59
+ import {
60
+ type ActorChannelAccess,
61
+ ConnectionActor,
62
+ WsTextFrameTransport,
63
+ } from '@serverless-ircd/irc-server';
64
+ import { TcpByteStreamTransport } from '@serverless-ircd/irc-server';
65
+ import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
66
+ import type { MarshalledConnection } from '../serialize.js';
67
+ import { CONNECTION_VERSION } from '../serialize.js';
68
+ import type { TablesConfig } from '../tables.js';
69
+ import { createInitialConnectionState } from './state.js';
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Event / response shapes
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * NLB Lambda target event (version 2.0). The `body` carries the client's
77
+ * raw TCP bytes, base64-encoded. Connection-identifying metadata is in
78
+ * the `headers` (`x-forwarded-for`, `x-forwarded-port`).
79
+ */
80
+ export interface NlbStreamEvent {
81
+ requestContext: {
82
+ elb: {
83
+ targetGroupArn: string;
84
+ };
85
+ };
86
+ version: string;
87
+ body: string;
88
+ isBase64Encoded: boolean;
89
+ headers?: Record<string, string>;
90
+ }
91
+
92
+ /**
93
+ * Lambda response consumed by the NLB. The `body` (base64-encoded) is the
94
+ * raw bytes the NLB forwards to the client over the held-open TCP socket.
95
+ */
96
+ export interface NlbStreamResponse {
97
+ statusCode: number;
98
+ body: string;
99
+ isBase64Encoded: boolean;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Connection identity
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Prefix distinguishing NLB-derived connection ids from APIGW ids in
108
+ * DynamoDB (so a client on both transports cannot collide).
109
+ */
110
+ const NLB_CONN_PREFIX = 'nlb-';
111
+
112
+ /**
113
+ * Derives a stable connection id from the NLB flow metadata (source IP +
114
+ * source port). Dots and colons in the IP are replaced with dashes so the
115
+ * id is safe for DynamoDB partition keys and logging.
116
+ *
117
+ * The NLB holds the TCP connection open for the lifetime of the flow, so
118
+ * the same `(ip, port)` pair maps to the same connection across every
119
+ * Lambda invocation for that flow.
120
+ */
121
+ export function deriveNlbConnectionId(sourceIp: string, sourcePort: string): string {
122
+ const safeIp = sourceIp.replace(/[.:]/g, '-');
123
+ return `${NLB_CONN_PREFIX}${safeIp}-${sourcePort}`;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Handler
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /** Parameters accepted by {@link handleNlbStream}. */
131
+ export interface NlbStreamParams {
132
+ dynamo: DynamoDBDocumentClient;
133
+ tables: TablesConfig;
134
+ serverConfig: ParsedServerConfig;
135
+ motd: MotdProvider;
136
+ /** Chat-history persistence source (same as the wss `$default` path). */
137
+ messages?: MessageStore;
138
+ /** SASL account verification source. */
139
+ accounts?: AccountStore;
140
+ /** mTLS identity source for SASL EXTERNAL. */
141
+ mtlsIdentity?: import('@serverless-ircd/irc-core').MtlsIdentityProvider;
142
+ /** Nick-history source for WHOWAS. */
143
+ history?: NickHistoryStore;
144
+ /**
145
+ * Backend for cross-connection fanout. `null` when the deploy is NLB-only
146
+ * (no APIGW management endpoint) — cross-connection sends are silent
147
+ * no-ops in that mode.
148
+ */
149
+ managementApi: ApiGatewayManagementApi | PostToConnection | null;
150
+ /** Injected for tests; defaults to {@link SystemClock}. */
151
+ clock?: Clock;
152
+ /** Injected for tests; defaults to {@link UuidIdFactory}. */
153
+ ids?: IdFactory;
154
+ }
155
+
156
+ /**
157
+ * Processes one NLB chunk: decodes the client bytes, frames them through
158
+ * the persistent TCP transport buffer, runs complete lines through the
159
+ * actor, persists state + buffer, and returns the outbound bytes for the
160
+ * NLB to stream back to the client.
161
+ *
162
+ * On the first chunk for a flow (no Connections row), a fresh row is
163
+ * created before processing — there is no separate `$connect` event from
164
+ * the NLB.
165
+ */
166
+ export async function handleNlbStream(
167
+ event: NlbStreamEvent,
168
+ params: NlbStreamParams,
169
+ ): Promise<NlbStreamResponse> {
170
+ const clock = params.clock ?? SystemClock;
171
+ const ids = params.ids ?? new UuidIdFactory();
172
+ const now = clock.now();
173
+
174
+ // 1. Decode the client bytes.
175
+ const data = event.isBase64Encoded
176
+ ? Buffer.from(event.body, 'base64').toString('utf8')
177
+ : event.body;
178
+
179
+ // 2. Derive the connection id from NLB flow metadata.
180
+ const sourceIp = readHeader(event, 'x-forwarded-for') ?? 'unknown';
181
+ const sourcePort = readHeader(event, 'x-forwarded-port') ?? '0';
182
+ const connId = deriveNlbConnectionId(sourceIp, sourcePort);
183
+
184
+ // 3. Load the existing row (if any) and split the transport buffer.
185
+ const existing = await loadRow(params.dynamo, params.tables.Connections, connId);
186
+ let state: ConnectionState;
187
+ let transportBuffer: string;
188
+ if (existing === null) {
189
+ state = createInitialConnectionState(connId, now);
190
+ if (sourceIp !== 'unknown') state.host = sourceIp;
191
+ transportBuffer = '';
192
+ } else {
193
+ state = unmarshalState(existing);
194
+ transportBuffer = typeof existing.transportBuffer === 'string' ? existing.transportBuffer : '';
195
+ }
196
+
197
+ // 4. Frame the chunk through the persistent TCP transport.
198
+ const transport = new TcpByteStreamTransport();
199
+ transport.restore(transportBuffer);
200
+ const lines = transport.feed(data);
201
+ const newBuffer = transport.getBuffer();
202
+
203
+ // Mark activity.
204
+ state.lastSeen = now;
205
+
206
+ // 5. Run complete lines through the actor (if any).
207
+ const outbound: string[] = [];
208
+ const handlers: AwsRuntimeHandlers = {
209
+ send: (sentLines) => {
210
+ for (const l of sentLines) outbound.push(l.text);
211
+ },
212
+ disconnect: () => {
213
+ // The Lambda cannot force-close the NLB-held TCP socket from inside
214
+ // the invocation. The canonical teardown is the sweeper + idleSince
215
+ // TTL (same pattern as the APIGW `$default` path, which also cannot
216
+ // close its own socket — see docs/AWS-Deployment.md §14.1).
217
+ },
218
+ snapshot: () => state,
219
+ };
220
+
221
+ const runtime = new AwsRuntime({
222
+ dynamo: params.dynamo,
223
+ tables: params.tables,
224
+ connId,
225
+ handlers,
226
+ managementApi: params.managementApi,
227
+ clock,
228
+ });
229
+
230
+ const channelAccess = new NlbChannelAccess(runtime, now);
231
+
232
+ const actor = new ConnectionActor({
233
+ state,
234
+ runtime,
235
+ channels: channelAccess,
236
+ serverConfig: params.serverConfig,
237
+ clock,
238
+ ids,
239
+ motd: params.motd,
240
+ // The actor receives already-framed complete lines; a stateless
241
+ // WsTextFrameTransport splits on \r\n (no-op for already-split lines).
242
+ transport: new WsTextFrameTransport(),
243
+ ...(params.messages !== undefined ? { messages: params.messages } : {}),
244
+ ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
245
+ ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
246
+ ...(params.history !== undefined ? { history: params.history } : {}),
247
+ });
248
+
249
+ if (lines.length > 0) {
250
+ try {
251
+ await actor.receiveTextFrame(lines.join('\r\n'));
252
+ } catch (err: unknown) {
253
+ console.error('[nlb-stream] actor.receiveTextFrame failed', err);
254
+ }
255
+ }
256
+
257
+ // 6. Persist state + transport buffer.
258
+ await persistStateAndBuffer(
259
+ params.dynamo,
260
+ params.tables.Connections,
261
+ connId,
262
+ state,
263
+ now,
264
+ newBuffer,
265
+ );
266
+
267
+ // 7. Return outbound bytes as the NLB response body.
268
+ const responseBody = outbound.length > 0 ? `${outbound.join('\r\n')}\r\n` : '';
269
+ return {
270
+ statusCode: 200,
271
+ body: Buffer.from(responseBody, 'utf8').toString('base64'),
272
+ isBase64Encoded: true,
273
+ };
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Internal — DynamoDB helpers
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /** Raw row shape (extends the marshalled connection with the transport buffer). */
281
+ type RawConnectionRow = MarshalledConnection & { transportBuffer?: string };
282
+
283
+ /** Reads a header case-insensitively (NLB lowercases header names). */
284
+ function readHeader(event: NlbStreamEvent, name: string): string | undefined {
285
+ const headers = event.headers;
286
+ if (headers === undefined) return undefined;
287
+ const lower = name.toLowerCase();
288
+ for (const [key, value] of Object.entries(headers)) {
289
+ if (key.toLowerCase() === lower) return value;
290
+ }
291
+ return undefined;
292
+ }
293
+
294
+ /** Loads a raw Connections row (includes transportBuffer if present). */
295
+ async function loadRow(
296
+ dynamo: DynamoDBDocumentClient,
297
+ tableName: string,
298
+ connId: string,
299
+ ): Promise<RawConnectionRow | null> {
300
+ const result = await dynamo.send(
301
+ new GetCommand({ TableName: tableName, Key: { connectionId: connId } }),
302
+ );
303
+ if (result.Item === undefined) return null;
304
+ return result.Item as unknown as RawConnectionRow;
305
+ }
306
+
307
+ /** Unmarshals a raw row into a ConnectionState (ignores transportBuffer). */
308
+ function unmarshalState(row: RawConnectionRow): ConnectionState {
309
+ // Strip transportBuffer before unmarshalling so it is not treated as a
310
+ // stray ConnectionState field.
311
+ const { transportBuffer: _drop, ...connFields } = row;
312
+ void _drop;
313
+ return unmarshalConnectionTyped(connFields);
314
+ }
315
+
316
+ /**
317
+ * Thin wrapper around `unmarshalConnection` that accepts the stripped row.
318
+ * Importing lazily keeps the module boundary clean.
319
+ */
320
+ import { unmarshalConnection } from '../serialize.js';
321
+ function unmarshalConnectionTyped(row: MarshalledConnection): ConnectionState {
322
+ return unmarshalConnection(row);
323
+ }
324
+
325
+ /**
326
+ * Persists the mutated state and the new transport buffer in a single
327
+ * UpdateCommand. The buffer is written as a plain string attribute — when
328
+ * empty it is REMOVE'd so stale buffers from a previous chunk do not
329
+ * linger after the line completes.
330
+ */
331
+ async function persistStateAndBuffer(
332
+ dynamo: DynamoDBDocumentClient,
333
+ tableName: string,
334
+ connId: string,
335
+ state: ConnectionState,
336
+ now: number,
337
+ transportBuffer: string,
338
+ ): Promise<void> {
339
+ // Build the standard persist expression (same fields as handleDefault's
340
+ // buildPersistUpdate, minus the joinedChannels which the membership
341
+ // transaction owns).
342
+ const values: Record<string, unknown> = {
343
+ ':reg': state.registration,
344
+ ':capN': state.capNegotiating,
345
+ ':caps': [...state.caps],
346
+ ':um': { ...state.userModes },
347
+ ':ls': state.lastSeen,
348
+ ':is': now,
349
+ ':v': CONNECTION_VERSION,
350
+ };
351
+ const setNames = [
352
+ 'registration = :reg',
353
+ 'capNegotiating = :capN',
354
+ 'caps = :caps',
355
+ 'userModes = :um',
356
+ 'lastSeen = :ls',
357
+ 'idleSince = :is',
358
+ 'version = :v',
359
+ ];
360
+ const removeNames: string[] = [];
361
+ const nameMap: Record<string, string> = {};
362
+
363
+ for (const field of OPTIONAL_CONN_FIELDS) {
364
+ const placeholder = `:${field}`;
365
+ const value = state[field] as string | undefined;
366
+ const nameKey = `#${field}`;
367
+ nameMap[nameKey] = field;
368
+ if (value !== undefined) {
369
+ values[placeholder] = value;
370
+ setNames.push(`${nameKey} = ${placeholder}`);
371
+ } else {
372
+ removeNames.push(nameKey);
373
+ }
374
+ }
375
+
376
+ // Transport buffer: SET when non-empty, REMOVE when empty.
377
+ nameMap['#tb'] = 'transportBuffer';
378
+ if (transportBuffer.length > 0) {
379
+ values[':tb'] = transportBuffer;
380
+ setNames.push('#tb = :tb');
381
+ } else {
382
+ removeNames.push('#tb');
383
+ }
384
+
385
+ const parts: string[] = [`SET ${setNames.join(', ')}`];
386
+ if (removeNames.length > 0) {
387
+ parts.push(`REMOVE ${removeNames.join(', ')}`);
388
+ }
389
+
390
+ await dynamo.send(
391
+ new UpdateCommand({
392
+ TableName: tableName,
393
+ Key: { connectionId: connId },
394
+ UpdateExpression: parts.join(' '),
395
+ ExpressionAttributeValues: values,
396
+ ExpressionAttributeNames: nameMap,
397
+ }),
398
+ );
399
+ }
400
+
401
+ /** Optional connection fields mirrored into the SET/REMOVE split. */
402
+ const OPTIONAL_CONN_FIELDS = [
403
+ 'nick',
404
+ 'user',
405
+ 'host',
406
+ 'realname',
407
+ 'passAttempt',
408
+ 'account',
409
+ 'away',
410
+ 'saslMech',
411
+ 'saslBuffer',
412
+ ] as const;
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // Channel access (mirrors LambdaChannelAccess from default.ts)
416
+ // ---------------------------------------------------------------------------
417
+
418
+ /**
419
+ * `ActorChannelAccess` backed by the per-invocation {@link AwsRuntime}.
420
+ * Identical to `LambdaChannelAccess` in `default.ts` — duplicated rather
421
+ * than shared so the NLB handler stays self-contained (no cross-handler
422
+ * import coupling).
423
+ */
424
+ class NlbChannelAccess implements ActorChannelAccess {
425
+ private readonly cache = new Map<string, ChannelState>();
426
+ constructor(
427
+ private readonly runtime: AwsRuntime,
428
+ private readonly now: number,
429
+ ) {}
430
+
431
+ getOrCreateChannel(name: string): ChannelState {
432
+ const key = name.toLowerCase();
433
+ const existing = this.cache.get(key);
434
+ if (existing !== undefined) return existing;
435
+ const created: ChannelState = {
436
+ name,
437
+ nameLower: key,
438
+ modes: {
439
+ inviteOnly: false,
440
+ topicLock: false,
441
+ noExternal: false,
442
+ moderated: false,
443
+ secret: false,
444
+ private: false,
445
+ },
446
+ banMasks: new Set<string>(),
447
+ members: new Map(),
448
+ pendingInvites: new Set<string>(),
449
+ createdAt: this.now,
450
+ };
451
+ this.cache.set(key, created);
452
+ return created;
453
+ }
454
+
455
+ async refreshChannel(name: string): Promise<void> {
456
+ const key = name.toLowerCase();
457
+ const snap = await this.runtime.getChannelSnapshot(name);
458
+ if (snap === null) return;
459
+ const existing = this.cache.get(key);
460
+ if (snap.members.size === 0) {
461
+ if (existing !== undefined) {
462
+ existing.modes = { ...snap.modes };
463
+ existing.banMasks = new Set(snap.banMasks);
464
+ existing.pendingInvites = new Set(snap.pendingInvites);
465
+ if (snap.topic !== undefined) existing.topic = snap.topic;
466
+ }
467
+ return;
468
+ }
469
+ const rebuilt: ChannelState = {
470
+ name,
471
+ nameLower: key,
472
+ modes: { ...snap.modes },
473
+ banMasks: new Set(snap.banMasks),
474
+ members: new Map(snap.members),
475
+ pendingInvites: new Set(snap.pendingInvites),
476
+ createdAt: snap.createdAt,
477
+ };
478
+ if (snap.topic !== undefined) rebuilt.topic = snap.topic;
479
+ this.cache.set(key, rebuilt);
480
+ }
481
+ }
@@ -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
  }
@@ -51,6 +51,9 @@ export {
51
51
  handler,
52
52
  dispatch,
53
53
  buildDepsFromEnv,
54
+ nlbStreamHandler,
55
+ handleNlbStream,
56
+ deriveNlbConnectionId,
54
57
  pingCheckerHandler,
55
58
  sweeperHandler,
56
59
  handlePingCheck,
@@ -66,6 +69,9 @@ export type {
66
69
  DisconnectEvent,
67
70
  HandlerDeps,
68
71
  LambdaResponse,
72
+ NlbStreamEvent,
73
+ NlbStreamParams,
74
+ NlbStreamResponse,
69
75
  PingCheckParams,
70
76
  PingCheckResult,
71
77
  SweepParams,
@@ -31,6 +31,11 @@ interface Fx {
31
31
 
32
32
  let fx: Fx | null = null;
33
33
 
34
+ function requireFx(): Fx {
35
+ if (!fx) throw new Error('test fixture not initialized');
36
+ return fx;
37
+ }
38
+
34
39
  async function makeFx(): Promise<Fx> {
35
40
  if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
36
41
  const tableName = `acct-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
@@ -69,55 +74,57 @@ describe.skipIf(!available)('Accounts table round-trip', () => {
69
74
  });
70
75
 
71
76
  it('putAccountCredential then loadDynamoAccountStore verifies the account', async () => {
72
- await putAccountCredential(fx!.client, fx!.tableName, 'alice', 's3cret');
73
- const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
77
+ await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 's3cret');
78
+ const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
74
79
  expect(store).toBeDefined();
75
- expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 's3cret' })).toEqual(
76
- {
77
- ok: true,
78
- account: 'alice',
79
- },
80
- );
80
+ expect(
81
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 's3cret' }),
82
+ ).toEqual({
83
+ ok: true,
84
+ account: 'alice',
85
+ });
81
86
  });
82
87
 
83
88
  it('loadDynamoAccountStore returns undefined when the table has no rows', async () => {
84
- const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
89
+ const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
85
90
  expect(store).toBeUndefined();
86
91
  });
87
92
 
88
93
  it('putAccountCredential writes multiple distinct accounts', async () => {
89
- await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'a1');
90
- await putAccountCredential(fx!.client, fx!.tableName, 'bob', 'b2');
91
- const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
94
+ await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'a1');
95
+ await putAccountCredential(requireFx().client, requireFx().tableName, 'bob', 'b2');
96
+ const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
92
97
  expect(store).toBeDefined();
93
- expect(store!.size).toBe(2);
94
- expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'a1' }).ok).toBe(
98
+ expect(store?.size).toBe(2);
99
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'a1' }).ok).toBe(
100
+ true,
101
+ );
102
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'b2' }).ok).toBe(
95
103
  true,
96
104
  );
97
- expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'b2' }).ok).toBe(true);
98
105
  });
99
106
 
100
107
  it('putAccountCredential overwrites an existing account (re-seed)', async () => {
101
- await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'old');
102
- await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'new');
103
- const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
108
+ await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'old');
109
+ await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'new');
110
+ const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
104
111
  expect(store).toBeDefined();
105
- expect(store!.size).toBe(1);
106
- expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'old' }).ok).toBe(
112
+ expect(store?.size).toBe(1);
113
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'old' }).ok).toBe(
107
114
  false,
108
115
  );
109
- expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'new' }).ok).toBe(
116
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'new' }).ok).toBe(
110
117
  true,
111
118
  );
112
119
  });
113
120
 
114
121
  it('rejects a wrong password after round-trip', async () => {
115
- await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'right');
116
- const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
122
+ await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'right');
123
+ const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
117
124
  expect(store).toBeDefined();
118
- expect(
119
- store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok,
120
- ).toBe(false);
125
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
126
+ false,
127
+ );
121
128
  });
122
129
  });
123
130
 
@@ -131,19 +138,19 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
131
138
  });
132
139
 
133
140
  it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
134
- await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'table-secret');
141
+ await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'table-secret');
135
142
  const cfgWithSasl = {
136
143
  ...DEFAULT_SERVER_CONFIG,
137
144
  saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
138
145
  };
139
- const store = await resolveAccountStore(fx!.client, fx!.tableName, cfgWithSasl);
146
+ const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
140
147
  expect(store).toBeDefined();
141
148
  // Table account verifies; config account does NOT (table is authoritative).
142
149
  expect(
143
- store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'table-secret' }).ok,
150
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'table-secret' }).ok,
144
151
  ).toBe(true);
145
152
  expect(
146
- store!.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
153
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
147
154
  ).toBe(false);
148
155
  });
149
156
 
@@ -152,20 +159,24 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
152
159
  ...DEFAULT_SERVER_CONFIG,
153
160
  saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
154
161
  };
155
- const store = await resolveAccountStore(fx!.client, fx!.tableName, cfgWithSasl);
162
+ const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
156
163
  expect(store).toBeDefined();
157
164
  expect(
158
- store!.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
165
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
159
166
  ).toBe(true);
160
167
  });
161
168
 
162
169
  it('returns undefined when the table is empty and no config accounts are set', async () => {
163
- const store = await resolveAccountStore(fx!.client, fx!.tableName, DEFAULT_SERVER_CONFIG);
170
+ const store = await resolveAccountStore(
171
+ requireFx().client,
172
+ requireFx().tableName,
173
+ DEFAULT_SERVER_CONFIG,
174
+ );
164
175
  expect(store).toBeUndefined();
165
176
  });
166
177
 
167
178
  it('returns undefined when the table is empty and serverConfig is undefined', async () => {
168
- const store = await resolveAccountStore(fx!.client, fx!.tableName, undefined);
179
+ const store = await resolveAccountStore(requireFx().client, requireFx().tableName, undefined);
169
180
  expect(store).toBeUndefined();
170
181
  });
171
182
  });