serverless-ircd 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +174 -18
  8. package/README.md +63 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +20 -3
  38. package/package.json +14 -10
  39. package/packages/aws-adapter/package.json +3 -3
  40. package/packages/aws-adapter/src/account-store.ts +1 -1
  41. package/packages/aws-adapter/src/admission.ts +74 -0
  42. package/packages/aws-adapter/src/aws-runtime.ts +6 -4
  43. package/packages/aws-adapter/src/config-loader.ts +32 -0
  44. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  45. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  46. package/packages/aws-adapter/src/handlers/default.ts +35 -2
  47. package/packages/aws-adapter/src/handlers/index.ts +69 -3
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  49. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  50. package/packages/aws-adapter/src/index.ts +6 -0
  51. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  52. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  53. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  54. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  55. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  56. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  57. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  58. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  59. package/packages/aws-adapter/tests/handlers.test.ts +154 -53
  60. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  62. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  63. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  64. package/packages/cf-adapter/package.json +1 -1
  65. package/packages/cf-adapter/src/cf-runtime.ts +2 -4
  66. package/packages/cf-adapter/src/config-loader.ts +33 -0
  67. package/packages/cf-adapter/src/connection-do.ts +111 -46
  68. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  69. package/packages/cf-adapter/src/env.ts +33 -9
  70. package/packages/cf-adapter/src/index.ts +9 -8
  71. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  72. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  73. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  74. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  75. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  76. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  78. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  79. package/packages/cf-adapter/tests/worker/main.ts +8 -1
  80. package/packages/cf-adapter/wrangler.test.toml +8 -0
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +6 -1
  83. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  84. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  85. package/packages/irc-core/src/cloak.ts +1 -1
  86. package/packages/irc-core/src/commands/cap.ts +8 -1
  87. package/packages/irc-core/src/commands/index.ts +9 -0
  88. package/packages/irc-core/src/commands/ison.ts +61 -0
  89. package/packages/irc-core/src/commands/quit.ts +12 -0
  90. package/packages/irc-core/src/commands/registration.ts +18 -12
  91. package/packages/irc-core/src/commands/sasl.ts +72 -9
  92. package/packages/irc-core/src/commands/server-info.ts +129 -0
  93. package/packages/irc-core/src/commands/userhost.ts +84 -0
  94. package/packages/irc-core/src/commands/whowas.ts +113 -0
  95. package/packages/irc-core/src/config.ts +58 -0
  96. package/packages/irc-core/src/credential-hashing.ts +124 -0
  97. package/packages/irc-core/src/effects.ts +6 -29
  98. package/packages/irc-core/src/index.ts +1 -0
  99. package/packages/irc-core/src/ports.ts +181 -12
  100. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  101. package/packages/irc-core/src/types.ts +38 -1
  102. package/packages/irc-core/tests/account-store.test.ts +45 -2
  103. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  104. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  105. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  106. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  107. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  108. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  109. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  111. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  112. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  113. package/packages/irc-core/tests/config.test.ts +95 -1
  114. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  115. package/packages/irc-core/tests/effects.test.ts +0 -27
  116. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  117. package/packages/irc-core/tests/numerics.test.ts +12 -0
  118. package/packages/irc-core/tests/types.test.ts +35 -1
  119. package/packages/irc-core/tsconfig.build.json +1 -1
  120. package/packages/irc-core/tsconfig.test.json +1 -1
  121. package/packages/irc-server/package.json +1 -1
  122. package/packages/irc-server/src/actor.ts +158 -14
  123. package/packages/irc-server/src/dispatch.ts +0 -3
  124. package/packages/irc-server/src/index.ts +10 -2
  125. package/packages/irc-server/src/routing.ts +12 -0
  126. package/packages/irc-server/src/transport.ts +101 -0
  127. package/packages/irc-server/tests/actor.test.ts +400 -3
  128. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  129. package/packages/irc-server/tests/routing.test.ts +4 -0
  130. package/packages/irc-server/tests/transport.test.ts +230 -0
  131. package/packages/irc-test-support/package.json +1 -1
  132. package/packages/irc-test-support/src/harness.ts +44 -9
  133. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  134. package/packages/irc-test-support/src/index.ts +3 -0
  135. package/packages/irc-test-support/src/scenarios.ts +132 -2
  136. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  137. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  138. package/pnpm-workspace.yaml +9 -1
  139. package/tools/ci-hardening/package.json +1 -1
  140. package/tools/package.json +4 -0
  141. package/tools/seed-aws-accounts.ts +5 -6
  142. package/tools/seed-cf-accounts.ts +104 -0
  143. package/tools/tcp-ws-forwarder/package.json +1 -1
  144. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  145. package/docs/ADR-002-location-of-authority.md +0 -82
  146. package/docs/ADR-003-durable-object-sharding.md +0 -93
  147. package/docs/ADR-004-dynamodb-schema.md +0 -96
  148. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  149. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  150. package/docs/ADR-007-deterministic-ports.md +0 -82
  151. package/docs/ADR-008-monorepo-tooling.md +0 -60
  152. package/docs/AWS-Adapter-Architecture.md +0 -496
  153. package/docs/AWS-Deployment.md +0 -1186
  154. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  155. package/docs/Home.md +0 -11
  156. package/docs/Observability.md +0 -87
  157. package/docs/PlanIRCv3Websocket.md +0 -489
  158. package/docs/PlanWebClient.md +0 -451
  159. package/docs/Release-Process.md +0 -443
