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
@@ -18,6 +18,9 @@ function fakeRuntime(calls: RecordedCall[]): IrcRuntime {
18
18
  async broadcast(chan, lines, except) {
19
19
  calls.push({ method: 'broadcast', args: [chan, lines, except] });
20
20
  },
21
+ async broadcastWallops(lines, except) {
22
+ calls.push({ method: 'broadcastWallops', args: [lines, except] });
23
+ },
21
24
  async disconnect(conn, reason) {
22
25
  calls.push({ method: 'disconnect', args: [conn, reason] });
23
26
  },
@@ -60,6 +63,10 @@ function fakeRuntime(calls: RecordedCall[]): IrcRuntime {
60
63
  async listChannels() {
61
64
  return [];
62
65
  },
66
+ async reloadConfig() {
67
+ calls.push({ method: 'reloadConfig', args: [] });
68
+ throw new Error('reloadConfig not configured for this fake');
69
+ },
63
70
  };
64
71
  }
65
72
 
@@ -126,6 +133,24 @@ describe('dispatch', () => {
126
133
  });
127
134
  });
128
135
 
136
+ it('routes BroadcastWallops with lines and except', async () => {
137
+ const calls: RecordedCall[] = [];
138
+ await dispatch([Effect.broadcastWallops([L(':a WALLOPS :hi')], 'c1')], fakeRuntime(calls));
139
+ expect(calls[0]).toEqual({
140
+ method: 'broadcastWallops',
141
+ args: [[{ text: ':a WALLOPS :hi' }], 'c1'],
142
+ });
143
+ });
144
+
145
+ it('routes BroadcastWallops preserving undefined except', async () => {
146
+ const calls: RecordedCall[] = [];
147
+ await dispatch([Effect.broadcastWallops([L(':a WALLOPS :hi')])], fakeRuntime(calls));
148
+ expect(calls[0]).toEqual({
149
+ method: 'broadcastWallops',
150
+ args: [[{ text: ':a WALLOPS :hi' }], undefined],
151
+ });
152
+ });
153
+
129
154
  it('calls reserveNick, changeNick, releaseNick in order', async () => {
130
155
  const calls: RecordedCall[] = [];
131
156
  await dispatch(
@@ -146,23 +171,6 @@ describe('dispatch', () => {
146
171
  expect(calls[0]).toEqual({ method: 'applyChannelDelta', args: ['#foo', delta] });
147
172
  });
148
173
 
149
- it('invokes lookupNick, getConnectionInfo, getChannelSnapshot lookups', async () => {
150
- const calls: RecordedCall[] = [];
151
- await dispatch(
152
- [
153
- Effect.lookupNick('alice'),
154
- Effect.getConnectionInfo('c1'),
155
- Effect.getChannelSnapshot('#foo'),
156
- ],
157
- fakeRuntime(calls),
158
- );
159
- expect(calls.map((c) => c.method)).toEqual([
160
- 'lookupNick',
161
- 'getConnectionInfo',
162
- 'getChannelSnapshot',
163
- ]);
164
- });
165
-
166
174
  it('awaits each effect in sequence (one fails, the rest after it do not run)', async () => {
167
175
  const calls: RecordedCall[] = [];
168
176
  const failing: IrcRuntime = {
@@ -264,6 +272,7 @@ function capAwareRuntime(members: ReadonlyMap<ConnId, MemberInfo>): {
264
272
  async broadcast(chan, lines, except) {
265
273
  broadcasts.push({ chan, lines, except });
266
274
  },
275
+ async broadcastWallops() {},
267
276
  async disconnect() {},
268
277
  async sendToNick() {},
269
278
  async reserveNick() {
@@ -321,6 +330,9 @@ function capAwareRuntime(members: ReadonlyMap<ConnId, MemberInfo>): {
321
330
  async listChannels() {
322
331
  return [];
323
332
  },
333
+ async reloadConfig() {
334
+ throw new Error('reloadConfig not configured for this fake');
335
+ },
324
336
  };
325
337
  return { runtime, sends, broadcasts };
326
338
  }
@@ -399,6 +411,7 @@ describe('dispatch — Broadcast cap / capLines', () => {
399
411
  sends.push({ to, lines });
400
412
  },
401
413
  async broadcast() {},
414
+ async broadcastWallops() {},
402
415
  async disconnect() {},
403
416
  async sendToNick() {},
404
417
  async reserveNick() {
@@ -427,6 +440,9 @@ describe('dispatch — Broadcast cap / capLines', () => {
427
440
  async listChannels() {
428
441
  return [];
429
442
  },
443
+ async reloadConfig() {
444
+ throw new Error('reloadConfig not configured for this fake');
445
+ },
430
446
  };
431
447
 
432
448
  await dispatch([Effect.broadcast('#nope', [L('x')], undefined, 'chghost')], runtime);
@@ -442,6 +458,7 @@ describe('dispatch — Broadcast cap / capLines', () => {
442
458
  sends.push({ to, lines });
443
459
  },
444
460
  async broadcast() {},
461
+ async broadcastWallops() {},
445
462
  async disconnect() {},
446
463
  async sendToNick() {},
447
464
  async reserveNick() {
@@ -500,6 +517,9 @@ describe('dispatch — Broadcast cap / capLines', () => {
500
517
  async listChannels() {
501
518
  return [];
502
519
  },
520
+ async reloadConfig() {
521
+ throw new Error('reloadConfig not configured for this fake');
522
+ },
503
523
  };
504
524
 
505
525
  await dispatch(
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Ambient declaration so `?raw` imports (Vite's built-in transform that
3
+ * inlines a file's text at bundle time) typecheck under the strict
4
+ * `tsconfig.test.json`. Used by source-level regression guards that
5
+ * assert the actor default-branch triage stays accurate.
6
+ */
7
+ declare module '*?raw' {
8
+ const content: string;
9
+ // eslint-disable-next-line import/no-default-export
10
+ export default content;
11
+ }
@@ -109,10 +109,12 @@ describe('buildRoutedReducers', () => {
109
109
  const reducers = buildRoutedReducers(baseServerConfig);
110
110
  expect(Object.keys(reducers).sort()).toEqual(
111
111
  [
112
+ 'admin',
112
113
  'authenticate',
113
114
  'away',
114
115
  'cap',
115
116
  'channelMode',
117
+ 'info',
116
118
  'invite',
117
119
  'join',
118
120
  'kick',
@@ -129,11 +131,14 @@ describe('buildRoutedReducers', () => {
129
131
  'privmsgChannel',
130
132
  'privmsgUser',
131
133
  'quit',
134
+ 'setname',
132
135
  'tagmsgChannel',
133
136
  'tagmsgUser',
137
+ 'time',
134
138
  'topic',
135
139
  'user',
136
140
  'userMode',
141
+ 'version',
137
142
  ].sort(),
138
143
  );
139
144
  });
@@ -34,6 +34,10 @@ describe('IrcRuntime contract', () => {
34
34
  async listChannels() {
35
35
  return [];
36
36
  },
37
+ async reloadConfig() {
38
+ throw new Error('reloadConfig not configured for this fake');
39
+ },
40
+ async broadcastWallops() {},
37
41
  };
38
42
  expect(runtime).toBeDefined();
39
43
  expect(typeof runtime.send).toBe('function');
@@ -48,5 +52,8 @@ describe('IrcRuntime contract', () => {
48
52
  expect(typeof runtime.getConnectionInfo).toBe('function');
49
53
  expect(typeof runtime.getConnection).toBe('function');
50
54
  expect(typeof runtime.getChannelSnapshot).toBe('function');
55
+ expect(typeof runtime.listChannels).toBe('function');
56
+ expect(typeof runtime.reloadConfig).toBe('function');
57
+ expect(typeof runtime.broadcastWallops).toBe('function');
51
58
  });
52
59
  });
@@ -0,0 +1,230 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { TcpByteStreamTransport, WsTextFrameTransport } from '../src/transport';
3
+ import type { Transport } from '../src/transport';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Transport seam: the generalized line-framing contract shared by the
7
+ // WebSocket and raw TCP+TLS transports (PLAN §9, ADR-009).
8
+ //
9
+ // Two transports share one parser/reducer/dispatch pipeline:
10
+ // - WsTextFrameTransport — one (or N `\r\n`-joined) IRC message(s) per WS
11
+ // text frame; a frame is a complete unit, so a
12
+ // trailing unterminated line is still emitted.
13
+ // - TcpByteStreamTransport — stateful `\r\n` byte-stream framing across
14
+ // arbitrary chunks with partial-line buffering.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ describe('WsTextFrameTransport', () => {
18
+ it('returns [] for an empty frame', () => {
19
+ const t = new WsTextFrameTransport();
20
+ expect(t.feed('')).toEqual([]);
21
+ });
22
+
23
+ it('emits a trailing unterminated line (a WS frame is a complete unit)', () => {
24
+ const t = new WsTextFrameTransport();
25
+ expect(t.feed('PING :tok')).toEqual(['PING :tok']);
26
+ });
27
+
28
+ it('splits a CRLF-terminated single line', () => {
29
+ const t = new WsTextFrameTransport();
30
+ expect(t.feed('PING :tok\r\n')).toEqual(['PING :tok', '']);
31
+ });
32
+
33
+ it('splits a frame with N CRLF-joined messages into N lines (in order)', () => {
34
+ const t = new WsTextFrameTransport();
35
+ expect(t.feed('PING :a\r\nPING :b\r\nPING :c\r\n')).toEqual([
36
+ 'PING :a',
37
+ 'PING :b',
38
+ 'PING :c',
39
+ '',
40
+ ]);
41
+ });
42
+
43
+ it('tolerates bare LF endings', () => {
44
+ const t = new WsTextFrameTransport();
45
+ expect(t.feed('PING :a\nPING :b\n')).toEqual(['PING :a', 'PING :b', '']);
46
+ });
47
+
48
+ it('keeps empty lines between messages (the actor skips them)', () => {
49
+ const t = new WsTextFrameTransport();
50
+ expect(t.feed('PING :a\r\n\r\nPING :b\r\n')).toEqual(['PING :a', '', 'PING :b', '']);
51
+ });
52
+
53
+ it('is stateless: the same frame fed twice yields the same result', () => {
54
+ const t = new WsTextFrameTransport();
55
+ const first = t.feed('PING :a\r\n');
56
+ const second = t.feed('PING :a\r\n');
57
+ expect(second).toEqual(first);
58
+ });
59
+ });
60
+
61
+ describe('TcpByteStreamTransport', () => {
62
+ it('returns [] for an empty chunk', () => {
63
+ const t = new TcpByteStreamTransport();
64
+ expect(t.feed('')).toEqual([]);
65
+ });
66
+
67
+ it('emits one complete CRLF-terminated line in a single chunk', () => {
68
+ const t = new TcpByteStreamTransport();
69
+ expect(t.feed('PRIVMSG #foo :hello\r\n')).toEqual(['PRIVMSG #foo :hello']);
70
+ });
71
+
72
+ it('buffers a partial line and emits nothing until the terminator arrives', () => {
73
+ const t = new TcpByteStreamTransport();
74
+ expect(t.feed('PRIVMSG #foo :hel')).toEqual([]);
75
+ expect(t.feed('lo\r\n')).toEqual(['PRIVMSG #foo :hello']);
76
+ });
77
+
78
+ it('two chunks split mid-line produce exactly one complete line (no loss, no duplication)', () => {
79
+ const t = new TcpByteStreamTransport();
80
+ expect(t.feed('PRIVMSG #foo :hel')).toEqual([]);
81
+ expect(t.feed('lo\r\n')).toEqual(['PRIVMSG #foo :hello']);
82
+ // A third empty chunk must not re-emit anything.
83
+ expect(t.feed('')).toEqual([]);
84
+ });
85
+
86
+ it('emits multiple complete lines delivered in one chunk, in order', () => {
87
+ const t = new TcpByteStreamTransport();
88
+ expect(t.feed('PING :a\r\nPING :b\r\nPING :c\r\n')).toEqual(['PING :a', 'PING :b', 'PING :c']);
89
+ });
90
+
91
+ it('terminates on bare LF (mixed/legacy clients)', () => {
92
+ const t = new TcpByteStreamTransport();
93
+ expect(t.feed('PING :a\nPING :b\n')).toEqual(['PING :a', 'PING :b']);
94
+ });
95
+
96
+ it('handles mixed CRLF and LF terminators in one stream', () => {
97
+ const t = new TcpByteStreamTransport();
98
+ expect(t.feed('A\r\nB\nC\r\n')).toEqual(['A', 'B', 'C']);
99
+ });
100
+
101
+ it('emits empty lines for consecutive terminators (the actor skips them)', () => {
102
+ const t = new TcpByteStreamTransport();
103
+ expect(t.feed('PING :a\r\n\r\nPING :b\r\n')).toEqual(['PING :a', '', 'PING :b']);
104
+ });
105
+
106
+ it('emits an empty line for a chunk that is only a terminator', () => {
107
+ const t = new TcpByteStreamTransport();
108
+ expect(t.feed('\r\n')).toEqual(['']);
109
+ });
110
+
111
+ it('emits an empty line when a chunk starts with a bare LF (no preceding CR)', () => {
112
+ const t = new TcpByteStreamTransport();
113
+ expect(t.feed('\n')).toEqual(['']);
114
+ });
115
+
116
+ it('reassembles a line split across more than two chunks', () => {
117
+ const t = new TcpByteStreamTransport();
118
+ expect(t.feed('PRI')).toEqual([]);
119
+ expect(t.feed('VMSG')).toEqual([]);
120
+ expect(t.feed(' #foo :')).toEqual([]);
121
+ expect(t.feed('hi\r\n')).toEqual(['PRIVMSG #foo :hi']);
122
+ });
123
+
124
+ it('handles a CRLF terminator split across chunks (CR in one, LF in next)', () => {
125
+ const t = new TcpByteStreamTransport();
126
+ expect(t.feed('PING :tok\r')).toEqual([]);
127
+ expect(t.feed('\n')).toEqual(['PING :tok']);
128
+ });
129
+
130
+ it('does not treat a stray CR without a following LF as a terminator', () => {
131
+ const t = new TcpByteStreamTransport();
132
+ // A bare CR mid-line is part of the line until a LF terminates it.
133
+ expect(t.feed('PING :a\rb\r\n')).toEqual(['PING :a\rb']);
134
+ });
135
+
136
+ it('preserves a trailing unterminated line across a flush of complete lines', () => {
137
+ const t = new TcpByteStreamTransport();
138
+ // Two complete lines plus the start of a third still in flight.
139
+ expect(t.feed('PING :a\r\nPING :b\r\nPI')).toEqual(['PING :a', 'PING :b']);
140
+ expect(t.feed('NG :c\r\n')).toEqual(['PING :c']);
141
+ });
142
+
143
+ it('satisfies the Transport interface contract', () => {
144
+ const t: Transport = new TcpByteStreamTransport();
145
+ expect(typeof t.feed).toBe('function');
146
+ expect(t.feed('x\r\n')).toEqual(['x']);
147
+ });
148
+ });
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Buffer save/restore: the NLB+Lambda TCP adapter must persist
152
+ // the partial-line buffer across independent Lambda invocations. The
153
+ // TcpByteStreamTransport exposes getBuffer/restore so the handler can
154
+ // snapshot the in-flight bytes to DynamoDB and rehydrate them on the next
155
+ // chunk from the same NLB flow.
156
+ // ---------------------------------------------------------------------------
157
+
158
+ describe('TcpByteStreamTransport — buffer save/restore', () => {
159
+ it('getBuffer returns "" when no partial line is buffered', () => {
160
+ const t = new TcpByteStreamTransport();
161
+ expect(t.getBuffer()).toBe('');
162
+ });
163
+
164
+ it('getBuffer returns the buffered partial line after a feed', () => {
165
+ const t = new TcpByteStreamTransport();
166
+ t.feed('PRIVMSG #foo :hel');
167
+ expect(t.getBuffer()).toBe('PRIVMSG #foo :hel');
168
+ });
169
+
170
+ it('getBuffer returns "" after all buffered bytes have been terminated', () => {
171
+ const t = new TcpByteStreamTransport();
172
+ t.feed('PRIVMSG #foo :hel');
173
+ t.feed('lo\r\n');
174
+ expect(t.getBuffer()).toBe('');
175
+ });
176
+
177
+ it('restore seeds the buffer so a subsequent chunk completes the line', () => {
178
+ const t = new TcpByteStreamTransport();
179
+ t.restore('PRIVMSG #foo :hel');
180
+ expect(t.feed('lo\r\n')).toEqual(['PRIVMSG #foo :hello']);
181
+ });
182
+
183
+ it('a fresh transport restored from a snapshot + fed the next chunk matches the original', () => {
184
+ const original = new TcpByteStreamTransport();
185
+ original.feed('PING :a\r\nPRIV');
186
+ const snapshot = original.getBuffer();
187
+
188
+ const restored = new TcpByteStreamTransport();
189
+ restored.restore(snapshot);
190
+ expect(restored.feed('MSG #b :hi\r\n')).toEqual(['PRIVMSG #b :hi']);
191
+ expect(restored.getBuffer()).toBe('');
192
+ });
193
+
194
+ it('restore("") behaves like a fresh transport (empty buffer)', () => {
195
+ const t = new TcpByteStreamTransport();
196
+ t.restore('');
197
+ expect(t.getBuffer()).toBe('');
198
+ expect(t.feed('PING :x\r\n')).toEqual(['PING :x']);
199
+ });
200
+ });
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Equivalence: a fully-terminated message stream produces the same complete
204
+ // lines regardless of how it is chunked across feed() calls.
205
+ // ---------------------------------------------------------------------------
206
+
207
+ describe('Transport — WS vs TCP framing equivalence', () => {
208
+ it('a fully-terminated single line yields the same complete line from both', () => {
209
+ const ws = new WsTextFrameTransport();
210
+ const tcp = new TcpByteStreamTransport();
211
+ const frame = 'PRIVMSG #foo :hello\r\n';
212
+ // Both transports emit the same complete line (the WS transport also
213
+ // yields a trailing '' from the final terminator, which the actor skips).
214
+ expect(ws.feed(frame)[0]).toBe(tcp.feed(frame)[0]);
215
+ });
216
+
217
+ it('chunking the same bytes arbitrarily never changes the reassembled lines', () => {
218
+ const stream = 'PRIVMSG #a :1\r\nPRIVMSG #b :2\r\nNOTICE #c :3\r\n';
219
+ const wsAll = new WsTextFrameTransport().feed(stream).filter((l) => l.length > 0);
220
+
221
+ // Feed the TCP transport the same bytes in arbitrary 5-byte chunks.
222
+ const tcp = new TcpByteStreamTransport();
223
+ const tcpLines: string[] = [];
224
+ for (let i = 0; i < stream.length; i += 5) {
225
+ tcpLines.push(...tcp.feed(stream.slice(i, i + 5)));
226
+ }
227
+ // Empty artifacts filtered, the reassembled lines are byte-identical.
228
+ expect(tcpLines.filter((l) => l.length > 0)).toEqual(wsAll);
229
+ });
230
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-test-support",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "private": true,
5
5
  "description": "Shared IRC scenario runner + harness seam used by every adapter's integration suite",
6
6
  "license": "BSD-3-Clause",
@@ -5,20 +5,37 @@
5
5
  * scenarios pass in-memory + CF + AWS." This file defines the seam:
6
6
  *
7
7
  * - {@link IrcHarnessFactory} builds a fresh, isolated world per test.
8
- * Each runtime (in-memory now; CF in Phase 3; AWS in Phase 4) ships
9
- * one factory. Adding a runtime to the parametrized suite is exactly
10
- * "register another factory in the `describe.each` table".
8
+ * Each (runtime × transport) combination ships one factory. Adding a
9
+ * runtime OR a transport to the parametrized suite is exactly "register
10
+ * another factory in the `describe.each` table".
11
11
  * - {@link IrcHarness} spawns clients, each a single IRC connection.
12
12
  * - {@link ClientHarness} is the per-connection handle: send a raw IRC
13
- * line, observe received lines, await a predicate, register the
14
- * connection (NICK+USER), close.
13
+ * line, feed raw transport bytes (for framing tests), observe received
14
+ * lines, await a predicate, register the connection (NICK+USER), close.
15
15
  *
16
16
  * The harness hides platform-specific transport (in-memory actor,
17
- * HibernatingWebSocket in a ConnectionDO, APIGW `$default` Lambda) behind
18
- * one synchronous-looking API. Tests are written once and exercise the
19
- * full pipeline: bytes → framed messages → reducer → dispatch → runtime.
17
+ * HibernatingWebSocket in a ConnectionDO, APIGW `$default` Lambda, raw
18
+ * TCP+TLS byte stream) behind one synchronous-looking API. Tests are
19
+ * written once and exercise the full pipeline: bytes → framed messages →
20
+ * reducer → dispatch.
20
21
  */
21
22
 
23
+ /**
24
+ * The kind of line-framing transport a factory binds. Drives the
25
+ * (runtime × transport) parametrized matrix:
26
+ * - `ws` — one WebSocket text frame per inbound unit (one or N
27
+ * `\r\n`-joined messages); a trailing unterminated line is
28
+ * still emitted because a frame is a complete unit.
29
+ * - `tcp` — a raw TCP+TLS byte stream; arbitrary chunk boundaries with
30
+ * stateful `\r\n` framing and partial-line buffering across
31
+ * chunks (PLAN §9, ADR-009).
32
+ *
33
+ * The scenario runner uses this to gate transport-specific cases: the
34
+ * TCP-framing scenarios (chunk boundaries, slow-loris partial sends, bare
35
+ * `\n` tolerance) only run against `transport: 'tcp'` factories.
36
+ */
37
+ export type TransportKind = 'ws' | 'tcp';
38
+
22
39
  import type { ConnId } from '@serverless-ircd/irc-core';
23
40
 
24
41
  /**
@@ -29,8 +46,14 @@ import type { ConnId } from '@serverless-ircd/irc-core';
29
46
  * once per `it(...)` and immediately tears down via {@link IrcHarness.close}.
30
47
  */
31
48
  export interface IrcHarnessFactory {
32
- /** Human-readable label for `describe.each` output (e.g. `"in-memory"`). */
49
+ /** Human-readable label for `describe.each` output (e.g. `"in-memory+ws"`). */
33
50
  readonly name: string;
51
+ /**
52
+ * The line-framing transport this factory binds. Drives the
53
+ * (runtime × transport) matrix and gates transport-specific scenarios
54
+ * (the TCP-framing block only runs against `transport: 'tcp'`).
55
+ */
56
+ readonly transport: TransportKind;
34
57
  /** Constructs a new harness. MUST NOT retain state across invocations. */
35
58
  create(): Promise<IrcHarness>;
36
59
  }
@@ -83,8 +106,20 @@ export interface ClientHarness {
83
106
  /**
84
107
  * Sends one IRC line as this connection. The harness frames it
85
108
  * (`line + \r\n`) and routes through the runtime's full pipeline.
109
+ * Equivalent to {@link feed} with a single complete terminated line;
110
+ * prefer `send` for ordinary one-line-per-call test setup.
86
111
  */
87
112
  send(line: string): Promise<void>;
113
+ /**
114
+ * Feeds raw transport input as this connection — one WebSocket text
115
+ * frame for the `ws` transport, one arbitrary byte chunk for the `tcp`
116
+ * transport. Use this to exercise transport-specific line framing:
117
+ * chunk boundaries, partial-line buffering, slow-loris-style partial
118
+ * sends, mixed `\r\n` / bare `\n` endings. The chunk is routed through
119
+ * the bound {@link Transport} and the resulting complete lines dispatch
120
+ * through the full pipeline.
121
+ */
122
+ feed(chunk: string): Promise<void>;
88
123
  /**
89
124
  * Resolves with the first line (already-seen or future) matching
90
125
  * `predicate`. Rejects after `timeoutMs` (default 1000) with the
@@ -24,12 +24,18 @@ import {
24
24
  StaticMotdProvider,
25
25
  createConnection,
26
26
  } from '@serverless-ircd/irc-core';
27
- import { ConnectionActor } from '@serverless-ircd/irc-server';
27
+ import {
28
+ ConnectionActor,
29
+ TcpByteStreamTransport,
30
+ type Transport,
31
+ WsTextFrameTransport,
32
+ } from '@serverless-ircd/irc-server';
28
33
  import type {
29
34
  ClientHarness,
30
35
  IrcHarness,
31
36
  IrcHarnessFactory,
32
37
  SpawnClientOptions,
38
+ TransportKind,
33
39
  } from './harness.js';
34
40
 
35
41
  /** Default tick for the polling waitFor* implementations (ms). */
@@ -64,16 +70,44 @@ export const HARNESS_MOTD_LINES = [
64
70
  ];
65
71
 
66
72
  /**
67
- * Factory entry for the in-memory runtime. Append this to a suite's
68
- * FACTORIES array to opt the scenarios into the in-memory matrix.
73
+ * Factory entry for the in-memory runtime over the WebSocket text-frame
74
+ * transport (PLAN §9, ADR-009). Append this to a suite's FACTORIES array
75
+ * to opt the scenarios into the in-memory+ws cell of the
76
+ * (runtime × transport) matrix.
77
+ */
78
+ export const inMemoryWsHarnessFactory: IrcHarnessFactory = Object.freeze({
79
+ name: 'in-memory+ws',
80
+ transport: 'ws',
81
+ async create() {
82
+ return new InMemoryHarness('ws');
83
+ },
84
+ });
85
+
86
+ /**
87
+ * Factory entry for the in-memory runtime over the raw TCP+TLS byte-stream
88
+ * transport (stateful `\r\n` framing with partial-line buffering across
89
+ * chunks — PLAN §9, ADR-009). Append this to a suite's FACTORIES array to
90
+ * opt the scenarios into the in-memory+tcp cell of the
91
+ * (runtime × transport) matrix. Lights up the TCP-framing
92
+ * scenario block (chunk boundaries, slow-loris sends, bare `\n`).
69
93
  */
70
- export const inMemoryHarnessFactory: IrcHarnessFactory = Object.freeze({
71
- name: 'in-memory',
94
+ export const inMemoryTcpHarnessFactory: IrcHarnessFactory = Object.freeze({
95
+ name: 'in-memory+tcp',
96
+ transport: 'tcp',
72
97
  async create() {
73
- return new InMemoryHarness();
98
+ return new InMemoryHarness('tcp');
74
99
  },
75
100
  });
76
101
 
102
+ /**
103
+ * Legacy alias for {@link inMemoryWsHarnessFactory}. Before the
104
+ * transport dimension was added, the in-memory runtime registered a
105
+ * single factory with no transport dimension; that factory was
106
+ * implicitly the WebSocket transport. Kept so existing adapter suites
107
+ * and tests referencing `inMemoryHarnessFactory` keep compiling unchanged.
108
+ */
109
+ export const inMemoryHarnessFactory: IrcHarnessFactory = inMemoryWsHarnessFactory;
110
+
77
111
  interface TrackedClient {
78
112
  harness: InMemoryClientHarness;
79
113
  disconnect: () => void;
@@ -85,9 +119,22 @@ export class InMemoryHarness implements IrcHarness {
85
119
  private readonly ids = new SequentialIdFactory();
86
120
  private readonly motd = new StaticMotdProvider(HARNESS_MOTD_LINES);
87
121
  private readonly clients = new Map<ConnId, TrackedClient>();
122
+ private readonly transportKind: TransportKind;
88
123
  private nextId = 0;
89
124
  private closed = false;
90
125
 
126
+ /**
127
+ * @param transportKind The line-framing transport each spawned client's
128
+ * actor binds. `'ws'` (default) uses a stateless
129
+ * {@link WsTextFrameTransport}; `'tcp'` uses a stateful
130
+ * {@link TcpByteStreamTransport} so chunk-boundary scenarios exercise
131
+ * real byte-stream framing. One transport instance per client (TCP
132
+ * transports own a per-connection partial-line buffer).
133
+ */
134
+ constructor(transportKind: TransportKind = 'ws') {
135
+ this.transportKind = transportKind;
136
+ }
137
+
91
138
  async spawnClient(opts?: SpawnClientOptions): Promise<ClientHarness> {
92
139
  if (this.closed) throw new Error('spawnClient called on a closed harness');
93
140
  const id = opts?.id ?? `c${this.nextId++}`;
@@ -119,6 +166,7 @@ export class InMemoryHarness implements IrcHarness {
119
166
  clock: this.clock,
120
167
  ids: this.ids,
121
168
  motd: this.motd,
169
+ transport: this.newTransport(),
122
170
  });
123
171
  client.bindActor(actor);
124
172
 
@@ -136,6 +184,15 @@ export class InMemoryHarness implements IrcHarness {
136
184
  return client;
137
185
  }
138
186
 
187
+ /**
188
+ * Allocates a fresh transport instance for one client. TCP transports
189
+ * carry per-connection partial-line state, so each client MUST receive
190
+ * its own instance rather than a shared one.
191
+ */
192
+ private newTransport(): Transport {
193
+ return this.transportKind === 'tcp' ? new TcpByteStreamTransport() : new WsTextFrameTransport();
194
+ }
195
+
139
196
  async clientByNick(nick: string): Promise<ClientHarness | undefined> {
140
197
  const connId = await this.runtime.lookupNick(nick);
141
198
  if (connId === null) return undefined;
@@ -173,13 +230,20 @@ class InMemoryClientHarness implements ClientHarness {
173
230
  }
174
231
 
175
232
  async send(line: string): Promise<void> {
233
+ // `send` is `feed` with one complete terminated line; routing both
234
+ // through the bound transport guarantees a transport change lights up
235
+ // identically across the scenario suite.
236
+ await this.feed(`${line}\r\n`);
237
+ }
238
+
239
+ async feed(chunk: string): Promise<void> {
176
240
  /* istanbul ignore next -- defensive: bindActor is called by the
177
241
  harness before the client is returned, so the unbound-actor branch
178
242
  is unreachable through the public API. */
179
243
  if (this.actor === undefined) {
180
244
  throw new Error(`client ${this.id} has no bound actor`);
181
245
  }
182
- await this.actor.receiveTextFrame(`${line}\r\n`);
246
+ await this.actor.receive(chunk);
183
247
  }
184
248
 
185
249
  async waitForLine(
@@ -217,8 +281,8 @@ class InMemoryClientHarness implements ClientHarness {
217
281
  // disconnect handler is wired by the harness to unregister.
218
282
  if (this.actor !== undefined) {
219
283
  try {
220
- await this.actor.receiveTextFrame('QUIT :client closed\r\n');
221
- /* istanbul ignore next -- defensive: receiveTextFrame swallows
284
+ await this.actor.receive('QUIT :client closed\r\n');
285
+ /* istanbul ignore next -- defensive: receive swallows
222
286
  every error internally; the catch is purely a teardown safety
223
287
  net for catastrophic runtime failures. */
224
288
  } catch {
@@ -14,11 +14,14 @@ export type {
14
14
  IrcHarness,
15
15
  IrcHarnessFactory,
16
16
  SpawnClientOptions,
17
+ TransportKind,
17
18
  } from './harness.js';
18
19
  export {
19
20
  HARNESS_MOTD_LINES,
20
21
  HARNESS_SERVER_CONFIG,
21
22
  InMemoryHarness,
22
23
  inMemoryHarnessFactory,
24
+ inMemoryTcpHarnessFactory,
25
+ inMemoryWsHarnessFactory,
23
26
  } from './in-memory-harness.js';
24
27
  export { runIrcScenarios } from './scenarios.js';