serverless-ircd 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +258 -18
  8. package/README.md +72 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  34. package/apps/cf-worker/src/worker.ts +8 -2
  35. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  36. package/apps/cf-worker/wrangler.test.toml +11 -1
  37. package/apps/cf-worker/wrangler.toml +69 -19
  38. package/apps/local-cli/package.json +1 -1
  39. package/apps/local-cli/src/config-loader.ts +11 -0
  40. package/apps/local-cli/src/main.ts +1 -1
  41. package/apps/local-cli/src/server.ts +46 -3
  42. package/apps/local-cli/tests/e2e.test.ts +52 -0
  43. package/package.json +19 -16
  44. package/packages/aws-adapter/package.json +3 -3
  45. package/packages/aws-adapter/src/account-store.ts +121 -0
  46. package/packages/aws-adapter/src/admission.ts +74 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +124 -34
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  49. package/packages/aws-adapter/src/config-loader.ts +62 -0
  50. package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  52. package/packages/aws-adapter/src/handlers/default.ts +43 -2
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  54. package/packages/aws-adapter/src/handlers/index.ts +120 -9
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  56. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  57. package/packages/aws-adapter/src/index.ts +13 -0
  58. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
  59. package/packages/aws-adapter/tests/account-store.test.ts +279 -0
  60. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  61. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  62. package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
  63. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
  65. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  66. package/packages/aws-adapter/tests/handlers.test.ts +194 -47
  67. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  68. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +1 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +40 -22
  73. package/packages/cf-adapter/src/channel-do.ts +40 -1
  74. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  75. package/packages/cf-adapter/src/config-loader.ts +62 -0
  76. package/packages/cf-adapter/src/connection-do.ts +181 -31
  77. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  78. package/packages/cf-adapter/src/env.ts +58 -8
  79. package/packages/cf-adapter/src/index.ts +11 -9
  80. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  81. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  82. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  83. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  84. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  85. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  86. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  87. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  88. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  89. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +9 -1
  91. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  92. package/packages/cf-adapter/wrangler.test.toml +18 -1
  93. package/packages/in-memory-runtime/package.json +1 -1
  94. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  95. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  96. package/packages/irc-core/package.json +8 -2
  97. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  98. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  99. package/packages/irc-core/src/case-fold.ts +64 -0
  100. package/packages/irc-core/src/cloak.ts +1 -1
  101. package/packages/irc-core/src/commands/cap.ts +8 -1
  102. package/packages/irc-core/src/commands/index.ts +11 -0
  103. package/packages/irc-core/src/commands/invite.ts +6 -9
  104. package/packages/irc-core/src/commands/ison.ts +61 -0
  105. package/packages/irc-core/src/commands/isupport.ts +1 -1
  106. package/packages/irc-core/src/commands/join.ts +4 -3
  107. package/packages/irc-core/src/commands/kick.ts +4 -11
  108. package/packages/irc-core/src/commands/list.ts +5 -4
  109. package/packages/irc-core/src/commands/mode.ts +5 -9
  110. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  111. package/packages/irc-core/src/commands/motd.ts +6 -110
  112. package/packages/irc-core/src/commands/oper.ts +151 -0
  113. package/packages/irc-core/src/commands/quit.ts +12 -0
  114. package/packages/irc-core/src/commands/registration.ts +26 -19
  115. package/packages/irc-core/src/commands/sasl.ts +72 -9
  116. package/packages/irc-core/src/commands/server-info.ts +129 -0
  117. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  118. package/packages/irc-core/src/commands/userhost.ts +84 -0
  119. package/packages/irc-core/src/commands/whowas.ts +113 -0
  120. package/packages/irc-core/src/config.ts +84 -3
  121. package/packages/irc-core/src/credential-hashing.ts +124 -0
  122. package/packages/irc-core/src/effects.ts +6 -29
  123. package/packages/irc-core/src/index.ts +3 -0
  124. package/packages/irc-core/src/ports.ts +240 -7
  125. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  126. package/packages/irc-core/src/types.ts +60 -1
  127. package/packages/irc-core/tests/account-store.test.ts +131 -0
  128. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  129. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  130. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  131. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  132. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  133. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  134. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  135. package/packages/irc-core/tests/commands/registration.test.ts +256 -19
  136. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  137. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  138. package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
  139. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  140. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  141. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  142. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  143. package/packages/irc-core/tests/config.test.ts +170 -1
  144. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  145. package/packages/irc-core/tests/effects.test.ts +0 -27
  146. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  147. package/packages/irc-core/tests/numerics.test.ts +12 -0
  148. package/packages/irc-core/tests/types.test.ts +35 -1
  149. package/packages/irc-core/tsconfig.build.json +1 -1
  150. package/packages/irc-core/tsconfig.test.json +1 -1
  151. package/packages/irc-server/package.json +1 -1
  152. package/packages/irc-server/src/actor.ts +182 -14
  153. package/packages/irc-server/src/dispatch.ts +0 -3
  154. package/packages/irc-server/src/index.ts +10 -2
  155. package/packages/irc-server/src/routing.ts +21 -0
  156. package/packages/irc-server/src/transport.ts +101 -0
  157. package/packages/irc-server/tests/actor.test.ts +617 -1
  158. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  159. package/packages/irc-server/tests/routing.test.ts +7 -0
  160. package/packages/irc-server/tests/transport.test.ts +230 -0
  161. package/packages/irc-test-support/package.json +1 -1
  162. package/packages/irc-test-support/src/harness.ts +44 -9
  163. package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
  164. package/packages/irc-test-support/src/index.ts +3 -0
  165. package/packages/irc-test-support/src/scenarios.ts +132 -2
  166. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  167. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  168. package/pnpm-workspace.yaml +9 -0
  169. package/tools/ci-hardening/package.json +1 -1
  170. package/tools/package.json +4 -0
  171. package/tools/seed-aws-accounts.ts +79 -0
  172. package/tools/seed-cf-accounts.ts +104 -0
  173. package/tools/tcp-ws-forwarder/package.json +1 -1
  174. package/AGENTS.md +0 -5
  175. package/MOTD.txt +0 -3
  176. package/PLAN-FIXES.md +0 -358
  177. package/PLAN.md +0 -420
  178. package/dashboards/cloudwatch-irc.json +0 -139
  179. package/docs/AWS-Adapter-Architecture.md +0 -494
  180. package/docs/AWS-Deployment.md +0 -1107
  181. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  182. package/docs/Home.md +0 -1
  183. package/docs/Observability.md +0 -87
  184. package/docs/PlanIRCv3Websocket.md +0 -489
  185. package/docs/PlanWebClient.md +0 -451
  186. package/docs/Release-Process.md +0 -440
  187. package/progress.md +0 -107
  188. package/tickets.md +0 -2485
  189. package/webircgateway/LICENSE +0 -201
  190. package/webircgateway/Makefile +0 -44
  191. package/webircgateway/README.md +0 -134
  192. package/webircgateway/config.conf.example +0 -135
  193. package/webircgateway/go.mod +0 -16
  194. package/webircgateway/go.sum +0 -89
  195. package/webircgateway/main.go +0 -118
  196. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  197. package/webircgateway/pkg/identd/identd.go +0 -86
  198. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  199. package/webircgateway/pkg/irc/isupport.go +0 -56
  200. package/webircgateway/pkg/irc/message.go +0 -217
  201. package/webircgateway/pkg/irc/state.go +0 -79
  202. package/webircgateway/pkg/proxy/proxy.go +0 -129
  203. package/webircgateway/pkg/proxy/server.go +0 -237
  204. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  205. package/webircgateway/pkg/webircgateway/client.go +0 -741
  206. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  207. package/webircgateway/pkg/webircgateway/config.go +0 -385
  208. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  209. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  210. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  211. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  212. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  213. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  214. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  215. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  216. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  217. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  218. package/webircgateway/plugins/example/plugin.go +0 -11
  219. package/webircgateway/plugins/stats/plugin.go +0 -52
  220. package/webircgateway/staticcheck.conf +0 -1
  221. package/webircgateway/webircgateway.svg +0 -3
