serverless-ircd 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +349 -18
  8. package/README.md +132 -30
  9. package/apps/aws-stack/README.md +6 -5
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +150 -10
  13. package/apps/aws-stack/tests/stack.test.ts +145 -4
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +149 -24
  38. package/apps/local-cli/tests/e2e.test.ts +1 -1
  39. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  40. package/package.json +14 -10
  41. package/packages/aws-adapter/package.json +3 -3
  42. package/packages/aws-adapter/src/account-store.ts +1 -1
  43. package/packages/aws-adapter/src/admission.ts +74 -0
  44. package/packages/aws-adapter/src/aws-runtime.ts +75 -4
  45. package/packages/aws-adapter/src/config-loader.ts +32 -0
  46. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  47. package/packages/aws-adapter/src/handlers/connect.ts +96 -9
  48. package/packages/aws-adapter/src/handlers/default.ts +98 -7
  49. package/packages/aws-adapter/src/handlers/index.ts +109 -4
  50. package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
  51. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  52. package/packages/aws-adapter/src/index.ts +8 -0
  53. package/packages/aws-adapter/src/serialize.ts +11 -1
  54. package/packages/aws-adapter/src/stats.ts +80 -0
  55. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  56. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  57. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  58. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  59. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  60. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  61. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  62. package/packages/aws-adapter/tests/connect.test.ts +174 -0
  63. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  64. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  65. package/packages/aws-adapter/tests/handlers.test.ts +302 -53
  66. package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
  67. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  68. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +5 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +68 -5
  73. package/packages/cf-adapter/src/channel-do.ts +2 -2
  74. package/packages/cf-adapter/src/config-loader.ts +33 -0
  75. package/packages/cf-adapter/src/connection-do.ts +278 -85
  76. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  77. package/packages/cf-adapter/src/env.ts +54 -11
  78. package/packages/cf-adapter/src/index.ts +11 -8
  79. package/packages/cf-adapter/src/registry-do.ts +22 -3
  80. package/packages/cf-adapter/src/sharding.ts +1 -2
  81. package/packages/cf-adapter/src/stats.ts +65 -0
  82. package/packages/cf-adapter/tests/cf-harness.ts +12 -2
  83. package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
  84. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  85. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  86. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  87. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  88. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  89. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  90. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  91. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  92. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  93. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  94. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  95. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  96. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  97. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  98. package/packages/cf-adapter/tests/worker/main.ts +15 -8
  99. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  100. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  101. package/packages/cf-adapter/wrangler.test.toml +15 -0
  102. package/packages/in-memory-runtime/package.json +1 -1
  103. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  104. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  105. package/packages/irc-core/package.json +6 -1
  106. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  107. package/packages/irc-core/src/admission.ts +16 -15
  108. package/packages/irc-core/src/caps/capabilities.ts +24 -3
  109. package/packages/irc-core/src/cloak.ts +1 -1
  110. package/packages/irc-core/src/commands/cap.ts +8 -1
  111. package/packages/irc-core/src/commands/index.ts +17 -0
  112. package/packages/irc-core/src/commands/invite.ts +2 -4
  113. package/packages/irc-core/src/commands/ison.ts +61 -0
  114. package/packages/irc-core/src/commands/isupport.ts +6 -2
  115. package/packages/irc-core/src/commands/kick.ts +2 -4
  116. package/packages/irc-core/src/commands/kill.ts +127 -0
  117. package/packages/irc-core/src/commands/list.ts +1 -1
  118. package/packages/irc-core/src/commands/lusers.ts +204 -0
  119. package/packages/irc-core/src/commands/mode.ts +4 -8
  120. package/packages/irc-core/src/commands/names.ts +3 -5
  121. package/packages/irc-core/src/commands/part.ts +2 -4
  122. package/packages/irc-core/src/commands/quit.ts +12 -0
  123. package/packages/irc-core/src/commands/registration.ts +18 -12
  124. package/packages/irc-core/src/commands/rehash.ts +119 -0
  125. package/packages/irc-core/src/commands/sasl.ts +72 -9
  126. package/packages/irc-core/src/commands/server-info.ts +129 -0
  127. package/packages/irc-core/src/commands/setname.ts +109 -0
  128. package/packages/irc-core/src/commands/stats.ts +152 -0
  129. package/packages/irc-core/src/commands/topic.ts +2 -4
  130. package/packages/irc-core/src/commands/trace.ts +137 -0
  131. package/packages/irc-core/src/commands/userhost.ts +84 -0
  132. package/packages/irc-core/src/commands/wallops.ts +118 -0
  133. package/packages/irc-core/src/commands/whowas.ts +113 -0
  134. package/packages/irc-core/src/config.ts +65 -0
  135. package/packages/irc-core/src/credential-hashing.ts +124 -0
  136. package/packages/irc-core/src/effects.ts +33 -30
  137. package/packages/irc-core/src/index.ts +3 -0
  138. package/packages/irc-core/src/ports.ts +360 -12
  139. package/packages/irc-core/src/protocol/numerics.ts +48 -11
  140. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  141. package/packages/irc-core/src/types.ts +46 -2
  142. package/packages/irc-core/src/ws-framing.ts +132 -0
  143. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  144. package/packages/irc-core/tests/account-store.test.ts +45 -2
  145. package/packages/irc-core/tests/admission.test.ts +18 -0
  146. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  147. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  148. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  149. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  150. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  151. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  152. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  153. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  154. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  155. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  156. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  157. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  158. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  159. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  160. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  161. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  162. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  163. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  164. package/packages/irc-core/tests/config.test.ts +95 -1
  165. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  166. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  167. package/packages/irc-core/tests/effects.test.ts +14 -27
  168. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  169. package/packages/irc-core/tests/numerics.test.ts +102 -0
  170. package/packages/irc-core/tests/outbound.test.ts +51 -0
  171. package/packages/irc-core/tests/ports.test.ts +22 -0
  172. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  173. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  174. package/packages/irc-core/tests/types.test.ts +35 -1
  175. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  176. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  177. package/packages/irc-core/tsconfig.build.json +1 -1
  178. package/packages/irc-core/tsconfig.test.json +1 -1
  179. package/packages/irc-server/package.json +1 -1
  180. package/packages/irc-server/src/actor.ts +393 -16
  181. package/packages/irc-server/src/dispatch.ts +1 -3
  182. package/packages/irc-server/src/index.ts +10 -2
  183. package/packages/irc-server/src/routing.ts +15 -0
  184. package/packages/irc-server/src/runtime.ts +31 -0
  185. package/packages/irc-server/src/transport.ts +104 -0
  186. package/packages/irc-server/tests/actor.test.ts +1489 -4
  187. package/packages/irc-server/tests/dispatch.test.ts +37 -17
  188. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  189. package/packages/irc-server/tests/routing.test.ts +5 -0
  190. package/packages/irc-server/tests/runtime.test.ts +7 -0
  191. package/packages/irc-server/tests/transport.test.ts +230 -0
  192. package/packages/irc-test-support/package.json +1 -1
  193. package/packages/irc-test-support/src/harness.ts +44 -9
  194. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  195. package/packages/irc-test-support/src/index.ts +3 -0
  196. package/packages/irc-test-support/src/scenarios.ts +141 -3
  197. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  198. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  199. package/pnpm-workspace.yaml +10 -1
  200. package/tools/ci-hardening/package.json +1 -1
  201. package/tools/package.json +9 -0
  202. package/tools/seed-aws-accounts.ts +5 -6
  203. package/tools/seed-cf-accounts.ts +107 -0
  204. package/tools/tcp-ws-forwarder/package.json +1 -1
  205. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  206. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  207. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
  208. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  209. package/docs/ADR-002-location-of-authority.md +0 -82
  210. package/docs/ADR-003-durable-object-sharding.md +0 -93
  211. package/docs/ADR-004-dynamodb-schema.md +0 -96
  212. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  213. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  214. package/docs/ADR-007-deterministic-ports.md +0 -82
  215. package/docs/ADR-008-monorepo-tooling.md +0 -60
  216. package/docs/AWS-Adapter-Architecture.md +0 -496
  217. package/docs/AWS-Deployment.md +0 -1186
  218. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  219. package/docs/Home.md +0 -11
  220. package/docs/Observability.md +0 -87
  221. package/docs/PlanIRCv3Websocket.md +0 -489
  222. package/docs/PlanWebClient.md +0 -451
  223. package/docs/Release-Process.md +0 -443