@@ -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.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). */
@@ -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';
@@ -1,13 +1,22 @@
1
1
  /**
2
2
  * Reusable IRC scenario runner — exported so adapter packages (cf-adapter,
3
3
  * aws-stack) can register their own {@link IrcHarnessFactory} and re-run
4
- * the exact same scenario suite under their native runtime.
4
+ * the exact same scenario suite under their native runtime AND transport.
5
5
  *
6
6
  * Each scenario is written once against the {@link IrcHarness} seam. The
7
7
  * runner takes a list of factories and produces one `describe` block per
8
8
  * factory, mirroring the parametrized matrix called for in PLAN §8
9
9
  * ("Runtime contract: vitest parametrized over IrcRuntime. Same scenarios
10
- * pass in-memory + CF + AWS.").
10
+ * pass in-memory + CF + AWS.") extended to cover the transport dimension
11
+ * ("Same scenarios pass in-memory+WS, in-memory+TCP, CF+WS, CF+TCP,
12
+ * AWS+WS, AWS+TCP.").
13
+ *
14
+ * The general scenario block runs against every factory regardless of
15
+ * transport. The TCP-framing block (chunk boundaries, slow-loris partial
16
+ * sends, mixed line endings) runs ONLY against factories declaring
17
+ * `transport: 'tcp'` — those cases are meaningless (and behave
18
+ * differently) under the WebSocket text-frame transport where every
19
+ * `feed()` is a complete unit.
11
20
  */
12
21
 
13
22
  import { describe, expect, it } from 'vitest';
@@ -491,5 +500,126 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
491
500
  await harness.close();
492
501
  });
493
502
  });
