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
@@ -49,6 +49,11 @@ interface Fixture {
49
49
 
50
50
  let fx: Fixture | null;
51
51
 
52
+ function requireFx(): Fixture {
53
+ if (!fx) throw new Error('test fixture not initialized');
54
+ return fx;
55
+ }
56
+
52
57
  async function makeFixture(): Promise<Fixture> {
53
58
  if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
54
59
  const prefix = `h${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
@@ -122,12 +127,12 @@ describe.skipIf(!available)('handlers', () => {
122
127
  it('returns 200 and persists a Connections row', async () => {
123
128
  const res = await dispatch(
124
129
  { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
125
- fx!.deps,
130
+ requireFx().deps,
126
131
  );
127
132
  expect(res.statusCode).toBe(200);
128
133
  const probe = new AwsRuntime({
129
- dynamo: fx!.client,
130
- tables: fx!.tables,
134
+ dynamo: requireFx().client,
135
+ tables: requireFx().tables,
131
136
  connId: '__probe__',
132
137
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
133
138
  managementApi: null,
@@ -139,11 +144,14 @@ describe.skipIf(!available)('handlers', () => {
139
144
 
140
145
  it('is idempotent on APIGW retry (same connectionId overwrites)', async () => {
141
146
  for (let i = 0; i < 2; i++) {
142
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
147
+ await dispatch(
148
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
149
+ requireFx().deps,
150
+ );
143
151
  }
144
152
  const probe = new AwsRuntime({
145
- dynamo: fx!.client,
146
- tables: fx!.tables,
153
+ dynamo: requireFx().client,
154
+ tables: requireFx().tables,
147
155
  connId: '__probe__',
148
156
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
149
157
  managementApi: null,
@@ -153,21 +161,84 @@ describe.skipIf(!available)('handlers', () => {
153
161
  });
154
162
  });
155
163
 
164
+ // -------------------------------------------------------------------------
165
+ // $connect admission gate (max-clients cap)
166
+ // -------------------------------------------------------------------------
167
+
168
+ describe('$connect admission gate', () => {
169
+ it('rejects the connection that exceeds the maxClients cap', async () => {
170
+ const capped: HandlerDeps = {
171
+ ...requireFx().deps,
172
+ serverConfig: { ...SERVER_CONFIG, maxClients: 2 },
173
+ };
174
+ const r1 = await dispatch(
175
+ { requestContext: { routeKey: '$connect', connectionId: 'a1' } },
176
+ capped,
177
+ );
178
+ const r2 = await dispatch(
179
+ { requestContext: { routeKey: '$connect', connectionId: 'a2' } },
180
+ capped,
181
+ );
182
+ const r3 = await dispatch(
183
+ { requestContext: { routeKey: '$connect', connectionId: 'a3' } },
184
+ capped,
185
+ );
186
+ expect(r1.statusCode).toBe(200);
187
+ expect(r2.statusCode).toBe(200);
188
+ expect(r3.statusCode).toBe(429);
189
+
190
+ const probe = new AwsRuntime({
191
+ dynamo: requireFx().client,
192
+ tables: requireFx().tables,
193
+ connId: '__probe__',
194
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
195
+ managementApi: null,
196
+ });
197
+ expect((await probe.getConnectionInfo('a1'))?.id).toBe('a1');
198
+ expect((await probe.getConnectionInfo('a2'))?.id).toBe('a2');
199
+ // The over-cap connection must NOT be recorded.
200
+ expect(await probe.getConnectionInfo('a3')).toBeNull();
201
+ });
202
+
203
+ it('admits a connection when the table is below the cap', async () => {
204
+ const capped: HandlerDeps = {
205
+ ...requireFx().deps,
206
+ serverConfig: { ...SERVER_CONFIG, maxClients: 5 },
207
+ };
208
+ const res = await dispatch(
209
+ { requestContext: { routeKey: '$connect', connectionId: 'b1' } },
210
+ capped,
211
+ );
212
+ expect(res.statusCode).toBe(200);
213
+ const probe = new AwsRuntime({
214
+ dynamo: requireFx().client,
215
+ tables: requireFx().tables,
216
+ connId: '__probe__',
217
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
218
+ managementApi: null,
219
+ });
220
+ expect((await probe.getConnectionInfo('b1'))?.id).toBe('b1');
221
+ });
222
+ });
223
+
156
224
  // -------------------------------------------------------------------------
157
225
  // $disconnect
158
226
  // -------------------------------------------------------------------------
159
227
 
160
228
  describe('$disconnect', () => {
161
229
  it('returns 200 and deletes the Connections row', async () => {
162
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
230
+ await dispatch(
231
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
232
+ requireFx().deps,
233
+ );
163
234
  const res = await dispatch(
164
235
  { requestContext: { routeKey: '$disconnect', connectionId: 'c1' } },
165
- fx!.deps,
236
+ requireFx().deps,
166
237
  );
167
238
  expect(res.statusCode).toBe(200);
168
239
  const probe = new AwsRuntime({
169
- dynamo: fx!.client,
170
- tables: fx!.tables,
240
+ dynamo: requireFx().client,
241
+ tables: requireFx().tables,
171
242
  connId: '__probe__',
172
243
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
173
244
  managementApi: null,
@@ -178,33 +249,39 @@ describe.skipIf(!available)('handlers', () => {
178
249
  it('returns 200 even when the connection was never seen', async () => {
179
250
  const res = await dispatch(
180
251
  { requestContext: { routeKey: '$disconnect', connectionId: 'never' } },
181
- fx!.deps,
252
+ requireFx().deps,
182
253
  );
183
254
  expect(res.statusCode).toBe(200);
184
255
  });
185
256
 
186
257
  it('releases the nick on disconnect', async () => {
187
258
  // Connect + register alice.
188
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
259
+ await dispatch(
260
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
261
+ requireFx().deps,
262
+ );
189
263
  await dispatch(
190
264
  {
191
265
  requestContext: { routeKey: '$default', connectionId: 'c1' },
192
266
  body: 'NICK alice\r\n',
193
267
  },
194
- fx!.deps,
268
+ requireFx().deps,
195
269
  );
196
270
  await dispatch(
197
271
  {
198
272
  requestContext: { routeKey: '$default', connectionId: 'c1' },
199
273
  body: 'USER alice 0 * :Alice\r\n',
200
274
  },
201
- fx!.deps,
275
+ requireFx().deps,
202
276
  );
203
277
  // Disconnect.
204
- await dispatch({ requestContext: { routeKey: '$disconnect', connectionId: 'c1' } }, fx!.deps);
278
+ await dispatch(
279
+ { requestContext: { routeKey: '$disconnect', connectionId: 'c1' } },
280
+ requireFx().deps,
281
+ );
205
282
  const probe = new AwsRuntime({
206
- dynamo: fx!.client,
207
- tables: fx!.tables,
283
+ dynamo: requireFx().client,
284
+ tables: requireFx().tables,
208
285
  connId: '__probe__',
209
286
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
210
287
  managementApi: null,
@@ -224,7 +301,7 @@ describe.skipIf(!available)('handlers', () => {
224
301
  requestContext: { routeKey: '$default', connectionId: 'ghost' },
225
302
  body: 'PING :tok\r\n',
226
303
  },
227
- fx!.deps,
304
+ requireFx().deps,
228
305
  );
229
306
  expect(res.statusCode).toBe(410);
230
307
  });
@@ -234,23 +311,26 @@ describe.skipIf(!available)('handlers', () => {
234
311
  const event = {
235
312
  requestContext: { routeKey: '$default', connectionId: 'c1' },
236
313
  } as never;
237
- const res = await dispatch(event, fx!.deps);
314
+ const res = await dispatch(event, requireFx().deps);
238
315
  expect(res.statusCode).toBe(400);
239
316
  });
240
317
 
241
318
  it('runs a NICK + USER frame and emits the welcome block', async () => {
242
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
243
- fx!.mgmt.register('c1', () => {});
319
+ await dispatch(
320
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
321
+ requireFx().deps,
322
+ );
323
+ requireFx().mgmt.register('c1', () => {});
244
324
  await dispatch(
245
325
  {
246
326
  requestContext: { routeKey: '$default', connectionId: 'c1' },
247
327
  body: 'NICK alice\r\nUSER alice 0 * :Alice\r\n',
248
328
  },
249
- fx!.deps,
329
+ requireFx().deps,
250
330
  );
251
331
  const probe = new AwsRuntime({
252
- dynamo: fx!.client,
253
- tables: fx!.tables,
332
+ dynamo: requireFx().client,
333
+ tables: requireFx().tables,
254
334
  connId: '__probe__',
255
335
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
256
336
  managementApi: null,
@@ -261,9 +341,12 @@ describe.skipIf(!available)('handlers', () => {
261
341
  });
262
342
 
263
343
  it('echoes outbound lines back to the caller via the management API', async () => {
264
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
344
+ await dispatch(
345
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
346
+ requireFx().deps,
347
+ );
265
348
  const sink: string[] = [];
266
- fx!.mgmt.register('c1', (data) => {
349
+ requireFx().mgmt.register('c1', (data) => {
267
350
  for (const line of data.split('\r\n')) {
268
351
  if (line.length > 0) sink.push(line);
269
352
  }
@@ -273,31 +356,34 @@ describe.skipIf(!available)('handlers', () => {
273
356
  requestContext: { routeKey: '$default', connectionId: 'c1' },
274
357
  body: 'PING :tok\r\n',
275
358
  },
276
- fx!.deps,
359
+ requireFx().deps,
277
360
  );
278
361
  expect(sink).toContain('PONG :tok');
279
362
  });
280
363
 
281
364
  it('persists joined channels across $default invocations', async () => {
282
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
283
- fx!.mgmt.register('c1', () => {});
365
+ await dispatch(
366
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
367
+ requireFx().deps,
368
+ );
369
+ requireFx().mgmt.register('c1', () => {});
284
370
  await dispatch(
285
371
  {
286
372
  requestContext: { routeKey: '$default', connectionId: 'c1' },
287
373
  body: 'NICK alice\r\nUSER alice 0 * :Alice\r\n',
288
374
  },
289
- fx!.deps,
375
+ requireFx().deps,
290
376
  );
291
377
  await dispatch(
292
378
  {
293
379
  requestContext: { routeKey: '$default', connectionId: 'c1' },
294
380
  body: 'JOIN #persist\r\n',
295
381
  },
296
- fx!.deps,
382
+ requireFx().deps,
297
383
  );
298
384
  const probe = new AwsRuntime({
299
- dynamo: fx!.client,
300
- tables: fx!.tables,
385
+ dynamo: requireFx().client,
386
+ tables: requireFx().tables,
301
387
  connId: '__probe__',
302
388
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
303
389
  managementApi: null,
@@ -308,26 +394,32 @@ describe.skipIf(!available)('handlers', () => {
308
394
 
309
395
  it('rebuilds an existing channel roster via refreshChannel when a second client JOINs', async () => {
310
396
  // First connection registers + JOINs.
311
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
312
- fx!.mgmt.register('c1', () => {});
397
+ await dispatch(
398
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
399
+ requireFx().deps,
400
+ );
401
+ requireFx().mgmt.register('c1', () => {});
313
402
  await dispatch(
314
403
  {
315
404
  requestContext: { routeKey: '$default', connectionId: 'c1' },
316
405
  body: 'NICK alice\r\nUSER alice 0 * :Alice\r\n',
317
406
  },
318
- fx!.deps,
407
+ requireFx().deps,
319
408
  );
320
409
  await dispatch(
321
410
  {
322
411
  requestContext: { routeKey: '$default', connectionId: 'c1' },
323
412
  body: 'JOIN #room\r\n',
324
413
  },
325
- fx!.deps,
414
+ requireFx().deps,
326
415
  );
327
416
  // Second connection registers + JOINs the SAME channel.
328
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c2' } }, fx!.deps);
417
+ await dispatch(
418
+ { requestContext: { routeKey: '$connect', connectionId: 'c2' } },
419
+ requireFx().deps,
420
+ );
329
421
  const c2sink: string[] = [];
330
- fx!.mgmt.register('c2', (data) => {
422
+ requireFx().mgmt.register('c2', (data) => {
331
423
  for (const line of data.split('\r\n')) {
332
424
  if (line.length > 0) c2sink.push(line);
333
425
  }
@@ -337,14 +429,14 @@ describe.skipIf(!available)('handlers', () => {
337
429
  requestContext: { routeKey: '$default', connectionId: 'c2' },
338
430
  body: 'NICK bob\r\nUSER bob 0 * :Bob\r\n',
339
431
  },
340
- fx!.deps,
432
+ requireFx().deps,
341
433
  );
342
434
  await dispatch(
343
435
  {
344
436
  requestContext: { routeKey: '$default', connectionId: 'c2' },
345
437
  body: 'JOIN #room\r\n',
346
438
  },
347
- fx!.deps,
439
+ requireFx().deps,
348
440
  );
349
441
  // The 353 RPL_NAMREPLY c2 receives must list BOTH alice and bob —
350
442
  // proving that the actor's `refreshChannel` populated the cache
@@ -356,9 +448,12 @@ describe.skipIf(!available)('handlers', () => {
356
448
  });
357
449
 
358
450
  it('records a PRIVMSG and replays it via CHATHISTORY LATEST in a BATCH', async () => {
359
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
451
+ await dispatch(
452
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
453
+ requireFx().deps,
454
+ );
360
455
  const sink: string[] = [];
361
- fx!.mgmt.register('c1', (data) => {
456
+ requireFx().mgmt.register('c1', (data) => {
362
457
  for (const line of data.split('\r\n')) {
363
458
  if (line.length > 0) sink.push(line);
364
459
  }
@@ -368,7 +463,7 @@ describe.skipIf(!available)('handlers', () => {
368
463
  requestContext: { routeKey: '$default', connectionId: 'c1' },
369
464
  body: 'CAP REQ :draft/chathistory server-time message-tags batch\r\nNICK aws-alice\r\nUSER aws-alice 0 * :Alice\r\nCAP END\r\nJOIN #aws-chathist\r\n',
370
465
  },
371
- fx!.deps,
466
+ requireFx().deps,
372
467
  );
373
468
  sink.length = 0;
374
469
  await dispatch(
@@ -376,7 +471,7 @@ describe.skipIf(!available)('handlers', () => {
376
471
  requestContext: { routeKey: '$default', connectionId: 'c1' },
377
472
  body: 'PRIVMSG #aws-chathist :hello from aws\r\nCHATHISTORY LATEST #aws-chathist * 10\r\n',
378
473
  },
379
- fx!.deps,
474
+ requireFx().deps,
380
475
  );
381
476
  const batchOpen = sink.find((l) => /^BATCH \+\S+ chathistory #aws-chathist/.test(l));
382
477
  expect(batchOpen).toBeDefined();
@@ -385,16 +480,19 @@ describe.skipIf(!available)('handlers', () => {
385
480
  });
386
481
 
387
482
  it('completes SASL PLAIN with 900/903 when an AccountStore is bound', async () => {
388
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
483
+ await dispatch(
484
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
485
+ requireFx().deps,
486
+ );
389
487
  const sink: string[] = [];
390
- fx!.mgmt.register('c1', (data) => {
488
+ requireFx().mgmt.register('c1', (data) => {
391
489
  for (const line of data.split('\r\n')) {
392
490
  if (line.length > 0) sink.push(line);
393
491
  }
394
492
  });
395
493
  // Swap in a deps with a seeded AccountStore for this dispatch.
396
494
  const depsWithAccounts: HandlerDeps = {
397
- ...fx!.deps,
495
+ ...requireFx().deps,
398
496
  accounts: new InMemoryAccountStore([{ username: 'aws-sasl', password: 's3cret' }]),
399
497
  };
400
498
  const payload = Buffer.from('\0aws-sasl\0s3cret', 'utf8').toString('base64');
@@ -411,9 +509,12 @@ describe.skipIf(!available)('handlers', () => {
411
509
  });
412
510
 
413
511
  it('emits 904 when no AccountStore is bound (regression guard)', async () => {
414
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c2' } }, fx!.deps);
512
+ await dispatch(
513
+ { requestContext: { routeKey: '$connect', connectionId: 'c2' } },
514
+ requireFx().deps,
515
+ );
415
516
  const sink: string[] = [];
416
- fx!.mgmt.register('c2', (data) => {
517
+ requireFx().mgmt.register('c2', (data) => {
417
518
  for (const line of data.split('\r\n')) {
418
519
  if (line.length > 0) sink.push(line);
419
520
  }
@@ -424,7 +525,7 @@ describe.skipIf(!available)('handlers', () => {
424
525
  requestContext: { routeKey: '$default', connectionId: 'c2' },
425
526
  body: `CAP REQ :sasl\r\nAUTHENTICATE PLAIN\r\nAUTHENTICATE ${payload}\r\n`,
426
527
  },
427
- fx!.deps,
528
+ requireFx().deps,
428
529
  );
429
530
  expect(sink.some((l) => l.includes(' 904 '))).toBe(true);
430
531
  });
@@ -446,7 +547,7 @@ describe.skipIf(!available)('handlers', () => {
446
547
  it('returns 200 (APIGW only forwards the three known routes)', async () => {
447
548
  const res = await dispatch(
448
549
  { requestContext: { routeKey: '$custom', connectionId: 'c1' } },
449
- fx!.deps,
550
+ requireFx().deps,
450
551
  );
451
552
  expect(res.statusCode).toBe(200);
452
553
  });
@@ -462,7 +563,7 @@ class LocalPostToConnection implements PostToConnection {
462
563
  register(connId: string, sink: (data: string) => void): void {
463
564
  this.sinks.set(connId, sink);
464
565
  }
465
- async postToConnection(input: { ConnectionId: string; Data: string }): Promise<{}> {
566
+ async postToConnection(input: { ConnectionId: string; Data: string }): Promise<unknown> {
466
567
  const sink = this.sinks.get(input.ConnectionId);
467
568
  if (sink !== undefined) sink(input.Data);
468
569
  return {};