@@ -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)}-`;
@@ -91,6 +96,9 @@ async function makeFixture(): Promise<Fixture> {
91
96
  motd: MOTD,
92
97
  messages: bindMessageStore(SERVER_CONFIG),
93
98
  managementApi: mgmt as never,
99
+ // Bind a REHASH reload source so the $default dispatch path exercises
100
+ // the configLoader wiring (and an oper's REHASH re-fetches config).
101
+ configLoader: async () => SERVER_CONFIG,
94
102
  },
95
103
  cleanup: async () => {
96
104
  for (const logical of Object.keys(TABLE_DEFS)) {
@@ -122,12 +130,12 @@ describe.skipIf(!available)('handlers', () => {
122
130
  it('returns 200 and persists a Connections row', async () => {
123
131
  const res = await dispatch(
124
132
  { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
125
- fx!.deps,
133
+ requireFx().deps,
126
134
  );
127
135
  expect(res.statusCode).toBe(200);
128
136
  const probe = new AwsRuntime({
129
- dynamo: fx!.client,
130
- tables: fx!.tables,
137
+ dynamo: requireFx().client,
138
+ tables: requireFx().tables,
131
139
  connId: '__probe__',
132
140
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
133
141
  managementApi: null,
@@ -137,13 +145,37 @@ describe.skipIf(!available)('handlers', () => {
137
145
  expect(info?.registration).toBe('pre-registration');
138
146
  });
139
147
 
148
+ it('echoes the agreed Sec-WebSocket-Protocol header back on a spec-mode connect', async () => {
149
+ const res = await dispatch(
150
+ {
151
+ requestContext: { routeKey: '$connect', connectionId: 'c-sub' },
152
+ headers: { 'Sec-WebSocket-Protocol': 'text.ircv3.net' },
153
+ },
154
+ requireFx().deps,
155
+ );
156
+ expect(res.statusCode).toBe(200);
157
+ expect(res.headers?.['Sec-WebSocket-Protocol']).toBe('text.ircv3.net');
158
+ });
159
+
160
+ it('omits the Sec-WebSocket-Protocol header on a legacy connect', async () => {
161
+ const res = await dispatch(
162
+ { requestContext: { routeKey: '$connect', connectionId: 'c-nosub' } },
163
+ requireFx().deps,
164
+ );
165
+ expect(res.statusCode).toBe(200);
166
+ expect(res.headers?.['Sec-WebSocket-Protocol']).toBeUndefined();
167
+ });
168
+
140
169
  it('is idempotent on APIGW retry (same connectionId overwrites)', async () => {
141
170
  for (let i = 0; i < 2; i++) {
142
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
171
+ await dispatch(
172
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
173
+ requireFx().deps,
174
+ );
143
175
  }
144
176
  const probe = new AwsRuntime({
145
- dynamo: fx!.client,
146
- tables: fx!.tables,
177
+ dynamo: requireFx().client,
178
+ tables: requireFx().tables,
147
179
  connId: '__probe__',
148
180
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
149
181
  managementApi: null,
@@ -153,21 +185,84 @@ describe.skipIf(!available)('handlers', () => {
153
185
  });
154
186
  });
155
187
 
188
+ // -------------------------------------------------------------------------
189
+ // $connect admission gate (max-clients cap)
190
+ // -------------------------------------------------------------------------
191
+
192
+ describe('$connect admission gate', () => {
193
+ it('rejects the connection that exceeds the maxClients cap', async () => {
194
+ const capped: HandlerDeps = {
195
+ ...requireFx().deps,
196
+ serverConfig: { ...SERVER_CONFIG, maxClients: 2 },
197
+ };
198
+ const r1 = await dispatch(
199
+ { requestContext: { routeKey: '$connect', connectionId: 'a1' } },
200
+ capped,
201
+ );
202
+ const r2 = await dispatch(
203
+ { requestContext: { routeKey: '$connect', connectionId: 'a2' } },
204
+ capped,
205
+ );
206
+ const r3 = await dispatch(
207
+ { requestContext: { routeKey: '$connect', connectionId: 'a3' } },
208
+ capped,
209
+ );
210
+ expect(r1.statusCode).toBe(200);
211
+ expect(r2.statusCode).toBe(200);
212
+ expect(r3.statusCode).toBe(429);
213
+
214
+ const probe = new AwsRuntime({
215
+ dynamo: requireFx().client,
216
+ tables: requireFx().tables,
217
+ connId: '__probe__',
218
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
219
+ managementApi: null,
220
+ });
221
+ expect((await probe.getConnectionInfo('a1'))?.id).toBe('a1');
222
+ expect((await probe.getConnectionInfo('a2'))?.id).toBe('a2');
223
+ // The over-cap connection must NOT be recorded.
224
+ expect(await probe.getConnectionInfo('a3')).toBeNull();
225
+ });
226
+
227
+ it('admits a connection when the table is below the cap', async () => {
228
+ const capped: HandlerDeps = {
229
+ ...requireFx().deps,
230
+ serverConfig: { ...SERVER_CONFIG, maxClients: 5 },
231
+ };
232
+ const res = await dispatch(
233
+ { requestContext: { routeKey: '$connect', connectionId: 'b1' } },
234
+ capped,
235
+ );
236
+ expect(res.statusCode).toBe(200);
237
+ const probe = new AwsRuntime({
238
+ dynamo: requireFx().client,
239
+ tables: requireFx().tables,
240
+ connId: '__probe__',
241
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
242
+ managementApi: null,
243
+ });
244
+ expect((await probe.getConnectionInfo('b1'))?.id).toBe('b1');
245
+ });
246
+ });
247
+
156
248
  // -------------------------------------------------------------------------
157
249
  // $disconnect
158
250
  // -------------------------------------------------------------------------
159
251
 
160
252
  describe('$disconnect', () => {
161
253
  it('returns 200 and deletes the Connections row', async () => {
162
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
254
+ await dispatch(
255
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
256
+ requireFx().deps,
257
+ );
163
258
  const res = await dispatch(
164
259
  { requestContext: { routeKey: '$disconnect', connectionId: 'c1' } },
165
- fx!.deps,
260
+ requireFx().deps,
166
261
  );
167
262
  expect(res.statusCode).toBe(200);
168
263
  const probe = new AwsRuntime({
169
- dynamo: fx!.client,
170
- tables: fx!.tables,
264
+ dynamo: requireFx().client,
265
+ tables: requireFx().tables,
171
266
  connId: '__probe__',
172
267
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
173
268
  managementApi: null,
@@ -178,33 +273,39 @@ describe.skipIf(!available)('handlers', () => {
178
273
  it('returns 200 even when the connection was never seen', async () => {
179
274
  const res = await dispatch(
180
275
  { requestContext: { routeKey: '$disconnect', connectionId: 'never' } },
181
- fx!.deps,
276
+ requireFx().deps,
182
277
  );
183
278
  expect(res.statusCode).toBe(200);
184
279
  });
185
280
 
186
281
  it('releases the nick on disconnect', async () => {
187
282
  // Connect + register alice.
188
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
283
+ await dispatch(
284
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
285
+ requireFx().deps,
286
+ );
189
287
  await dispatch(
190
288
  {
191
289
  requestContext: { routeKey: '$default', connectionId: 'c1' },
192
290
  body: 'NICK alice\r\n',
193
291
  },
194
- fx!.deps,
292
+ requireFx().deps,
195
293
  );
196
294
  await dispatch(
197
295
  {
198
296
  requestContext: { routeKey: '$default', connectionId: 'c1' },
199
297
  body: 'USER alice 0 * :Alice\r\n',
200
298
  },
201
- fx!.deps,
299
+ requireFx().deps,
202
300
  );
203
301
  // Disconnect.
204
- await dispatch({ requestContext: { routeKey: '$disconnect', connectionId: 'c1' } }, fx!.deps);
302
+ await dispatch(
303
+ { requestContext: { routeKey: '$disconnect', connectionId: 'c1' } },
304
+ requireFx().deps,
305
+ );
205
306
  const probe = new AwsRuntime({
206
- dynamo: fx!.client,
207
- tables: fx!.tables,
307
+ dynamo: requireFx().client,
308
+ tables: requireFx().tables,
208
309
  connId: '__probe__',
209
310
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
210
311
  managementApi: null,
@@ -224,7 +325,7 @@ describe.skipIf(!available)('handlers', () => {
224
325
  requestContext: { routeKey: '$default', connectionId: 'ghost' },
225
326
  body: 'PING :tok\r\n',
226
327
  },
227
- fx!.deps,
328
+ requireFx().deps,
228
329
  );
229
330
  expect(res.statusCode).toBe(410);
230
331
  });
@@ -234,23 +335,26 @@ describe.skipIf(!available)('handlers', () => {
234
335
  const event = {
235
336
  requestContext: { routeKey: '$default', connectionId: 'c1' },
236
337
  } as never;
237
- const res = await dispatch(event, fx!.deps);
338
+ const res = await dispatch(event, requireFx().deps);
238
339
  expect(res.statusCode).toBe(400);
239
340
  });
240
341
 
241
342
  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', () => {});
343
+ await dispatch(
344
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
345
+ requireFx().deps,
346
+ );
347
+ requireFx().mgmt.register('c1', () => {});
244
348
  await dispatch(
245
349
  {
246
350
  requestContext: { routeKey: '$default', connectionId: 'c1' },
247
351
  body: 'NICK alice\r\nUSER alice 0 * :Alice\r\n',
248
352
  },
249
- fx!.deps,
353
+ requireFx().deps,
250
354
  );
251
355
  const probe = new AwsRuntime({
252
- dynamo: fx!.client,
253
- tables: fx!.tables,
356
+ dynamo: requireFx().client,
357
+ tables: requireFx().tables,
254
358
  connId: '__probe__',
255
359
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
256
360
  managementApi: null,
@@ -261,9 +365,12 @@ describe.skipIf(!available)('handlers', () => {
261
365
  });
262
366
 
263
367
  it('echoes outbound lines back to the caller via the management API', async () => {
264
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
368
+ await dispatch(
369
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
370
+ requireFx().deps,
371
+ );
265
372
  const sink: string[] = [];
266
- fx!.mgmt.register('c1', (data) => {
373
+ requireFx().mgmt.register('c1', (data) => {
267
374
  for (const line of data.split('\r\n')) {
268
375
  if (line.length > 0) sink.push(line);
269
376
  }
@@ -273,31 +380,123 @@ describe.skipIf(!available)('handlers', () => {
273
380
  requestContext: { routeKey: '$default', connectionId: 'c1' },
274
381
  body: 'PING :tok\r\n',
275
382
  },
276
- fx!.deps,
383
+ requireFx().deps,
277
384
  );
278
385
  expect(sink).toContain('PONG :tok');
279
386
  });
280
387
 
388
+ it('delivers each outbound line as its own postToConnection for a spec-mode connection', async () => {
389
+ await dispatch(
390
+ {
391
+ requestContext: { routeKey: '$connect', connectionId: 'spec1' },
392
+ headers: { 'Sec-WebSocket-Protocol': 'text.ircv3.net' },
393
+ },
394
+ requireFx().deps,
395
+ );
396
+ const calls: string[] = [];
397
+ requireFx().mgmt.register('spec1', (data) => {
398
+ calls.push(data);
399
+ });
400
+ // Spec clients send exactly one IRC message per WebSocket frame.
401
+ await dispatch(
402
+ {
403
+ requestContext: { routeKey: '$default', connectionId: 'spec1' },
404
+ body: 'NICK bob\r\n',
405
+ },
406
+ requireFx().deps,
407
+ );
408
+ await dispatch(
409
+ {
410
+ requestContext: { routeKey: '$default', connectionId: 'spec1' },
411
+ body: 'USER bob 0 * :Bob\r\n',
412
+ },
413
+ requireFx().deps,
414
+ );
415
+ // The welcome block is multiple IRC lines; the IRCv3 spec contract
416
+ // sends exactly one line per WebSocket message (no trailing CRLF).
417
+ expect(calls.length).toBeGreaterThan(1);
418
+ for (const data of calls) {
419
+ expect(data).not.toContain('\r\n');
420
+ }
421
+ expect(calls.some((d) => d.includes(' 001 '))).toBe(true);
422
+ });
423
+
424
+ it('delivers one postToConnection per line for a binary.ircv3.net connection', async () => {
425
+ await dispatch(
426
+ {
427
+ requestContext: { routeKey: '$connect', connectionId: 'spec2' },
428
+ headers: { 'Sec-WebSocket-Protocol': 'binary.ircv3.net' },
429
+ },
430
+ requireFx().deps,
431
+ );
432
+ const calls: string[] = [];
433
+ requireFx().mgmt.register('spec2', (data) => {
434
+ calls.push(data);
435
+ });
436
+ await dispatch(
437
+ {
438
+ requestContext: { routeKey: '$default', connectionId: 'spec2' },
439
+ body: 'NICK binbob\r\n',
440
+ },
441
+ requireFx().deps,
442
+ );
443
+ await dispatch(
444
+ {
445
+ requestContext: { routeKey: '$default', connectionId: 'spec2' },
446
+ body: 'USER binbob 0 * :BinBob\r\n',
447
+ },
448
+ requireFx().deps,
449
+ );
450
+ expect(calls.length).toBeGreaterThan(1);
451
+ for (const data of calls) {
452
+ expect(data).not.toContain('\r\n');
453
+ }
454
+ });
455
+
456
+ it('batches outbound lines into a single postToConnection for a legacy connection', async () => {
457
+ await dispatch(
458
+ { requestContext: { routeKey: '$connect', connectionId: 'leg1' } },
459
+ requireFx().deps,
460
+ );
461
+ const calls: string[] = [];
462
+ requireFx().mgmt.register('leg1', (data) => {
463
+ calls.push(data);
464
+ });
465
+ await dispatch(
466
+ {
467
+ requestContext: { routeKey: '$default', connectionId: 'leg1' },
468
+ body: 'NICK carol\r\nUSER carol 0 * :Carol\r\n',
469
+ },
470
+ requireFx().deps,
471
+ );
472
+ expect(calls.length).toBe(1);
473
+ expect(calls[0]).toContain('\r\n');
474
+ expect(calls[0]).toContain(' 001 ');
475
+ });
476
+
281
477
  it('persists joined channels across $default invocations', async () => {
282
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
283
- fx!.mgmt.register('c1', () => {});
478
+ await dispatch(
479
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
480
+ requireFx().deps,
481
+ );
482
+ requireFx().mgmt.register('c1', () => {});
284
483
  await dispatch(
285
484
  {
286
485
  requestContext: { routeKey: '$default', connectionId: 'c1' },
287
486
  body: 'NICK alice\r\nUSER alice 0 * :Alice\r\n',
288
487
  },
289
- fx!.deps,
488
+ requireFx().deps,
290
489
  );
291
490
  await dispatch(
292
491
  {
293
492
  requestContext: { routeKey: '$default', connectionId: 'c1' },
294
493
  body: 'JOIN #persist\r\n',
295
494
  },
296
- fx!.deps,
495
+ requireFx().deps,
297
496
  );
298
497
  const probe = new AwsRuntime({
299
- dynamo: fx!.client,
300
- tables: fx!.tables,
498
+ dynamo: requireFx().client,
499
+ tables: requireFx().tables,
301
500
  connId: '__probe__',
302
501
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
303
502
  managementApi: null,
@@ -308,26 +507,32 @@ describe.skipIf(!available)('handlers', () => {
308
507
 
309
508
  it('rebuilds an existing channel roster via refreshChannel when a second client JOINs', async () => {
310
509
  // First connection registers + JOINs.
311
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
312
- fx!.mgmt.register('c1', () => {});
510
+ await dispatch(
511
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
512
+ requireFx().deps,
513
+ );
514
+ requireFx().mgmt.register('c1', () => {});
313
515
  await dispatch(
314
516
  {
315
517
  requestContext: { routeKey: '$default', connectionId: 'c1' },
316
518
  body: 'NICK alice\r\nUSER alice 0 * :Alice\r\n',
317
519
  },
318
- fx!.deps,
520
+ requireFx().deps,
319
521
  );
320
522
  await dispatch(
321
523
  {
322
524
  requestContext: { routeKey: '$default', connectionId: 'c1' },
323
525
  body: 'JOIN #room\r\n',
324
526
  },
325
- fx!.deps,
527
+ requireFx().deps,
326
528
  );
327
529
  // Second connection registers + JOINs the SAME channel.
328
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c2' } }, fx!.deps);
530
+ await dispatch(
531
+ { requestContext: { routeKey: '$connect', connectionId: 'c2' } },
532
+ requireFx().deps,
533
+ );
329
534
  const c2sink: string[] = [];
330
- fx!.mgmt.register('c2', (data) => {
535
+ requireFx().mgmt.register('c2', (data) => {
331
536
  for (const line of data.split('\r\n')) {
332
537
  if (line.length > 0) c2sink.push(line);
333
538
  }
@@ -337,14 +542,14 @@ describe.skipIf(!available)('handlers', () => {
337
542
  requestContext: { routeKey: '$default', connectionId: 'c2' },
338
543
  body: 'NICK bob\r\nUSER bob 0 * :Bob\r\n',
339
544
  },
340
- fx!.deps,
545
+ requireFx().deps,
341
546
  );
342
547
  await dispatch(
343
548
  {
344
549
  requestContext: { routeKey: '$default', connectionId: 'c2' },
345
550
  body: 'JOIN #room\r\n',
346
551
  },
347
- fx!.deps,
552
+ requireFx().deps,
348
553
  );
349
554
  // The 353 RPL_NAMREPLY c2 receives must list BOTH alice and bob —
350
555
  // proving that the actor's `refreshChannel` populated the cache
@@ -356,9 +561,12 @@ describe.skipIf(!available)('handlers', () => {
356
561
  });
357
562
 
358
563
  it('records a PRIVMSG and replays it via CHATHISTORY LATEST in a BATCH', async () => {
359
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
564
+ await dispatch(
565
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
566
+ requireFx().deps,
567
+ );
360
568
  const sink: string[] = [];
361
- fx!.mgmt.register('c1', (data) => {
569
+ requireFx().mgmt.register('c1', (data) => {
362
570
  for (const line of data.split('\r\n')) {
363
571
  if (line.length > 0) sink.push(line);
364
572
  }
@@ -368,7 +576,7 @@ describe.skipIf(!available)('handlers', () => {
368
576
  requestContext: { routeKey: '$default', connectionId: 'c1' },
369
577
  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
578
  },
371
- fx!.deps,
579
+ requireFx().deps,
372
580
  );
373
581
  sink.length = 0;
374
582
  await dispatch(
@@ -376,7 +584,7 @@ describe.skipIf(!available)('handlers', () => {
376
584
  requestContext: { routeKey: '$default', connectionId: 'c1' },
377
585
  body: 'PRIVMSG #aws-chathist :hello from aws\r\nCHATHISTORY LATEST #aws-chathist * 10\r\n',
378
586
  },
379
- fx!.deps,
587
+ requireFx().deps,
380
588
  );
381
589
  const batchOpen = sink.find((l) => /^BATCH \+\S+ chathistory #aws-chathist/.test(l));
382
590
  expect(batchOpen).toBeDefined();
@@ -385,16 +593,19 @@ describe.skipIf(!available)('handlers', () => {
385
593
  });
386
594
 
387
595
  it('completes SASL PLAIN with 900/903 when an AccountStore is bound', async () => {
388
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
596
+ await dispatch(
597
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
598
+ requireFx().deps,
599
+ );
389
600
  const sink: string[] = [];
390
- fx!.mgmt.register('c1', (data) => {
601
+ requireFx().mgmt.register('c1', (data) => {
391
602
  for (const line of data.split('\r\n')) {
392
603
  if (line.length > 0) sink.push(line);
393
604
  }
394
605
  });
395
606
  // Swap in a deps with a seeded AccountStore for this dispatch.
396
607
  const depsWithAccounts: HandlerDeps = {
397
- ...fx!.deps,
608
+ ...requireFx().deps,
398
609
  accounts: new InMemoryAccountStore([{ username: 'aws-sasl', password: 's3cret' }]),
399
610
  };
400
611
  const payload = Buffer.from('\0aws-sasl\0s3cret', 'utf8').toString('base64');
@@ -411,9 +622,12 @@ describe.skipIf(!available)('handlers', () => {
411
622
  });
412
623
 
413
624
  it('emits 904 when no AccountStore is bound (regression guard)', async () => {
414
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c2' } }, fx!.deps);
625
+ await dispatch(
626
+ { requestContext: { routeKey: '$connect', connectionId: 'c2' } },
627
+ requireFx().deps,
628
+ );
415
629
  const sink: string[] = [];
416
- fx!.mgmt.register('c2', (data) => {
630
+ requireFx().mgmt.register('c2', (data) => {
417
631
  for (const line of data.split('\r\n')) {
418
632
  if (line.length > 0) sink.push(line);
419
633
  }
@@ -424,7 +638,7 @@ describe.skipIf(!available)('handlers', () => {
424
638
  requestContext: { routeKey: '$default', connectionId: 'c2' },
425
639
  body: `CAP REQ :sasl\r\nAUTHENTICATE PLAIN\r\nAUTHENTICATE ${payload}\r\n`,
426
640
  },
427
- fx!.deps,
641
+ requireFx().deps,
428
642
  );
429
643
  expect(sink.some((l) => l.includes(' 904 '))).toBe(true);
430
644
  });
@@ -436,6 +650,41 @@ describe.skipIf(!available)('handlers', () => {
436
650
  // The 500-branch is covered indirectly by GoneException tests.
437
651
  expect(true).toBe(true);
438
652
  });
653
+
654
+ it('runs an oper REHASH through the bound configLoader and emits 382', async () => {
655
+ await dispatch(
656
+ { requestContext: { routeKey: '$connect', connectionId: 'rh1' } },
657
+ requireFx().deps,
658
+ );
659
+ const sink: string[] = [];
660
+ requireFx().mgmt.register('rh1', (data) => {
661
+ for (const line of data.split('\r\n')) {
662
+ if (line.length > 0) sink.push(line);
663
+ }
664
+ });
665
+ // Seed oper creds + a reload source so OPER then REHASH both succeed.
666
+ const depsWithOper: HandlerDeps = {
667
+ ...requireFx().deps,
668
+ serverConfig: { ...SERVER_CONFIG, operCreds: [{ user: 'alice', password: 's3cret' }] },
669
+ configLoader: async () => ({
670
+ ...SERVER_CONFIG,
671
+ operCreds: [{ user: 'alice', password: 'rotated' }],
672
+ }),
673
+ };
674
+ await dispatch(
675
+ {
676
+ requestContext: { routeKey: '$default', connectionId: 'rh1' },
677
+ body: 'NICK alice\r\nUSER alice 0 * :Alice\r\nOPER alice s3cret\r\nREHASH\r\n',
678
+ },
679
+ depsWithOper,
680
+ );
681
+
682
+ // 381 (oper) + 382 (rehashing from the Secrets Manager source) emitted.
683
+ expect(sink.some((l) => l.includes(' 381 '))).toBe(true);
684
+ expect(sink.some((l) => l.includes(' 382 ') && l.includes('Secrets Manager'))).toBe(true);
685
+ // Never a 421 for REHASH.
686
+ expect(sink.some((l) => l.includes(' 421 ') && l.includes('REHASH'))).toBe(false);
687
+ });
439
688
  });
440
689
 
441
690
  // -------------------------------------------------------------------------
@@ -446,7 +695,7 @@ describe.skipIf(!available)('handlers', () => {
446
695
  it('returns 200 (APIGW only forwards the three known routes)', async () => {
447
696
  const res = await dispatch(
448
697
  { requestContext: { routeKey: '$custom', connectionId: 'c1' } },
449
- fx!.deps,
698
+ requireFx().deps,
450
699
  );
451
700
  expect(res.statusCode).toBe(200);
452
701
  });
@@ -462,7 +711,7 @@ class LocalPostToConnection implements PostToConnection {
462
711
  register(connId: string, sink: (data: string) => void): void {
463
712
  this.sinks.set(connId, sink);
464
713
  }
465
- async postToConnection(input: { ConnectionId: string; Data: string }): Promise<{}> {
714
+ async postToConnection(input: { ConnectionId: string; Data: string }): Promise<unknown> {
466
715
  const sink = this.sinks.get(input.ConnectionId);
467
716
  if (sink !== undefined) sink(input.Data);
468
717
  return {};