503
+
504
+ // -------------------------------------------------------------------------
505
+ // TCP-framing scenarios.
506
+ //
507
+ // These cases exercise the stateful `\r\n` byte-stream transport
508
+ // (PLAN §9, ADR-009): chunk boundaries, partial-line buffering across
509
+ // chunks, slow-loris-style one-byte sends, bare `\n` tolerance, and
510
+ // multi-message chunks. They are meaningless under the WebSocket
511
+ // text-frame transport (where every feed() is a complete unit), so
512
+ // they only run against factories declaring `transport: 'tcp'`.
513
+ // -------------------------------------------------------------------------
514
+ if (factory.transport === 'tcp') {
515
+ describe(`TCP framing — ${factory.name}`, () => {
516
+ let harness: IrcHarness;
517
+
518
+ it('tcp 01: message split across two chunks produces one command', async () => {
519
+ harness = await factory.create();
520
+ const c = await harness.spawnClient({ nick: 'alice' });
521
+ await c.waitForNumeric('001');
522
+ c.received.length = 0;
523
+ // Feed the first half — incomplete, must buffer without dispatching.
524
+ await c.feed('PI');
525
+ // Nothing has terminated yet: no PONG, no 421.
526
+ expect(c.received.length).toBe(0);
527
+ await c.feed('NG :split\r\n');
528
+ await c.waitForLine((l) => l === 'PONG :split');
529
+ await harness.close();
530
+ });
531
+
532
+ it('tcp 02: partial line buffered across many one-byte chunks (slow-loris style)', async () => {
533
+ harness = await factory.create();
534
+ const c = await harness.spawnClient({ nick: 'alice' });
535
+ await c.waitForNumeric('001');
536
+ c.received.length = 0;
537
+ const line = 'PING :slow';
538
+ for (const ch of line) {
539
+ await c.feed(ch);
540
+ // No terminator yet — nothing dispatches.
541
+ expect(c.received.length).toBe(0);
542
+ }
543
+ await c.feed('\r\n');
544
+ await c.waitForLine((l) => l === 'PONG :slow');
545
+ await harness.close();
546
+ });
547
+
548
+ it('tcp 03: bare LF line ending is tolerated (legacy/mixed clients)', async () => {
549
+ harness = await factory.create();
550
+ const c = await harness.spawnClient({ nick: 'alice' });
551
+ await c.waitForNumeric('001');
552
+ c.received.length = 0;
553
+ await c.feed('PING :bare\n');
554
+ await c.waitForLine((l) => l === 'PONG :bare');
555
+ await harness.close();
556
+ });
557
+
558
+ it('tcp 04: multiple CRLF-terminated messages in one chunk dispatch in order', async () => {
559
+ harness = await factory.create();
560
+ const c = await harness.spawnClient({ nick: 'alice' });
561
+ await c.waitForNumeric('001');
562
+ c.received.length = 0;
563
+ await c.feed('PING :one\r\nPING :two\r\nPING :three\r\n');
564
+ await c.waitForLine((l) => l === 'PONG :three');
565
+ const pongs = c.received.filter((l) => l.startsWith('PONG :'));
566
+ expect(pongs).toEqual(['PONG :one', 'PONG :two', 'PONG :three']);
567
+ await harness.close();
568
+ });
569
+
570
+ it('tcp 05: mixed CRLF and LF terminators in one chunk both frame', async () => {
571
+ harness = await factory.create();
572
+ const c = await harness.spawnClient({ nick: 'alice' });
573
+ await c.waitForNumeric('001');
574
+ c.received.length = 0;
575
+ await c.feed('PING :crlf\r\nPING :lf\n');
576
+ await c.waitForLine((l) => l === 'PONG :lf');
577
+ const toks = c.received
578
+ .filter((l) => l.startsWith('PONG :'))
579
+ .map((l) => l.slice('PONG :'.length));
580
+ expect(toks).toEqual(['crlf', 'lf']);
581
+ await harness.close();
582
+ });
583
+
584
+ it('tcp 06: CRLF terminator split across chunks (CR in one, LF in next)', async () => {
585
+ harness = await factory.create();
586
+ const c = await harness.spawnClient({ nick: 'alice' });
587
+ await c.waitForNumeric('001');
588
+ c.received.length = 0;
589
+ await c.feed('PING :cr\r');
590
+ expect(c.received.length).toBe(0);
591
+ await c.feed('\n');
592
+ await c.waitForLine((l) => l === 'PONG :cr');
593
+ await harness.close();
594
+ });
595
+
596
+ it('tcp 07: an empty chunk is a no-op (no dispatch, no crash)', async () => {
597
+ harness = await factory.create();
598
+ const c = await harness.spawnClient({ nick: 'alice' });
599
+ await c.waitForNumeric('001');
600
+ c.received.length = 0;
601
+ await c.feed('');
602
+ expect(c.received.length).toBe(0);
603
+ // The connection is still usable after the empty chunk.
604
+ await c.feed('PING :after-empty\r\n');
605
+ await c.waitForLine((l) => l === 'PONG :after-empty');
606
+ await harness.close();
607
+ });
608
+
609
+ it('tcp 08: a complete line followed by a partial line buffers the tail', async () => {
610
+ harness = await factory.create();
611
+ const c = await harness.spawnClient({ nick: 'alice' });
612
+ await c.waitForNumeric('001');
613
+ c.received.length = 0;
614
+ await c.feed('PING :first\r\nPI');
615
+ await c.waitForLine((l) => l === 'PONG :first');
616
+ // The trailing partial 'PI' must not have dispatched yet.
617
+ expect(c.received.some((l) => l === 'PONG :')).toBe(false);
618
+ await c.feed('NG :second\r\n');
619
+ await c.waitForLine((l) => l === 'PONG :second');
620
+ await harness.close();
621
+ });
622
+ });
623
+ }
494
624
  }
495
625
  }
@@ -12,7 +12,8 @@ import { InMemoryHarness, inMemoryHarnessFactory } from '../src/index.js';
12
12
 
13
13
  describe('inMemoryHarnessFactory', () => {
14
14
  it('exposes a stable name for the describe.each matrix', () => {
15
- expect(inMemoryHarnessFactory.name).toBe('in-memory');
15
+ expect(inMemoryHarnessFactory.name).toBe('in-memory+ws');
16
+ expect(inMemoryHarnessFactory.transport).toBe('ws');
16
17
  });
17
18
 
18
19
  it('create() returns a fresh, independent InMemoryHarness each call', async () => {