@@ -146,23 +146,6 @@ describe('dispatch', () => {
146
146
  expect(calls[0]).toEqual({ method: 'applyChannelDelta', args: ['#foo', delta] });
147
147
  });
148
148
 
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
149
  it('awaits each effect in sequence (one fails, the rest after it do not run)', async () => {
167
150
  const calls: RecordedCall[] = [];
168
151
  const failing: IrcRuntime = {
@@ -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',
@@ -121,6 +123,7 @@ describe('buildRoutedReducers', () => {
121
123
  'nick',
122
124
  'noticeChannel',
123
125
  'noticeUser',
126
+ 'oper',
124
127
  'part',
125
128
  'pass',
126
129
  'ping',
@@ -128,9 +131,13 @@ describe('buildRoutedReducers', () => {
128
131
  'privmsgChannel',
129
132
  'privmsgUser',
130
133
  'quit',
134
+ 'tagmsgChannel',
135
+ 'tagmsgUser',
136
+ 'time',
131
137
  'topic',
132
138
  'user',
133
139
  'userMode',
140
+ 'version',
134
141
  ].sort(),
135
142
  );
136
143
  });
@@ -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.2.0",
3
+ "version": "0.4.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). */
@@ -52,10 +58,11 @@ export const HARNESS_SERVER_CONFIG = {
52
58
  } as const;
53
59
 
54
60
  /**
55
- * Default MOTD lines exposed to every spawned client. The welcome block
56
- * always emits the placeholder 375/372/376 numerics; an explicit `MOTD`
57
- * command after registration reads these lines via the {@link MotdProvider}
58
- * port. Tests assert against this content (scenario: MOTD).
61
+ * Default MOTD lines exposed to every spawned client. The registration
62
+ * welcome block renders these lines as `375`/`372`×N/`376` numerics, and
63
+ * an explicit `MOTD` command after registration reads them again via the
64
+ * {@link MotdProvider} port. Tests assert against this content
65
+ * (scenario: MOTD).
59
66
  */
60
67
  export const HARNESS_MOTD_LINES = [
61
68
  'Welcome to the ServerlessIRCd integration-test harness.',
@@ -63,16 +70,44 @@ export const HARNESS_MOTD_LINES = [
63
70
  ];
64
71
 
65
72
  /**
66
- * Factory entry for the in-memory runtime. Append this to a suite's
67
- * 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`).
68
93
  */
69
- export const inMemoryHarnessFactory: IrcHarnessFactory = Object.freeze({
70
- name: 'in-memory',
94
+ export const inMemoryTcpHarnessFactory: IrcHarnessFactory = Object.freeze({
95
+ name: 'in-memory+tcp',
96
+ transport: 'tcp',
71
97
  async create() {
72
- return new InMemoryHarness();
98
+ return new InMemoryHarness('tcp');
73
99
  },
74
100
  });
75
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
+
76
111
  interface TrackedClient {
77
112
  harness: InMemoryClientHarness;
78
113
  disconnect: () => void;
@@ -84,9 +119,22 @@ export class InMemoryHarness implements IrcHarness {
84
119
  private readonly ids = new SequentialIdFactory();
85
120
  private readonly motd = new StaticMotdProvider(HARNESS_MOTD_LINES);
86
121
  private readonly clients = new Map<ConnId, TrackedClient>();
122
+ private readonly transportKind: TransportKind;
87
123
  private nextId = 0;
88
124
  private closed = false;
89
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
+
90
138
  async spawnClient(opts?: SpawnClientOptions): Promise<ClientHarness> {
91
139
  if (this.closed) throw new Error('spawnClient called on a closed harness');
92
140
  const id = opts?.id ?? `c${this.nextId++}`;
@@ -118,6 +166,7 @@ export class InMemoryHarness implements IrcHarness {
118
166
  clock: this.clock,
119
167
  ids: this.ids,
120
168
  motd: this.motd,
169
+ transport: this.newTransport(),
121
170
  });
122
171
  client.bindActor(actor);
123
172
 
@@ -135,6 +184,15 @@ export class InMemoryHarness implements IrcHarness {
135
184
  return client;
136
185
  }
137
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
+
138
196
  async clientByNick(nick: string): Promise<ClientHarness | undefined> {
139
197
  const connId = await this.runtime.lookupNick(nick);
140
198
  if (connId === null) return undefined;
@@ -172,13 +230,20 @@ class InMemoryClientHarness implements ClientHarness {
172
230
  }
173
231
 
174
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> {
175
240
  /* istanbul ignore next -- defensive: bindActor is called by the
176
241
  harness before the client is returned, so the unbound-actor branch
177
242
  is unreachable through the public API. */
178
243
  if (this.actor === undefined) {
179
244
  throw new Error(`client ${this.id} has no bound actor`);
180
245
  }
181
- await this.actor.receiveTextFrame(`${line}\r\n`);
246
+ await this.actor.receive(chunk);
182
247
  }
183
248
 
184
249
  async waitForLine(
@@ -216,8 +281,8 @@ class InMemoryClientHarness implements ClientHarness {
216
281
  // disconnect handler is wired by the harness to unregister.
217
282
  if (this.actor !== undefined) {
218
283
  try {
219
- await this.actor.receiveTextFrame('QUIT :client closed\r\n');
220
- /* istanbul ignore next -- defensive: receiveTextFrame swallows
284
+ await this.actor.receive('QUIT :client closed\r\n');
285
+ /* istanbul ignore next -- defensive: receive swallows
221
286
  every error internally; the catch is purely a teardown safety
222
287
  net for catastrophic runtime failures. */
223
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';