serverless-ircd 0.2.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 (221) 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 +258 -18
  8. package/README.md +72 -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/scripts/smoke.mjs +0 -0
  34. package/apps/cf-worker/src/worker.ts +8 -2
  35. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  36. package/apps/cf-worker/wrangler.test.toml +11 -1
  37. package/apps/cf-worker/wrangler.toml +69 -19
  38. package/apps/local-cli/package.json +1 -1
  39. package/apps/local-cli/src/config-loader.ts +11 -0
  40. package/apps/local-cli/src/main.ts +1 -1
  41. package/apps/local-cli/src/server.ts +46 -3
  42. package/apps/local-cli/tests/e2e.test.ts +52 -0
  43. package/package.json +19 -16
  44. package/packages/aws-adapter/package.json +3 -3
  45. package/packages/aws-adapter/src/account-store.ts +121 -0
  46. package/packages/aws-adapter/src/admission.ts +74 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +124 -34
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  49. package/packages/aws-adapter/src/config-loader.ts +62 -0
  50. package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  52. package/packages/aws-adapter/src/handlers/default.ts +43 -2
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  54. package/packages/aws-adapter/src/handlers/index.ts +120 -9
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  56. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  57. package/packages/aws-adapter/src/index.ts +13 -0
  58. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
  59. package/packages/aws-adapter/tests/account-store.test.ts +279 -0
  60. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  61. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  62. package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
  63. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
  65. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  66. package/packages/aws-adapter/tests/handlers.test.ts +194 -47
  67. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  68. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  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 +1 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +40 -22
  73. package/packages/cf-adapter/src/channel-do.ts +40 -1
  74. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  75. package/packages/cf-adapter/src/config-loader.ts +62 -0
  76. package/packages/cf-adapter/src/connection-do.ts +181 -31
  77. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  78. package/packages/cf-adapter/src/env.ts +58 -8
  79. package/packages/cf-adapter/src/index.ts +11 -9
  80. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  81. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  82. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  83. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  84. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  85. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  86. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  87. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  88. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  89. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +9 -1
  91. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  92. package/packages/cf-adapter/wrangler.test.toml +18 -1
  93. package/packages/in-memory-runtime/package.json +1 -1
  94. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  95. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  96. package/packages/irc-core/package.json +8 -2
  97. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  98. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  99. package/packages/irc-core/src/case-fold.ts +64 -0
  100. package/packages/irc-core/src/cloak.ts +1 -1
  101. package/packages/irc-core/src/commands/cap.ts +8 -1
  102. package/packages/irc-core/src/commands/index.ts +11 -0
  103. package/packages/irc-core/src/commands/invite.ts +6 -9
  104. package/packages/irc-core/src/commands/ison.ts +61 -0
  105. package/packages/irc-core/src/commands/isupport.ts +1 -1
  106. package/packages/irc-core/src/commands/join.ts +4 -3
  107. package/packages/irc-core/src/commands/kick.ts +4 -11
  108. package/packages/irc-core/src/commands/list.ts +5 -4
  109. package/packages/irc-core/src/commands/mode.ts +5 -9
  110. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  111. package/packages/irc-core/src/commands/motd.ts +6 -110
  112. package/packages/irc-core/src/commands/oper.ts +151 -0
  113. package/packages/irc-core/src/commands/quit.ts +12 -0
  114. package/packages/irc-core/src/commands/registration.ts +26 -19
  115. package/packages/irc-core/src/commands/sasl.ts +72 -9
  116. package/packages/irc-core/src/commands/server-info.ts +129 -0
  117. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  118. package/packages/irc-core/src/commands/userhost.ts +84 -0
  119. package/packages/irc-core/src/commands/whowas.ts +113 -0
  120. package/packages/irc-core/src/config.ts +84 -3
  121. package/packages/irc-core/src/credential-hashing.ts +124 -0
  122. package/packages/irc-core/src/effects.ts +6 -29
  123. package/packages/irc-core/src/index.ts +3 -0
  124. package/packages/irc-core/src/ports.ts +240 -7
  125. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  126. package/packages/irc-core/src/types.ts +60 -1
  127. package/packages/irc-core/tests/account-store.test.ts +131 -0
  128. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  129. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  130. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  131. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  132. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  133. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  134. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  135. package/packages/irc-core/tests/commands/registration.test.ts +256 -19
  136. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  137. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  138. package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
  139. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  140. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  141. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  142. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  143. package/packages/irc-core/tests/config.test.ts +170 -1
  144. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  145. package/packages/irc-core/tests/effects.test.ts +0 -27
  146. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  147. package/packages/irc-core/tests/numerics.test.ts +12 -0
  148. package/packages/irc-core/tests/types.test.ts +35 -1
  149. package/packages/irc-core/tsconfig.build.json +1 -1
  150. package/packages/irc-core/tsconfig.test.json +1 -1
  151. package/packages/irc-server/package.json +1 -1
  152. package/packages/irc-server/src/actor.ts +182 -14
  153. package/packages/irc-server/src/dispatch.ts +0 -3
  154. package/packages/irc-server/src/index.ts +10 -2
  155. package/packages/irc-server/src/routing.ts +21 -0
  156. package/packages/irc-server/src/transport.ts +101 -0
  157. package/packages/irc-server/tests/actor.test.ts +617 -1
  158. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  159. package/packages/irc-server/tests/routing.test.ts +7 -0
  160. package/packages/irc-server/tests/transport.test.ts +230 -0
  161. package/packages/irc-test-support/package.json +1 -1
  162. package/packages/irc-test-support/src/harness.ts +44 -9
  163. package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
  164. package/packages/irc-test-support/src/index.ts +3 -0
  165. package/packages/irc-test-support/src/scenarios.ts +132 -2
  166. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  167. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  168. package/pnpm-workspace.yaml +9 -0
  169. package/tools/ci-hardening/package.json +1 -1
  170. package/tools/package.json +4 -0
  171. package/tools/seed-aws-accounts.ts +79 -0
  172. package/tools/seed-cf-accounts.ts +104 -0
  173. package/tools/tcp-ws-forwarder/package.json +1 -1
  174. package/AGENTS.md +0 -5
  175. package/MOTD.txt +0 -3
  176. package/PLAN-FIXES.md +0 -358
  177. package/PLAN.md +0 -420
  178. package/dashboards/cloudwatch-irc.json +0 -139
  179. package/docs/AWS-Adapter-Architecture.md +0 -494
  180. package/docs/AWS-Deployment.md +0 -1107
  181. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  182. package/docs/Home.md +0 -1
  183. package/docs/Observability.md +0 -87
  184. package/docs/PlanIRCv3Websocket.md +0 -489
  185. package/docs/PlanWebClient.md +0 -451
  186. package/docs/Release-Process.md +0 -440
  187. package/progress.md +0 -107
  188. package/tickets.md +0 -2485
  189. package/webircgateway/LICENSE +0 -201
  190. package/webircgateway/Makefile +0 -44
  191. package/webircgateway/README.md +0 -134
  192. package/webircgateway/config.conf.example +0 -135
  193. package/webircgateway/go.mod +0 -16
  194. package/webircgateway/go.sum +0 -89
  195. package/webircgateway/main.go +0 -118
  196. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  197. package/webircgateway/pkg/identd/identd.go +0 -86
  198. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  199. package/webircgateway/pkg/irc/isupport.go +0 -56
  200. package/webircgateway/pkg/irc/message.go +0 -217
  201. package/webircgateway/pkg/irc/state.go +0 -79
  202. package/webircgateway/pkg/proxy/proxy.go +0 -129
  203. package/webircgateway/pkg/proxy/server.go +0 -237
  204. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  205. package/webircgateway/pkg/webircgateway/client.go +0 -741
  206. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  207. package/webircgateway/pkg/webircgateway/config.go +0 -385
  208. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  209. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  210. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  211. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  212. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  213. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  214. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  215. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  216. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  217. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  218. package/webircgateway/plugins/example/plugin.go +0 -11
  219. package/webircgateway/plugins/stats/plugin.go +0 -52
  220. package/webircgateway/staticcheck.conf +0 -1
  221. package/webircgateway/webircgateway.svg +0 -3
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Unit tests for the NLB TCP+TLS Lambda streaming handler.
3
+ *
4
+ * Exercises the NLB→Lambda byte framing end-to-end against real DynamoDB
5
+ * Local: connection identity derivation, transport buffer persistence
6
+ * across invocations, chunk-boundary reassembly, and the streaming-back
7
+ * response path. The `LocalPostToConnection` double routes cross-connection
8
+ * fanout in-process (no actual NLB or APIGW needed).
9
+ */
10
+
11
+ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
12
+ import {
13
+ DEFAULT_SERVER_CONFIG,
14
+ type ParsedServerConfig,
15
+ StaticMotdProvider,
16
+ } from '@serverless-ircd/irc-core';
17
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
18
+ import { AwsRuntime } from '../src/aws-runtime.js';
19
+ import type { PostToConnection } from '../src/aws-runtime.js';
20
+ import { TABLE_DEFS } from '../src/cdk-table-defs.js';
21
+ import { createDynamoDocumentClient } from '../src/dynamo.js';
22
+ import {
23
+ type NlbStreamEvent,
24
+ deriveNlbConnectionId,
25
+ handleNlbStream,
26
+ } from '../src/handlers/nlb-stream.js';
27
+ import { bindMessageStore } from '../src/message-store.js';
28
+ import type { TablesConfig } from '../src/tables.js';
29
+
30
+ const available = process.env.DDB_AVAILABLE === '1';
31
+ const endpoint = process.env.DYNAMO_ENDPOINT;
32
+
33
+ const SERVER_CONFIG: ParsedServerConfig = {
34
+ ...DEFAULT_SERVER_CONFIG,
35
+ serverName: 'irc.example.com',
36
+ networkName: 'ExampleNet',
37
+ motdLines: ['Welcome to the test IRC server.'],
38
+ };
39
+
40
+ const MOTD = new StaticMotdProvider(SERVER_CONFIG.motdLines);
41
+
42
+ interface Fixture {
43
+ tables: TablesConfig;
44
+ client: ReturnType<typeof createDynamoDocumentClient>;
45
+ mgmt: LocalPostToConnection;
46
+ cleanup: () => Promise<void>;
47
+ }
48
+
49
+ let fx: Fixture | null;
50
+
51
+ function requireFx(): Fixture {
52
+ if (!fx) throw new Error('test fixture not initialized');
53
+ return fx;
54
+ }
55
+
56
+ async function makeFixture(): Promise<Fixture> {
57
+ if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
58
+ const prefix = `n${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
59
+ const tables = Object.fromEntries(
60
+ Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
61
+ ) as TablesConfig;
62
+ const client = createDynamoDocumentClient({ endpoint });
63
+ for (const [logical, props] of Object.entries(TABLE_DEFS)) {
64
+ const pkName = props.partitionKey?.name;
65
+ const skName = props.sortKey?.name;
66
+ if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
67
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
68
+ { AttributeName: pkName, AttributeType: 'S' },
69
+ ];
70
+ const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
71
+ { AttributeName: pkName, KeyType: 'HASH' },
72
+ ];
73
+ if (skName !== undefined) {
74
+ attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
75
+ keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
76
+ }
77
+ await client.send(
78
+ new CreateTableCommand({
79
+ TableName: `${prefix}${logical}`,
80
+ AttributeDefinitions: attributeDefinitions,
81
+ KeySchema: keySchema,
82
+ BillingMode: 'PAY_PER_REQUEST',
83
+ }),
84
+ );
85
+ }
86
+ const mgmt = new LocalPostToConnection();
87
+ return {
88
+ tables,
89
+ client,
90
+ mgmt,
91
+ cleanup: async () => {
92
+ for (const logical of Object.keys(TABLE_DEFS)) {
93
+ try {
94
+ await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
95
+ } catch {
96
+ // Already gone.
97
+ }
98
+ }
99
+ },
100
+ };
101
+ }
102
+
103
+ /** Builds an NLB event from raw client bytes (base64-encodes the body). */
104
+ function nlbEvent(sourceIp: string, sourcePort: string, data: string): NlbStreamEvent {
105
+ return {
106
+ requestContext: { elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:::targetgroup/test' } },
107
+ version: '2.0',
108
+ body: Buffer.from(data, 'utf8').toString('base64'),
109
+ isBase64Encoded: true,
110
+ headers: {
111
+ 'x-forwarded-for': sourceIp,
112
+ 'x-forwarded-port': sourcePort,
113
+ },
114
+ };
115
+ }
116
+
117
+ describe.skipIf(!available)('deriveNlbConnectionId', () => {
118
+ it('derives a stable id from source IP + port', () => {
119
+ const id = deriveNlbConnectionId('10.0.0.1', '54321');
120
+ expect(id).toBe('nlb-10-0-0-1-54321');
121
+ });
122
+
123
+ it('produces different ids for different ports on the same IP', () => {
124
+ expect(deriveNlbConnectionId('10.0.0.1', '54321')).not.toBe(
125
+ deriveNlbConnectionId('10.0.0.1', '54322'),
126
+ );
127
+ });
128
+
129
+ it('handles IPv6 addresses (colons replaced with dashes)', () => {
130
+ const id = deriveNlbConnectionId('::1', '1234');
131
+ // Two colons → two dashes, then the digit.
132
+ expect(id).toBe('nlb---1-1234');
133
+ });
134
+ });
135
+
136
+ describe.skipIf(!available)('NLB stream handler — byte framing', () => {
137
+ beforeEach(async () => {
138
+ fx = await makeFixture();
139
+ });
140
+ afterEach(async () => {
141
+ if (fx) await fx.cleanup();
142
+ fx = null;
143
+ });
144
+
145
+ it('creates a Connections row on the first chunk (connection establishment)', async () => {
146
+ const f = requireFx();
147
+ const res = await handleNlbStream(nlbEvent('10.0.0.1', '50001', 'PING :tok\r\n'), {
148
+ dynamo: f.client,
149
+ tables: f.tables,
150
+ serverConfig: SERVER_CONFIG,
151
+ motd: MOTD,
152
+ messages: bindMessageStore(SERVER_CONFIG),
153
+ managementApi: f.mgmt,
154
+ });
155
+ expect(res.statusCode).toBe(200);
156
+
157
+ const probe = new AwsRuntime({
158
+ dynamo: f.client,
159
+ tables: f.tables,
160
+ connId: '__probe__',
161
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
162
+ managementApi: null,
163
+ });
164
+ const info = await probe.getConnectionInfo(deriveNlbConnectionId('10.0.0.1', '50001'));
165
+ expect(info?.id).toBe(deriveNlbConnectionId('10.0.0.1', '50001'));
166
+ expect(info?.registration).toBe('pre-registration');
167
+ });
168
+
169
+ it('returns a PONG in the response body for a single PING', async () => {
170
+ const f = requireFx();
171
+ // First chunk establishes the connection.
172
+ await handleNlbStream(nlbEvent('10.0.0.2', '50002', ''), {
173
+ dynamo: f.client,
174
+ tables: f.tables,
175
+ serverConfig: SERVER_CONFIG,
176
+ motd: MOTD,
177
+ messages: bindMessageStore(SERVER_CONFIG),
178
+ managementApi: f.mgmt,
179
+ });
180
+ const res = await handleNlbStream(nlbEvent('10.0.0.2', '50002', 'PING :tok\r\n'), {
181
+ dynamo: f.client,
182
+ tables: f.tables,
183
+ serverConfig: SERVER_CONFIG,
184
+ motd: MOTD,
185
+ messages: bindMessageStore(SERVER_CONFIG),
186
+ managementApi: f.mgmt,
187
+ });
188
+ expect(res.statusCode).toBe(200);
189
+ expect(res.isBase64Encoded).toBe(true);
190
+ const decoded = Buffer.from(res.body, 'base64').toString('utf8');
191
+ expect(decoded).toContain('PONG :tok');
192
+ });
193
+
194
+ it('completes a full registration sequence (NICK + USER) and returns the welcome block', async () => {
195
+ const f = requireFx();
196
+ const connId = deriveNlbConnectionId('10.0.0.3', '50003');
197
+ // First chunk establishes the connection.
198
+ await handleNlbStream(nlbEvent('10.0.0.3', '50003', ''), {
199
+ dynamo: f.client,
200
+ tables: f.tables,
201
+ serverConfig: SERVER_CONFIG,
202
+ motd: MOTD,
203
+ messages: bindMessageStore(SERVER_CONFIG),
204
+ managementApi: f.mgmt,
205
+ });
206
+ const res = await handleNlbStream(
207
+ nlbEvent('10.0.0.3', '50003', 'NICK alice\r\nUSER alice 0 * :Alice\r\n'),
208
+ {
209
+ dynamo: f.client,
210
+ tables: f.tables,
211
+ serverConfig: SERVER_CONFIG,
212
+ motd: MOTD,
213
+ messages: bindMessageStore(SERVER_CONFIG),
214
+ managementApi: f.mgmt,
215
+ },
216
+ );
217
+ expect(res.statusCode).toBe(200);
218
+ const decoded = Buffer.from(res.body, 'base64').toString('utf8');
219
+ expect(decoded).toContain('001');
220
+ expect(decoded).toContain('alice');
221
+
222
+ const probe = new AwsRuntime({
223
+ dynamo: f.client,
224
+ tables: f.tables,
225
+ connId: '__probe__',
226
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
227
+ managementApi: null,
228
+ });
229
+ const info = await probe.getConnectionInfo(connId);
230
+ expect(info?.nick).toBe('alice');
231
+ expect(info?.registration).toBe('registered');
232
+ });
233
+ });
234
+
235
+ describe.skipIf(!available)('NLB stream handler — chunk boundaries', () => {
236
+ beforeEach(async () => {
237
+ fx = await makeFixture();
238
+ });
239
+ afterEach(async () => {
240
+ if (fx) await fx.cleanup();
241
+ fx = null;
242
+ });
243
+
244
+ it('buffers a partial line across two invocations and completes it on the second', async () => {
245
+ const f = requireFx();
246
+ // Establish + register.
247
+ await handleNlbStream(nlbEvent('10.0.0.10', '50010', ''), {
248
+ dynamo: f.client,
249
+ tables: f.tables,
250
+ serverConfig: SERVER_CONFIG,
251
+ motd: MOTD,
252
+ messages: bindMessageStore(SERVER_CONFIG),
253
+ managementApi: f.mgmt,
254
+ });
255
+ await handleNlbStream(nlbEvent('10.0.0.10', '50010', 'NICK bob\r\nUSER bob 0 * :Bob\r\n'), {
256
+ dynamo: f.client,
257
+ tables: f.tables,
258
+ serverConfig: SERVER_CONFIG,
259
+ motd: MOTD,
260
+ messages: bindMessageStore(SERVER_CONFIG),
261
+ managementApi: f.mgmt,
262
+ });
263
+
264
+ // First chunk: partial PING with no terminator.
265
+ const res1 = await handleNlbStream(nlbEvent('10.0.0.10', '50010', 'PING :to'), {
266
+ dynamo: f.client,
267
+ tables: f.tables,
268
+ serverConfig: SERVER_CONFIG,
269
+ motd: MOTD,
270
+ messages: bindMessageStore(SERVER_CONFIG),
271
+ managementApi: f.mgmt,
272
+ });
273
+ expect(res1.statusCode).toBe(200);
274
+ // No PONG yet — the line is incomplete.
275
+ const decoded1 = Buffer.from(res1.body, 'base64').toString('utf8');
276
+ expect(decoded1).not.toContain('PONG');
277
+
278
+ // Second chunk: the rest of the line + terminator.
279
+ const res2 = await handleNlbStream(nlbEvent('10.0.0.10', '50010', 'k\r\n'), {
280
+ dynamo: f.client,
281
+ tables: f.tables,
282
+ serverConfig: SERVER_CONFIG,
283
+ motd: MOTD,
284
+ messages: bindMessageStore(SERVER_CONFIG),
285
+ managementApi: f.mgmt,
286
+ });
287
+ expect(res2.statusCode).toBe(200);
288
+ const decoded2 = Buffer.from(res2.body, 'base64').toString('utf8');
289
+ expect(decoded2).toContain('PONG :tok');
290
+ });
291
+
292
+ it('reassembles a line split across three chunks', async () => {
293
+ const f = requireFx();
294
+ await handleNlbStream(nlbEvent('10.0.0.11', '50011', ''), {
295
+ dynamo: f.client,
296
+ tables: f.tables,
297
+ serverConfig: SERVER_CONFIG,
298
+ motd: MOTD,
299
+ messages: bindMessageStore(SERVER_CONFIG),
300
+ managementApi: f.mgmt,
301
+ });
302
+ await handleNlbStream(
303
+ nlbEvent('10.0.0.11', '50011', 'NICK carol\r\nUSER carol 0 * :Carol\r\n'),
304
+ {
305
+ dynamo: f.client,
306
+ tables: f.tables,
307
+ serverConfig: SERVER_CONFIG,
308
+ motd: MOTD,
309
+ messages: bindMessageStore(SERVER_CONFIG),
310
+ managementApi: f.mgmt,
311
+ },
312
+ );
313
+
314
+ const commonOpts = {
315
+ dynamo: f.client,
316
+ tables: f.tables,
317
+ serverConfig: SERVER_CONFIG,
318
+ motd: MOTD,
319
+ messages: bindMessageStore(SERVER_CONFIG),
320
+ managementApi: f.mgmt,
321
+ };
322
+
323
+ const r1 = await handleNlbStream(nlbEvent('10.0.0.11', '50011', 'PI'), commonOpts);
324
+ const d1 = Buffer.from(r1.body, 'base64').toString('utf8');
325
+ expect(d1).not.toContain('PONG');
326
+
327
+ const r2 = await handleNlbStream(nlbEvent('10.0.0.11', '50011', 'NG :hello'), commonOpts);
328
+ const d2 = Buffer.from(r2.body, 'base64').toString('utf8');
329
+ expect(d2).not.toContain('PONG');
330
+
331
+ const r3 = await handleNlbStream(nlbEvent('10.0.0.11', '50011', '\r\n'), commonOpts);
332
+ const d3 = Buffer.from(r3.body, 'base64').toString('utf8');
333
+ expect(d3).toContain('PONG :hello');
334
+ });
335
+
336
+ it('persists the transport buffer in DynamoDB between invocations', async () => {
337
+ const f = requireFx();
338
+ await handleNlbStream(nlbEvent('10.0.0.12', '50012', ''), {
339
+ dynamo: f.client,
340
+ tables: f.tables,
341
+ serverConfig: SERVER_CONFIG,
342
+ motd: MOTD,
343
+ messages: bindMessageStore(SERVER_CONFIG),
344
+ managementApi: f.mgmt,
345
+ });
346
+ // Feed a partial line.
347
+ await handleNlbStream(nlbEvent('10.0.0.12', '50012', 'PING :partial'), {
348
+ dynamo: f.client,
349
+ tables: f.tables,
350
+ serverConfig: SERVER_CONFIG,
351
+ motd: MOTD,
352
+ messages: bindMessageStore(SERVER_CONFIG),
353
+ managementApi: f.mgmt,
354
+ });
355
+
356
+ // The Connections row must carry the buffered partial line.
357
+ const { GetCommand } = await import('@aws-sdk/lib-dynamodb');
358
+ const row = await f.client.send(
359
+ new GetCommand({
360
+ TableName: f.tables.Connections,
361
+ Key: { connectionId: deriveNlbConnectionId('10.0.0.12', '50012') },
362
+ }),
363
+ );
364
+ expect(row.Item?.transportBuffer).toBe('PING :partial');
365
+ });
366
+ });
367
+
368
+ describe.skipIf(!available)('NLB stream handler — streaming back', () => {
369
+ beforeEach(async () => {
370
+ fx = await makeFixture();
371
+ });
372
+ afterEach(async () => {
373
+ if (fx) await fx.cleanup();
374
+ fx = null;
375
+ });
376
+
377
+ it('returns base64-encoded response bytes for the NLB client', async () => {
378
+ const f = requireFx();
379
+ await handleNlbStream(nlbEvent('10.0.0.20', '50020', ''), {
380
+ dynamo: f.client,
381
+ tables: f.tables,
382
+ serverConfig: SERVER_CONFIG,
383
+ motd: MOTD,
384
+ messages: bindMessageStore(SERVER_CONFIG),
385
+ managementApi: f.mgmt,
386
+ });
387
+ const res = await handleNlbStream(nlbEvent('10.0.0.20', '50020', 'PING :xyz\r\n'), {
388
+ dynamo: f.client,
389
+ tables: f.tables,
390
+ serverConfig: SERVER_CONFIG,
391
+ motd: MOTD,
392
+ messages: bindMessageStore(SERVER_CONFIG),
393
+ managementApi: f.mgmt,
394
+ });
395
+ expect(res.statusCode).toBe(200);
396
+ expect(res.isBase64Encoded).toBe(true);
397
+ // The body must be valid base64 that decodes to the PONG line.
398
+ const decoded = Buffer.from(res.body, 'base64').toString('utf8');
399
+ expect(decoded).toMatch(/PONG :xyz\r?\n/);
400
+ });
401
+
402
+ it('fans out a PRIVMSG to a second NLB connection via the management API', async () => {
403
+ const f = requireFx();
404
+ // Connection A registers.
405
+ await handleNlbStream(nlbEvent('10.0.0.30', '50030', ''), {
406
+ dynamo: f.client,
407
+ tables: f.tables,
408
+ serverConfig: SERVER_CONFIG,
409
+ motd: MOTD,
410
+ messages: bindMessageStore(SERVER_CONFIG),
411
+ managementApi: f.mgmt,
412
+ });
413
+ await handleNlbStream(
414
+ nlbEvent('10.0.0.30', '50030', 'NICK alice\r\nUSER alice 0 * :Alice\r\nJOIN #room\r\n'),
415
+ {
416
+ dynamo: f.client,
417
+ tables: f.tables,
418
+ serverConfig: SERVER_CONFIG,
419
+ motd: MOTD,
420
+ messages: bindMessageStore(SERVER_CONFIG),
421
+ managementApi: f.mgmt,
422
+ },
423
+ );
424
+ // Connection B registers + joins the same channel.
425
+ const bSink: string[] = [];
426
+ const bConnId = deriveNlbConnectionId('10.0.0.31', '50031');
427
+ f.mgmt.register(bConnId, (data) => {
428
+ for (const line of data.split('\r\n')) {
429
+ if (line.length > 0) bSink.push(line);
430
+ }
431
+ });
432
+ await handleNlbStream(nlbEvent('10.0.0.31', '50031', ''), {
433
+ dynamo: f.client,
434
+ tables: f.tables,
435
+ serverConfig: SERVER_CONFIG,
436
+ motd: MOTD,
437
+ messages: bindMessageStore(SERVER_CONFIG),
438
+ managementApi: f.mgmt,
439
+ });
440
+ await handleNlbStream(
441
+ nlbEvent('10.0.0.31', '50031', 'NICK bob\r\nUSER bob 0 * :Bob\r\nJOIN #room\r\n'),
442
+ {
443
+ dynamo: f.client,
444
+ tables: f.tables,
445
+ serverConfig: SERVER_CONFIG,
446
+ motd: MOTD,
447
+ messages: bindMessageStore(SERVER_CONFIG),
448
+ managementApi: f.mgmt,
449
+ },
450
+ );
451
+ // Alice sends a PRIVMSG; Bob's sink should receive it via management API.
452
+ await handleNlbStream(nlbEvent('10.0.0.30', '50030', 'PRIVMSG #room :hello from alice\r\n'), {
453
+ dynamo: f.client,
454
+ tables: f.tables,
455
+ serverConfig: SERVER_CONFIG,
456
+ motd: MOTD,
457
+ messages: bindMessageStore(SERVER_CONFIG),
458
+ managementApi: f.mgmt,
459
+ });
460
+ expect(bSink.some((l) => l.includes('PRIVMSG #room :hello from alice'))).toBe(true);
461
+ });
462
+ });
463
+
464
+ /**
465
+ * Test double for `ApiGatewayManagementApi`. Routes `postToConnection`
466
+ * calls to a registered sink for `ConnectionId`.
467
+ */
468
+ class LocalPostToConnection implements PostToConnection {
469
+ private readonly sinks = new Map<string, (data: string) => void>();
470
+ register(connId: string, sink: (data: string) => void): void {
471
+ this.sinks.set(connId, sink);
472
+ }
473
+ async postToConnection(input: { ConnectionId: string; Data: string }): Promise<unknown> {
474
+ const sink = this.sinks.get(input.ConnectionId);
475
+ if (sink !== undefined) sink(input.Data);
476
+ return {};
477
+ }
478
+ }
@@ -52,6 +52,11 @@ interface Fixture {
52
52
 
53
53
  let fx: Fixture | null;
54
54
 
55
+ function requireFx(): Fixture {
56
+ if (!fx) throw new Error('test fixture not initialized');
57
+ return fx;
58
+ }
59
+
55
60
  async function makeFixture(): Promise<Fixture> {
56
61
  if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
57
62
  const prefix = `p${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
@@ -100,9 +105,9 @@ async function makeFixture(): Promise<Fixture> {
100
105
 
101
106
  function params(overrides: Partial<PingCheckParams> = {}): PingCheckParams {
102
107
  return {
103
- dynamo: fx!.client,
104
- tables: fx!.tables,
105
- managementApi: fx!.mgmt,
108
+ dynamo: requireFx().client,
109
+ tables: requireFx().tables,
110
+ managementApi: requireFx().mgmt,
106
111
  ...overrides,
107
112
  };
108
113
  }
@@ -120,18 +125,18 @@ async function seedConnection(opts: {
120
125
  if (opts.nick !== undefined) {
121
126
  state.nick = opts.nick as Nick;
122
127
  state.user = opts.nick;
123
- await fx!.client.send(
128
+ await requireFx().client.send(
124
129
  new PutCommand({
125
- TableName: fx!.tables.Nicks,
130
+ TableName: requireFx().tables.Nicks,
126
131
  Item: marshalNick(opts.nick as Nick, opts.connId, opts.idleSince),
127
132
  }),
128
133
  );
129
134
  }
130
135
  for (const chan of opts.channels ?? []) {
131
136
  state.joinedChannels.add(chan);
132
- await fx!.client.send(
137
+ await requireFx().client.send(
133
138
  new PutCommand({
134
- TableName: fx!.tables.ChannelMembers,
139
+ TableName: requireFx().tables.ChannelMembers,
135
140
  Item: marshalChannelMember(chan.toLowerCase(), {
136
141
  conn: opts.connId,
137
142
  nick: (opts.nick ?? 'x') as Nick,
@@ -141,26 +146,26 @@ async function seedConnection(opts: {
141
146
  }),
142
147
  );
143
148
  }
144
- await fx!.client.send(
149
+ await requireFx().client.send(
145
150
  new PutCommand({
146
- TableName: fx!.tables.Connections,
151
+ TableName: requireFx().tables.Connections,
147
152
  Item: marshalConnection(state, opts.idleSince),
148
153
  }),
149
154
  );
150
155
  }
151
156
 
152
157
  async function connectionExists(connId: string): Promise<boolean> {
153
- const res = await fx!.client.send(
154
- new GetCommand({ TableName: fx!.tables.Connections, Key: { connectionId: connId } }),
158
+ const res = await requireFx().client.send(
159
+ new GetCommand({ TableName: requireFx().tables.Connections, Key: { connectionId: connId } }),
155
160
  );
156
161
  return res.Item !== undefined;
157
162
  }
158
163
 
159
164
  async function membersOf(channelKey: string): Promise<{ connectionId: string }[]> {
160
165
  const { QueryCommand } = await import('@aws-sdk/lib-dynamodb');
161
- const res = await fx!.client.send(
166
+ const res = await requireFx().client.send(
162
167
  new QueryCommand({
163
- TableName: fx!.tables.ChannelMembers,
168
+ TableName: requireFx().tables.ChannelMembers,
164
169
  KeyConditionExpression: 'channelName = :k',
165
170
  ExpressionAttributeValues: { ':k': channelKey },
166
171
  }),
@@ -169,8 +174,8 @@ async function membersOf(channelKey: string): Promise<{ connectionId: string }[]
169
174
  }
170
175
 
171
176
  async function nickExists(nick: string): Promise<boolean> {
172
- const res = await fx!.client.send(
173
- new GetCommand({ TableName: fx!.tables.Nicks, Key: { nickLower: nick.toLowerCase() } }),
177
+ const res = await requireFx().client.send(
178
+ new GetCommand({ TableName: requireFx().tables.Nicks, Key: { nickLower: nick.toLowerCase() } }),
174
179
  );
175
180
  return res.Item !== undefined;
176
181
  }
@@ -200,10 +205,10 @@ describe.skipIf(!available)('idle / PING checker', () => {
200
205
  expect(result.scanned).toBe(1);
201
206
  expect(result.pinged).toBe(1);
202
207
  expect(result.disconnected).toBe(0);
203
- expect(fx!.mgmt.calls.size).toBe(1);
204
- expect(fx!.mgmt.calls.has('idle')).toBe(true);
208
+ expect(requireFx().mgmt.calls.size).toBe(1);
209
+ expect(requireFx().mgmt.calls.has('idle')).toBe(true);
205
210
  // PING line format: `PING :<token>` (token is opaque, server-chosen).
206
- const line = fx!.mgmt.calls.get('idle')!.join('\r\n');
211
+ const line = requireFx().mgmt.calls.get('idle')?.join('\r\n');
207
212
  expect(line).toMatch(/^PING :.+\r?\n?$/);
208
213
  });
209
214
 
@@ -221,7 +226,7 @@ describe.skipIf(!available)('idle / PING checker', () => {
221
226
  expect(result.disconnected).toBe(1);
222
227
  expect(await connectionExists('ghost')).toBe(false);
223
228
  // No PING is sent in the disconnect window — past it.
224
- expect(fx!.mgmt.calls.size).toBe(0);
229
+ expect(requireFx().mgmt.calls.size).toBe(0);
225
230
  });
226
231
 
227
232
  // -------------------------------------------------------------------------
@@ -239,7 +244,7 @@ describe.skipIf(!available)('idle / PING checker', () => {
239
244
  expect(result.scanned).toBe(1);
240
245
  expect(result.pinged).toBe(0);
241
246
  expect(result.disconnected).toBe(0);
242
- expect(fx!.mgmt.calls.size).toBe(0);
247
+ expect(requireFx().mgmt.calls.size).toBe(0);
243
248
  expect(await connectionExists('live')).toBe(true);
244
249
  });
245
250
 
@@ -329,7 +334,10 @@ describe.skipIf(!available)('idle / PING checker', () => {
329
334
  expect(await connectionExists('live')).toBe(true);
330
335
  expect(await connectionExists('pingMe')).toBe(true);
331
336
  expect(await connectionExists('reap')).toBe(false);
332
- expect([...fx!.mgmt.calls.keys()]).toEqual(['pingMe']);
337
+ expect(fx).toBeDefined();
338
+ if (fx) {
339
+ expect([...fx.mgmt.calls.keys()]).toEqual(['pingMe']);
340
+ }
333
341
  });
334
342
 
335
343
  it('uses Date.now() when `now` is omitted (real-clock default path)', async () => {
@@ -377,9 +385,9 @@ describe.skipIf(!available)('idle / PING checker', () => {
377
385
  });
378
386
 
379
387
  it('skips a row whose idleSince is not a number (defensive)', async () => {
380
- await fx!.client.send(
388
+ await requireFx().client.send(
381
389
  new PutCommand({
382
- TableName: fx!.tables.Connections,
390
+ TableName: requireFx().tables.Connections,
383
391
  Item: {
384
392
  connectionId: 'corrupt',
385
393
  registration: 'registered',
@@ -408,7 +416,7 @@ describe.skipIf(!available)('idle / PING checker', () => {
408
416
  // the connection as disconnected.
409
417
  const now = 100_000;
410
418
  await seedConnection({ connId: 'halfopen', idleSince: 0 }); // idleFor = 100s
411
- fx!.mgmt.failNextWithGone('halfopen');
419
+ requireFx().mgmt.failNextWithGone('halfopen');
412
420
 
413
421
  const result = await handlePingCheck(
414
422
  params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 }),
@@ -431,7 +439,7 @@ describe.skipIf(!available)('idle / PING checker', () => {
431
439
  it('rethrows non-Gone errors from the PING send (no silent swallow)', async () => {
432
440
  const now = 100_000;
433
441
  await seedConnection({ connId: 'idle', idleSince: 0 });
434
- fx!.mgmt.throwNext('idle', new Error('InternalServerError: boom'));
442
+ requireFx().mgmt.throwNext('idle', new Error('InternalServerError: boom'));
435
443
 
436
444
  await expect(
437
445
  handlePingCheck(params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 })),
@@ -548,7 +556,7 @@ class RecordingPostToConnection {
548
556
  throwNext(connId: string, err: Error): void {
549
557
  this.throwNextErr.set(connId, err);
550
558
  }
551
- async postToConnection(input: { ConnectionId: string; Data: string }): Promise<{}> {
559
+ async postToConnection(input: { ConnectionId: string; Data: string }): Promise<unknown> {
552
560
  const throwErr = this.throwNextErr.get(input.ConnectionId);
553
561
  if (throwErr !== undefined) {
554
562
  this.throwNextErr.delete(input.ConnectionId);
@@ -566,6 +574,3 @@ class RecordingPostToConnection {
566
574
  return {};
567
575
  }
568
576
  }
569
-
570
- // Keep the import live for type usage in `params`.
571
- export type { DynamoDBDocumentClient };