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
@@ -0,0 +1,465 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { type Socket, createConnection } from 'node:net';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
7
+ import { type ContainerServer, startContainerServer } from '../src/container-server.js';
8
+
9
+ /**
10
+ * Minimal IRC-over-TCP test client. Writes lines with CRLF, parses inbound
11
+ * bytes into IRC lines (tolerant of CRLF and bare LF).
12
+ */
13
+ class TcpClient extends EventEmitter {
14
+ readonly socket: Socket;
15
+ readonly received: string[] = [];
16
+
17
+ constructor(host: string, port: number) {
18
+ super();
19
+ this.socket = createConnection({ host, port });
20
+ this.socket.setEncoding('utf8');
21
+ this.socket.on('data', (text: string) => {
22
+ for (const line of text.split(/\r?\n/u)) {
23
+ if (line.length > 0) {
24
+ this.received.push(line);
25
+ this.emit('line', line);
26
+ }
27
+ }
28
+ });
29
+ }
30
+
31
+ opened(): Promise<void> {
32
+ return new Promise((resolve, reject) => {
33
+ if (this.socket.readyState === 'open') resolve();
34
+ else {
35
+ this.socket.once('connect', () => resolve());
36
+ this.socket.once('error', reject);
37
+ }
38
+ });
39
+ }
40
+
41
+ send(line: string): Promise<void> {
42
+ return new Promise((resolve, reject) => {
43
+ this.socket.write(`${line}\r\n`, (err) => (err ? reject(err) : resolve()));
44
+ });
45
+ }
46
+
47
+ writeRaw(chunk: string): Promise<void> {
48
+ return new Promise((resolve, reject) => {
49
+ this.socket.write(chunk, 'utf8', (err) => (err ? reject(err) : resolve()));
50
+ });
51
+ }
52
+
53
+ waitFor(predicate: (line: string) => boolean, timeoutMs = 2000): Promise<string> {
54
+ return new Promise((resolve, reject) => {
55
+ const timeout = setTimeout(() => {
56
+ reject(new Error(`timeout; received: ${JSON.stringify(this.received)}`));
57
+ }, timeoutMs);
58
+ const check = (line: string): void => {
59
+ if (predicate(line)) {
60
+ clearTimeout(timeout);
61
+ this.off('line', check);
62
+ resolve(line);
63
+ }
64
+ };
65
+ for (const line of this.received) {
66
+ if (predicate(line)) {
67
+ clearTimeout(timeout);
68
+ resolve(line);
69
+ return;
70
+ }
71
+ }
72
+ this.on('line', check);
73
+ });
74
+ }
75
+
76
+ close(): Promise<void> {
77
+ return new Promise((resolve) => {
78
+ this.socket.once('close', () => resolve());
79
+ this.socket.end();
80
+ });
81
+ }
82
+
83
+ destroy(): void {
84
+ this.socket.destroy();
85
+ }
86
+ }
87
+
88
+ describe('container-server — lifecycle', () => {
89
+ it('starts and exposes the bound port', async () => {
90
+ const srv = await startContainerServer({
91
+ port: 0,
92
+ host: '127.0.0.1',
93
+ serverName: 'irc.test',
94
+ networkName: 'TestNet',
95
+ });
96
+ expect(srv.port).toBeGreaterThan(0);
97
+ expect(srv.host).toBe('127.0.0.1');
98
+ await srv.close();
99
+ });
100
+
101
+ it('close() is idempotent', async () => {
102
+ const srv = await startContainerServer({
103
+ port: 0,
104
+ host: '127.0.0.1',
105
+ serverName: 'irc.test',
106
+ networkName: 'TestNet',
107
+ });
108
+ await srv.close();
109
+ await expect(srv.close()).resolves.toBeUndefined();
110
+ });
111
+
112
+ it('rejects when the port is already in use', async () => {
113
+ const first = await startContainerServer({
114
+ port: 0,
115
+ host: '127.0.0.1',
116
+ serverName: 'irc.test',
117
+ networkName: 'TestNet',
118
+ });
119
+ await expect(
120
+ startContainerServer({
121
+ port: first.port,
122
+ host: '127.0.0.1',
123
+ serverName: 'irc.test',
124
+ networkName: 'TestNet',
125
+ }),
126
+ ).rejects.toThrow();
127
+ await first.close();
128
+ });
129
+ });
130
+
131
+ describe('container-server — IRC flows over TCP', () => {
132
+ let server: ContainerServer;
133
+
134
+ beforeAll(async () => {
135
+ server = await startContainerServer({
136
+ port: 0,
137
+ host: '127.0.0.1',
138
+ serverName: 'irc.example.com',
139
+ networkName: 'ExampleNet',
140
+ });
141
+ });
142
+
143
+ afterAll(async () => {
144
+ await server.close();
145
+ });
146
+
147
+ it('completes register → join → privmsg → quit', async () => {
148
+ const alice = new TcpClient('127.0.0.1', server.port);
149
+ const bob = new TcpClient('127.0.0.1', server.port);
150
+ await alice.opened();
151
+ await bob.opened();
152
+
153
+ await alice.send('NICK alice');
154
+ await alice.send('USER alice 0 * :Alice');
155
+ await alice.waitFor((l) => l.startsWith(':irc.example.com 001 '));
156
+
157
+ await bob.send('NICK bob');
158
+ await bob.send('USER bob 0 * :Bob');
159
+ await bob.waitFor((l) => l.startsWith(':irc.example.com 001 '));
160
+
161
+ await alice.send('JOIN #container');
162
+ await alice.waitFor((l) => l.includes('JOIN #container'));
163
+ await bob.send('JOIN #container');
164
+ await bob.waitFor((l) => l.includes('JOIN #container'));
165
+
166
+ await alice.send('PRIVMSG #container :hello from tcp origin');
167
+ await bob.waitFor((l) => l.includes('PRIVMSG #container :hello from tcp origin'));
168
+
169
+ await alice.close();
170
+ await bob.close();
171
+ });
172
+
173
+ it('routes PING → PONG', async () => {
174
+ const client = new TcpClient('127.0.0.1', server.port);
175
+ await client.opened();
176
+ await client.send('NICK pinger');
177
+ await client.send('USER pinger 0 * :Pinger');
178
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
179
+
180
+ await client.send('PING :tok-123');
181
+ await client.waitFor((l) => l === 'PONG :tok-123');
182
+ await client.close();
183
+ });
184
+
185
+ it('reassembles a single IRC message split across two TCP chunks', async () => {
186
+ const client = new TcpClient('127.0.0.1', server.port);
187
+ await client.opened();
188
+ await client.send('NICK splitter');
189
+ await client.send('USER splitter 0 * :Splitter');
190
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
191
+
192
+ await client.writeRaw('PING :split');
193
+ await client.writeRaw('ted-tok\r\n');
194
+ await client.waitFor((l) => l === 'PONG :splitted-tok');
195
+ await client.close();
196
+ });
197
+
198
+ it('processes multiple IRC lines delivered in a single TCP chunk', async () => {
199
+ const client = new TcpClient('127.0.0.1', server.port);
200
+ await client.opened();
201
+ await client.writeRaw('NICK bundler\r\nUSER bundler 0 * :Bundler\r\n');
202
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 bundler'));
203
+ await client.close();
204
+ });
205
+
206
+ it('cleans up the runtime connection on abrupt socket termination', async () => {
207
+ const client = new TcpClient('127.0.0.1', server.port);
208
+ await client.opened();
209
+ await client.send('NICK termUser');
210
+ await client.send('USER termUser 0 * :Term');
211
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
212
+
213
+ const connId = await server.runtime.lookupNick('termUser');
214
+ expect(connId).not.toBeNull();
215
+ expect(server.runtime.hasConnection(connId as string)).toBe(true);
216
+
217
+ client.destroy();
218
+ await new Promise((r) => setTimeout(r, 100));
219
+ expect(server.runtime.hasConnection(connId as string)).toBe(false);
220
+ });
221
+
222
+ it('delivers an ERROR notice when a reducer supplies a disconnect reason', async () => {
223
+ const client = new TcpClient('127.0.0.1', server.port);
224
+ await client.opened();
225
+ await client.send('NICK floodUser');
226
+ await client.send('USER floodUser 0 * :Flood');
227
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
228
+
229
+ const connId = await server.runtime.lookupNick('floodUser');
230
+ expect(connId).not.toBeNull();
231
+ const wait = client.waitFor((l) => l.startsWith('ERROR :Closing link:'));
232
+ await server.runtime.disconnect(connId as string, 'Excess Flood');
233
+ await expect(wait).resolves.toContain('Excess Flood');
234
+ await client.close();
235
+ });
236
+ });
237
+
238
+ describe('container-server — persistence across restart', () => {
239
+ let dir: string;
240
+
241
+ beforeAll(async () => {
242
+ dir = await mkdtemp(join(tmpdir(), 'sirc-container-'));
243
+ });
244
+
245
+ afterAll(async () => {
246
+ await rm(dir, { recursive: true, force: true });
247
+ });
248
+
249
+ it('channel state survives a restart via snapshot file', async () => {
250
+ const statePath = join(dir, 'restart.json');
251
+
252
+ // Boot #1: register, join, set topic + modes, then close (snapshot).
253
+ const srv1 = await startContainerServer({
254
+ port: 0,
255
+ host: '127.0.0.1',
256
+ serverName: 'irc.persist',
257
+ networkName: 'PersistNet',
258
+ persistencePath: statePath,
259
+ });
260
+ const c1 = new TcpClient('127.0.0.1', srv1.port);
261
+ await c1.opened();
262
+ await c1.send('NICK op');
263
+ await c1.send('USER op 0 * :Op');
264
+ await c1.waitFor((l) => l.startsWith(':irc.persist 001 '));
265
+ await c1.send('JOIN #persist');
266
+ await c1.waitFor((l) => l.includes('JOIN #persist'));
267
+ await c1.send('TOPIC #persist :persisted topic');
268
+ await c1.waitFor((l) => l.includes('TOPIC #persist :persisted topic'));
269
+ await c1.send('MODE #persist +s');
270
+ await c1.waitFor((l) => l.includes('MODE #persist +s'));
271
+ await c1.close();
272
+ await srv1.close();
273
+
274
+ // Boot #2: a fresh client joins and sees the persisted topic + modes.
275
+ const srv2 = await startContainerServer({
276
+ port: 0,
277
+ host: '127.0.0.1',
278
+ serverName: 'irc.persist',
279
+ networkName: 'PersistNet',
280
+ persistencePath: statePath,
281
+ });
282
+ const c2 = new TcpClient('127.0.0.1', srv2.port);
283
+ await c2.opened();
284
+ await c2.send('NICK late');
285
+ await c2.send('USER late 0 * :Late');
286
+ await c2.waitFor((l) => l.startsWith(':irc.persist 001 '));
287
+ await c2.send('JOIN #persist');
288
+ await c2.waitFor((l) => l.includes('JOIN #persist'));
289
+ // The topic should be readable after restore.
290
+ await c2.send('TOPIC #persist');
291
+ await c2.waitFor((l) => l.includes('332') && l.includes('persisted topic'), 3000);
292
+ // The secret mode (+s) should be restored — verify via the runtime.
293
+ const snap = await srv2.runtime.getChannelSnapshot('#persist');
294
+ expect(snap?.modes.secret).toBe(true);
295
+ expect(snap?.topic?.text).toBe('persisted topic');
296
+ await c2.close();
297
+ await srv2.close();
298
+ });
299
+ });
300
+
301
+ describe('container-server — admission gate', () => {
302
+ it('rejects a connection when per-IP limit is reached', async () => {
303
+ const srv = await startContainerServer({
304
+ port: 0,
305
+ host: '127.0.0.1',
306
+ serverName: 'irc.gate',
307
+ networkName: 'GateNet',
308
+ maxConnectionsPerIp: 1,
309
+ });
310
+ const c1 = new TcpClient('127.0.0.1', srv.port);
311
+ await c1.opened();
312
+ // Second connection from the same IP should be rejected.
313
+ const c2 = new TcpClient('127.0.0.1', srv.port);
314
+ const error = await new Promise<string | null>((resolve) => {
315
+ c2.socket.setEncoding('utf8');
316
+ c2.socket.once('data', (d: string) => resolve(d));
317
+ c2.socket.once('close', () => resolve(null));
318
+ setTimeout(() => resolve(null), 2000);
319
+ });
320
+ expect(error).toContain('Closing link');
321
+ await c1.close();
322
+ await srv.close();
323
+ });
324
+ });
325
+
326
+ describe('container-server — snapshot error resilience', () => {
327
+ it('logs and continues when the snapshot file is corrupt on boot', async () => {
328
+ const dir = await mkdtemp(join(tmpdir(), 'sirc-corrupt-'));
329
+ const path = join(dir, 'corrupt.json');
330
+ const { writeFile } = await import('node:fs/promises');
331
+ await writeFile(path, '{ this is not valid json', 'utf8');
332
+
333
+ const srv = await startContainerServer({
334
+ port: 0,
335
+ host: '127.0.0.1',
336
+ serverName: 'irc.corrupt',
337
+ networkName: 'CorruptNet',
338
+ persistencePath: path,
339
+ });
340
+ // Server should still work — starts fresh.
341
+ const c = new TcpClient('127.0.0.1', srv.port);
342
+ await c.opened();
343
+ await c.send('NICK test');
344
+ await c.send('USER test 0 * :Test');
345
+ await c.waitFor((l) => l.startsWith(':irc.corrupt 001 '), 2000);
346
+ await c.close();
347
+ await srv.close();
348
+ await rm(dir, { recursive: true, force: true });
349
+ });
350
+
351
+ it('close() destroys live sockets before snapshotting', async () => {
352
+ const dir = await mkdtemp(join(tmpdir(), 'sirc-close-'));
353
+ const path = join(dir, 'close.json');
354
+ const srv = await startContainerServer({
355
+ port: 0,
356
+ host: '127.0.0.1',
357
+ serverName: 'irc.close',
358
+ networkName: 'CloseNet',
359
+ persistencePath: path,
360
+ snapshotIntervalMs: 999_999,
361
+ });
362
+ const c = new TcpClient('127.0.0.1', srv.port);
363
+ await c.opened();
364
+ // Wait for the server-side socket to be registered in the sockets set.
365
+ for (let i = 0; i < 50 && srv.testSockets.size === 0; i++) {
366
+ await new Promise((r) => setTimeout(r, 5));
367
+ }
368
+ expect(srv.testSockets.size).toBe(1);
369
+ // Closing the server should force-close the client's socket.
370
+ const closed = new Promise<void>((resolve) => c.socket.once('close', () => resolve()));
371
+ await srv.close();
372
+ await closed;
373
+ expect(c.socket.destroyed).toBe(true);
374
+ await rm(dir, { recursive: true, force: true });
375
+ });
376
+ });
377
+
378
+ describe('container-server — error paths', () => {
379
+ it('logs an actor failure without crashing the server', async () => {
380
+ const srv = await startContainerServer({
381
+ port: 0,
382
+ host: '127.0.0.1',
383
+ serverName: 'irc.err',
384
+ networkName: 'ErrNet',
385
+ });
386
+ const original = srv.runtime.send.bind(srv.runtime);
387
+ srv.runtime.send = async () => {
388
+ throw new Error('synthetic send failure');
389
+ };
390
+ const client = new TcpClient('127.0.0.1', srv.port);
391
+ await client.opened();
392
+ await client.send('NICK errUser');
393
+ await client.send('USER errUser 0 * :Err');
394
+ // PING triggers a Send → synthetic throw → actor catch → logError.
395
+ await client.send('PING :will-throw');
396
+ await new Promise((r) => setTimeout(r, 100));
397
+
398
+ srv.runtime.send = original;
399
+ // Server should still serve new connections.
400
+ const next = new TcpClient('127.0.0.1', srv.port);
401
+ await next.opened();
402
+ await next.send('NICK recover');
403
+ await next.send('USER recover 0 * :Recover');
404
+ await next.waitFor((l) => l.startsWith(':irc.err 001 '), 3000);
405
+ await next.close();
406
+ await client.close();
407
+ await srv.close();
408
+ });
409
+
410
+ it('logs and recovers from a per-socket error', async () => {
411
+ const srv = await startContainerServer({
412
+ port: 0,
413
+ host: '127.0.0.1',
414
+ serverName: 'irc.sockerr',
415
+ networkName: 'SockErrNet',
416
+ });
417
+ const client = new TcpClient('127.0.0.1', srv.port);
418
+ await client.opened();
419
+ for (let i = 0; i < 50 && srv.testSockets.size === 0; i++) {
420
+ await new Promise((r) => setTimeout(r, 5));
421
+ }
422
+ const serverSideSocket = [...srv.testSockets][0];
423
+ if (serverSideSocket === undefined) throw new Error('no server-side socket');
424
+ serverSideSocket.emit('error', new Error('synthetic socket failure'));
425
+ await new Promise((r) => setImmediate(r));
426
+ // Server should still serve new connections.
427
+ const next = new TcpClient('127.0.0.1', srv.port);
428
+ await next.opened();
429
+ await next.close();
430
+ await client.close();
431
+ await srv.close();
432
+ });
433
+
434
+ it('auto-snapshots on the configured interval', async () => {
435
+ const dir = await mkdtemp(join(tmpdir(), 'sirc-auto-'));
436
+ const path = join(dir, 'auto.json');
437
+ const srv = await startContainerServer({
438
+ port: 0,
439
+ host: '127.0.0.1',
440
+ serverName: 'irc.auto',
441
+ networkName: 'AutoNet',
442
+ persistencePath: path,
443
+ snapshotIntervalMs: 100,
444
+ });
445
+ // Create a channel so the snapshot has content.
446
+ const c = new TcpClient('127.0.0.1', srv.port);
447
+ await c.opened();
448
+ await c.send('NICK autoUser');
449
+ await c.send('USER autoUser 0 * :Auto');
450
+ await c.waitFor((l) => l.startsWith(':irc.auto 001 '), 2000);
451
+ await c.send('JOIN #auto-snap');
452
+ await c.waitFor((l) => l.includes('JOIN #auto-snap'));
453
+ // Wait for the interval to fire.
454
+ await new Promise((r) => setTimeout(r, 200));
455
+ await c.close();
456
+ await srv.close();
457
+
458
+ // Verify the snapshot was written by the timer.
459
+ const { readFile } = await import('node:fs/promises');
460
+ const raw = await readFile(path, 'utf8');
461
+ const snap = JSON.parse(raw);
462
+ expect(snap.channels.some((ch: { name: string }) => ch.name === '#auto-snap')).toBe(true);
463
+ await rm(dir, { recursive: true, force: true });
464
+ });
465
+ });
@@ -0,0 +1,227 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { InMemoryRuntime } from '@serverless-ircd/in-memory-runtime';
5
+ import type { ChanSnapshot } from '@serverless-ircd/irc-core';
6
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
7
+ import {
8
+ type PersistedState,
9
+ deserializeState,
10
+ loadSnapshot,
11
+ restoreRuntime,
12
+ saveSnapshot,
13
+ serializeState,
14
+ snapshotRuntime,
15
+ } from '../src/persistence.js';
16
+
17
+ /**
18
+ * Builds a runtime with two channels carrying distinct modes, topics, and
19
+ * ban masks so every persisted field is exercised. Members are intentionally
20
+ * omitted — they are per-connection and cannot survive a restart.
21
+ */
22
+ async function runtimeWithChannels(): Promise<InMemoryRuntime> {
23
+ const rt = new InMemoryRuntime({ clock: { now: () => 1_700_000_000_000 } });
24
+ // #dev: secret + key + limit, topic set, one ban mask.
25
+ const dev = rt.getOrCreateChannel('#dev');
26
+ dev.modes.secret = true;
27
+ dev.modes.topicLock = true;
28
+ dev.modes.key = 'hunter2';
29
+ dev.modes.limit = 42;
30
+ dev.banMasks.add('*!bad@*.evil');
31
+ dev.topic = { text: 'dev chat', setter: 'op!op@op', setAt: 1_700_000_001_000 };
32
+ // #lobby: plain channel, no topic, no modes.
33
+ rt.getOrCreateChannel('#lobby');
34
+ return rt;
35
+ }
36
+
37
+ describe('persistence — snapshotRuntime', () => {
38
+ it('extracts channel state (name, modes, topic, bans, createdAt) without members', async () => {
39
+ const rt = await runtimeWithChannels();
40
+ const state = await snapshotRuntime(rt);
41
+ expect(state.version).toBe(1);
42
+ expect(state.channels).toHaveLength(2);
43
+
44
+ const dev = state.channels.find((c) => c.name === '#dev');
45
+ expect(dev).toBeDefined();
46
+ expect(dev?.modes.secret).toBe(true);
47
+ expect(dev?.modes.topicLock).toBe(true);
48
+ expect(dev?.modes.key).toBe('hunter2');
49
+ expect(dev?.modes.limit).toBe(42);
50
+ expect(dev?.banMasks).toEqual(['*!bad@*.evil']);
51
+ expect(dev?.topic).toEqual({
52
+ text: 'dev chat',
53
+ setter: 'op!op@op',
54
+ setAt: 1_700_000_001_000,
55
+ });
56
+ expect(dev?.createdAt).toBe(1_700_000_000_000);
57
+ });
58
+
59
+ it('omits topic when the channel has none', async () => {
60
+ const rt = await runtimeWithChannels();
61
+ const state = await snapshotRuntime(rt);
62
+ const lobby = state.channels.find((c) => c.name === '#lobby');
63
+ expect(lobby).toBeDefined();
64
+ expect(lobby?.topic).toBeUndefined();
65
+ expect(lobby?.banMasks).toEqual([]);
66
+ });
67
+
68
+ it('snapshots an empty runtime as zero channels', async () => {
69
+ const rt = new InMemoryRuntime({ clock: { now: () => 0 } });
70
+ const state = await snapshotRuntime(rt);
71
+ expect(state.channels).toHaveLength(0);
72
+ });
73
+ });
74
+
75
+ describe('persistence — serialize / deserialize round-trip', () => {
76
+ it('round-trips through JSON without loss', async () => {
77
+ const rt = await runtimeWithChannels();
78
+ const original = await snapshotRuntime(rt);
79
+ const json = serializeState(original);
80
+ const restored = deserializeState(json);
81
+ expect(restored).toEqual(original);
82
+ });
83
+
84
+ it('produces valid JSON with version and savedAt', async () => {
85
+ const rt = await runtimeWithChannels();
86
+ const state = await snapshotRuntime(rt);
87
+ const json = serializeState(state);
88
+ const parsed = JSON.parse(json) as PersistedState;
89
+ expect(parsed.version).toBe(1);
90
+ expect(typeof parsed.savedAt).toBe('number');
91
+ expect(Array.isArray(parsed.channels)).toBe(true);
92
+ });
93
+
94
+ it('throws on malformed JSON', () => {
95
+ expect(() => deserializeState('{ not json')).toThrow();
96
+ });
97
+
98
+ it('throws on wrong version', () => {
99
+ const bad = JSON.stringify({ version: 999, savedAt: 0, channels: [] });
100
+ expect(() => deserializeState(bad)).toThrow(/version/i);
101
+ });
102
+
103
+ it('throws when the JSON value is a primitive (not an object)', () => {
104
+ expect(() => deserializeState('"hello"')).toThrow(/object/i);
105
+ expect(() => deserializeState('42')).toThrow(/object/i);
106
+ expect(() => deserializeState('null')).toThrow(/object/i);
107
+ });
108
+
109
+ it('throws when channels is not an array', () => {
110
+ const bad = JSON.stringify({ version: 1, savedAt: 0, channels: 'not-an-array' });
111
+ expect(() => deserializeState(bad)).toThrow(/channels/i);
112
+ });
113
+
114
+ it('uses savedAt=0 when the field is missing or non-numeric', () => {
115
+ const state = deserializeState(JSON.stringify({ version: 1, channels: [] }));
116
+ expect(state.savedAt).toBe(0);
117
+ });
118
+ });
119
+
120
+ describe('persistence — saveSnapshot / loadSnapshot file I/O', () => {
121
+ let dir: string;
122
+
123
+ beforeAll(async () => {
124
+ dir = await mkdtemp(join(tmpdir(), 'sirc-persist-'));
125
+ });
126
+
127
+ afterAll(async () => {
128
+ await rm(dir, { recursive: true, force: true });
129
+ });
130
+
131
+ it('writes and reads a snapshot file round-trip', async () => {
132
+ const rt = await runtimeWithChannels();
133
+ const state = await snapshotRuntime(rt);
134
+ const path = join(dir, 'snap.json');
135
+ await saveSnapshot(path, state);
136
+ const loaded = await loadSnapshot(path);
137
+ expect(loaded).not.toBeNull();
138
+ expect(loaded?.channels).toHaveLength(2);
139
+ });
140
+
141
+ it('returns null when the file does not exist', async () => {
142
+ const loaded = await loadSnapshot(join(dir, 'nonexistent.json'));
143
+ expect(loaded).toBeNull();
144
+ });
145
+
146
+ it('overwrites a previous snapshot on re-save', async () => {
147
+ const path = join(dir, 'overwrite.json');
148
+ const rt1 = new InMemoryRuntime({ clock: { now: () => 0 } });
149
+ rt1.getOrCreateChannel('#first');
150
+ await saveSnapshot(path, await snapshotRuntime(rt1));
151
+
152
+ const rt2 = new InMemoryRuntime({ clock: { now: () => 0 } });
153
+ rt2.getOrCreateChannel('#second');
154
+ await saveSnapshot(path, await snapshotRuntime(rt2));
155
+
156
+ const loaded = await loadSnapshot(path);
157
+ expect(loaded?.channels).toHaveLength(1);
158
+ expect(loaded?.channels[0]?.name).toBe('#second');
159
+ });
160
+ });
161
+
162
+ describe('persistence — restoreRuntime', () => {
163
+ it('restores channels with correct modes, topic, and bans into a fresh runtime', async () => {
164
+ const original = await runtimeWithChannels();
165
+ const state = await snapshotRuntime(original);
166
+
167
+ const target = new InMemoryRuntime({ clock: { now: () => 9_999 } });
168
+ restoreRuntime(target, state);
169
+
170
+ const channels = await target.listChannels();
171
+ expect(channels).toHaveLength(2);
172
+
173
+ const dev = channels.find((c) => c.name === '#dev') as ChanSnapshot;
174
+ expect(dev.modes.secret).toBe(true);
175
+ expect(dev.modes.topicLock).toBe(true);
176
+ expect(dev.modes.key).toBe('hunter2');
177
+ expect(dev.modes.limit).toBe(42);
178
+ expect([...dev.banMasks]).toEqual(['*!bad@*.evil']);
179
+ expect(dev.topic?.text).toBe('dev chat');
180
+ expect(dev.createdAt).toBe(1_700_000_000_000);
181
+
182
+ // Members must NOT be restored (connections are gone on restart).
183
+ expect(dev.members.size).toBe(0);
184
+ });
185
+
186
+ it('preserves the original channel name casing', async () => {
187
+ const original = new InMemoryRuntime({ clock: { now: () => 0 } });
188
+ original.getOrCreateChannel('#MixedCase');
189
+ const state = await snapshotRuntime(original);
190
+
191
+ const target = new InMemoryRuntime({ clock: { now: () => 0 } });
192
+ restoreRuntime(target, state);
193
+
194
+ const channels = await target.listChannels();
195
+ expect(channels[0]?.name).toBe('#MixedCase');
196
+ expect(channels[0]?.nameLower).toBe('#mixedcase');
197
+ });
198
+
199
+ it('full round-trip: snapshot → serialize → deserialize → restore yields equivalent state', async () => {
200
+ const original = await runtimeWithChannels();
201
+ const json = serializeState(await snapshotRuntime(original));
202
+ const restored = deserializeState(json);
203
+
204
+ const target = new InMemoryRuntime({ clock: { now: () => 0 } });
205
+ restoreRuntime(target, restored);
206
+
207
+ const before = (await original.listChannels()).sort((a, b) => a.name.localeCompare(b.name));
208
+ const after = (await target.listChannels()).sort((a, b) => a.name.localeCompare(b.name));
209
+
210
+ expect(after).toHaveLength(before.length);
211
+ for (let i = 0; i < before.length; i++) {
212
+ const b = before[i] as ChanSnapshot;
213
+ const a = after[i] as ChanSnapshot;
214
+ expect(a.name).toBe(b.name);
215
+ expect(a.modes).toEqual(b.modes);
216
+ expect([...a.banMasks].sort()).toEqual([...b.banMasks].sort());
217
+ expect(a.topic).toEqual(b.topic);
218
+ expect(a.createdAt).toBe(b.createdAt);
219
+ }
220
+ });
221
+
222
+ it('is a no-op on an empty channels list', async () => {
223
+ const target = new InMemoryRuntime({ clock: { now: () => 0 } });
224
+ restoreRuntime(target, { version: 1, savedAt: 0, channels: [] });
225
+ expect((await target.listChannels()).length).toBe(0);
226
+ });
227
+ });