serverless-ircd 0.1.0 → 0.3.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 (204) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Vitest `globalSetup` — boots a DynamoDB Local instance once per test run
3
+ * and exposes its endpoint via `process.env.DYNAMO_ENDPOINT`.
4
+ *
5
+ * Strategy (in priority order):
6
+ *
7
+ * 1. **testcontainers** — preferred, matches CI. Spins up the canonical
8
+ * `amazon/dynamodb-local` Docker image. Requires Docker.
9
+ * 2. **DynamoDB Local ZIP + Java** — development fallback for hosts
10
+ * with Java (e.g. dev laptops without Docker Desktop). Downloads
11
+ * the official AWS ZIP once into a per-user cache dir, runs it
12
+ * with `-inMemory` so per-run state is isolated by port.
13
+ * 3. **Skip** — sets `process.env.DDB_AVAILABLE = '0'`; integration
14
+ * tests gate themselves on this via `describe.skipIf`.
15
+ *
16
+ * The skip path keeps the suite green in environments that legitimately
17
+ * can't run DynamoDB Local (no Docker, no Java). The acceptance criterion
18
+ * is "all 31 scenarios pass when DDB is available", not "every dev box
19
+ * runs them".
20
+ */
21
+
22
+ import { execSync, spawn } from 'node:child_process';
23
+ import { exec as execCb } from 'node:child_process';
24
+ import { createWriteStream, existsSync, mkdirSync, renameSync } from 'node:fs';
25
+ import { type RequestOptions, request as httpRequest } from 'node:http';
26
+ import { request as httpsRequest } from 'node:https';
27
+ import { type Server, createServer } from 'node:net';
28
+ import { homedir, platform } from 'node:os';
29
+ import { join } from 'node:path';
30
+ import { promisify } from 'node:util';
31
+
32
+ const execAsync = promisify(execCb);
33
+
34
+ const DDB_LOCAL_IMAGE = 'amazon/dynamodb-local:2.5.3';
35
+ /** Official AWS-hosted DynamoDB Local distribution (ZIP). */
36
+ const DDB_LOCAL_ZIP_URL =
37
+ 'https://s3.us-west-2.amazonaws.com/dynamodb-local/dynamodb_local_latest.zip';
38
+ const CACHE_DIR = join(homedir(), '.cache', 'serverless-ircd', 'ddb-local');
39
+ const MARKER_PATH = join(CACHE_DIR, '.installed');
40
+ const JAR_PATH = join(CACHE_DIR, 'DynamoDBLocal.jar');
41
+ const LIB_PATH = join(CACHE_DIR, 'DynamoDBLocal_lib');
42
+
43
+ /** Idle health-check loop interval (ms). */
44
+ const HEALTH_INTERVAL_MS = 200;
45
+ /** Cap on how long we wait for DynamoDB Local to answer ListTables. */
46
+ const HEALTH_TIMEOUT_MS = 20_000;
47
+
48
+ interface DdbHandle {
49
+ endpoint: string;
50
+ stop: () => Promise<void>;
51
+ }
52
+
53
+ export default async function globalSetup(): Promise<(() => Promise<void>) | undefined> {
54
+ // Strategy 1: testcontainers (Docker).
55
+ try {
56
+ const handle = await tryTestcontainers();
57
+ if (handle !== null) {
58
+ process.env.DDB_AVAILABLE = '1';
59
+ process.env.DYNAMO_ENDPOINT = handle.endpoint;
60
+ console.warn(`[aws-adapter global-setup] DynamoDB Local via Docker at ${handle.endpoint}`);
61
+ return async () => {
62
+ await handle.stop();
63
+ };
64
+ }
65
+ } catch (err: unknown) {
66
+ console.warn('[aws-adapter global-setup] testcontainers unavailable:', renderErr(err));
67
+ }
68
+
69
+ // Strategy 2: local ZIP + Java.
70
+ try {
71
+ const handle = await tryZip();
72
+ if (handle !== null) {
73
+ process.env.DDB_AVAILABLE = '1';
74
+ process.env.DYNAMO_ENDPOINT = handle.endpoint;
75
+ console.warn(`[aws-adapter global-setup] DynamoDB Local via Java ZIP at ${handle.endpoint}`);
76
+ return async () => {
77
+ await handle.stop();
78
+ };
79
+ }
80
+ } catch (err: unknown) {
81
+ console.warn('[aws-adapter global-setup] ZIP fallback unavailable:', renderErr(err));
82
+ }
83
+
84
+ // Strategy 3: skip.
85
+ console.warn(
86
+ '[aws-adapter global-setup] No DynamoDB Local backend available (need Docker or Java). ' +
87
+ 'Integration tests will be skipped.',
88
+ );
89
+ process.env.DDB_AVAILABLE = '0';
90
+ return undefined;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Strategy 1: testcontainers
95
+ // ---------------------------------------------------------------------------
96
+
97
+ async function tryTestcontainers(): Promise<DdbHandle | null> {
98
+ let mod: typeof import('testcontainers') | undefined;
99
+ try {
100
+ mod = await import('testcontainers');
101
+ } catch {
102
+ return null;
103
+ }
104
+ const container = await new mod.GenericContainer(DDB_LOCAL_IMAGE)
105
+ .withExposedPorts(8000)
106
+ .withWaitStrategy(mod.Wait.forLogMessage(/Started ScriptEnvironment/u, 2))
107
+ .start();
108
+ const port = container.getMappedPort(8000);
109
+ const host = container.getHost();
110
+ const endpoint = `http://${host}:${port}`;
111
+ await waitForHealthy(endpoint);
112
+ return {
113
+ endpoint,
114
+ stop: async () => {
115
+ await container.stop();
116
+ },
117
+ };
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Strategy 2: DynamoDB Local ZIP + Java
122
+ // ---------------------------------------------------------------------------
123
+
124
+ async function tryZip(): Promise<DdbHandle | null> {
125
+ // Find any Java on PATH; the official ZIP's bundled version works with
126
+ // Java 8+, so we don't need a specific major version.
127
+ const javaBin = which('java');
128
+ if (javaBin === null) return null;
129
+ await ensureZipInstalled();
130
+ const port = await pickFreePort();
131
+ const endpoint = `http://localhost:${port}`;
132
+ const child = spawn(
133
+ javaBin,
134
+ [`-Djava.library.path=${LIB_PATH}`, '-jar', JAR_PATH, '-port', String(port), '-inMemory'],
135
+ { stdio: 'ignore', detached: false, cwd: CACHE_DIR },
136
+ );
137
+ const crashed = new Promise<void>((resolve) => {
138
+ child.on('exit', () => resolve());
139
+ });
140
+ try {
141
+ await waitForHealthy(endpoint);
142
+ } catch (err) {
143
+ child.kill('SIGKILL');
144
+ throw err;
145
+ }
146
+ return {
147
+ endpoint,
148
+ stop: async () => {
149
+ child.kill('SIGTERM');
150
+ await Promise.race([crashed, new Promise<void>((resolve) => setTimeout(resolve, 1500))]);
151
+ try {
152
+ child.kill('SIGKILL');
153
+ } catch {
154
+ // Already gone.
155
+ }
156
+ },
157
+ };
158
+ }
159
+
160
+ async function ensureZipInstalled(): Promise<void> {
161
+ if (existsSync(MARKER_PATH) && existsSync(JAR_PATH) && existsSync(LIB_PATH)) return;
162
+ mkdirSync(CACHE_DIR, { recursive: true });
163
+ const zipPath = join(CACHE_DIR, 'dynamodb_local.zip');
164
+ await downloadFile(DDB_LOCAL_ZIP_URL, zipPath);
165
+ // `unzip` ships with macOS and most Linux distros; fall back to jar tooling
166
+ // if absent (the JAR can also extract ZIPs).
167
+ if (which('unzip') !== null) {
168
+ await execAsync(`unzip -q -o ${quote(zipPath)} -d ${quote(CACHE_DIR)}`);
169
+ } else {
170
+ // java -jar DynamoDBLocal.jar would explode on a non-DDB zip; use
171
+ // Python's zipfile as a portable fallback.
172
+ await execAsync(
173
+ `python3 -c "import zipfile,sys; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])" ${quote(zipPath)} ${quote(CACHE_DIR)}`,
174
+ );
175
+ }
176
+ // Marker write happens last so a partial extraction is detectable.
177
+ const { writeFileSync } = await import('node:fs');
178
+ writeFileSync(MARKER_PATH, new Date().toISOString());
179
+ }
180
+
181
+ function downloadFile(url: string, dest: string): Promise<void> {
182
+ return new Promise((resolve, reject) => {
183
+ const tmp = `${dest}.tmp`;
184
+ const file = createWriteStream(tmp);
185
+ const req = httpsRequest(url, (res) => {
186
+ if (res.statusCode !== 200) {
187
+ reject(new Error(`download failed: ${res.statusCode ?? '?'} for ${url}`));
188
+ return;
189
+ }
190
+ res.pipe(file);
191
+ file.on('finish', () => {
192
+ file.close(() => {
193
+ renameSync(tmp, dest);
194
+ resolve();
195
+ });
196
+ });
197
+ });
198
+ req.on('error', reject);
199
+ req.end();
200
+ });
201
+ }
202
+
203
+ function pickFreePort(): Promise<number> {
204
+ return new Promise((resolve, reject) => {
205
+ const srv: Server = createServer();
206
+ srv.unref();
207
+ srv.on('error', reject);
208
+ srv.listen(0, '127.0.0.1', () => {
209
+ const addr = srv.address();
210
+ if (addr === null || typeof addr === 'string') {
211
+ reject(new Error('failed to pick free port'));
212
+ return;
213
+ }
214
+ const port = addr.port;
215
+ srv.close(() => resolve(port));
216
+ });
217
+ });
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Health check
222
+ // ---------------------------------------------------------------------------
223
+
224
+ async function waitForHealthy(endpoint: string): Promise<void> {
225
+ const deadline = Date.now() + HEALTH_TIMEOUT_MS;
226
+ let lastErr: unknown;
227
+ while (Date.now() < deadline) {
228
+ try {
229
+ await listTablesProbe(endpoint);
230
+ return;
231
+ } catch (err) {
232
+ lastErr = err;
233
+ await new Promise((r) => setTimeout(r, HEALTH_INTERVAL_MS));
234
+ }
235
+ }
236
+ throw new Error(
237
+ `DynamoDB Local at ${endpoint} did not become healthy in ${HEALTH_TIMEOUT_MS}ms: ${renderErr(
238
+ lastErr,
239
+ )}`,
240
+ );
241
+ }
242
+
243
+ function listTablesProbe(endpoint: string): Promise<void> {
244
+ // DynamoDB Local replies with `MissingAuthenticationToken` (status 400
245
+ // or 403 depending on version) when the server is *up* but the bare
246
+ // probe lacks a SigV4 signature. We treat any HTTP response as
247
+ // "healthy" — the SDK signs every real request.
248
+ return new Promise((resolve, reject) => {
249
+ const body = '{}';
250
+ const opts: RequestOptions = {
251
+ method: 'POST',
252
+ headers: {
253
+ 'Content-Type': 'application/x-amz-json-1.0',
254
+ 'X-Amz-Target': 'DynamoDB_20120810.ListTables',
255
+ 'Content-Length': Buffer.byteLength(body),
256
+ },
257
+ };
258
+ const req = httpRequest(`${endpoint}/`, opts, (res) => {
259
+ res.resume();
260
+ res.on('end', () => {
261
+ if (res.statusCode === undefined) {
262
+ reject(new Error('health probe: no status'));
263
+ return;
264
+ }
265
+ // 200 (signed) or 400/403 (auth errors indicating server is up).
266
+ resolve();
267
+ });
268
+ });
269
+ req.on('error', reject);
270
+ req.write(body);
271
+ req.end();
272
+ });
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Misc helpers
277
+ // ---------------------------------------------------------------------------
278
+
279
+ function which(bin: string): string | null {
280
+ const cmd = platform() === 'win32' ? `where ${bin}` : `command -v ${bin}`;
281
+ try {
282
+ const out = execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] })
283
+ .toString()
284
+ .trim();
285
+ return out.length > 0 ? out : null;
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+
291
+ function quote(path: string): string {
292
+ // Minimal shell-quote for paths with spaces; fine for our use.
293
+ if (/\s/u.test(path)) return `"${path}"`;
294
+ return path;
295
+ }
296
+
297
+ function renderErr(err: unknown): string {
298
+ if (err === null || err === undefined) return '<no error>';
299
+ if (err instanceof Error) return `${err.name}: ${err.message}`;
300
+ return String(err);
301
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Verifies that a `GoneException` raised during cross-connection `send`
3
+ * triggers {@link cleanupConnection}, which deletes:
4
+ * - the dead connection's row in Connections,
5
+ * - its nick (if any) in Nicks,
6
+ * - its memberships in ChannelMembers.
7
+ *
8
+ * Tested by injecting a mock `PostToConnection` that throws once.
9
+ */
10
+
11
+ import { GoneException } from '@aws-sdk/client-apigatewaymanagementapi';
12
+ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
13
+ import { PutCommand } from '@aws-sdk/lib-dynamodb';
14
+ import {
15
+ DEFAULT_SERVER_CONFIG,
16
+ type Nick,
17
+ type ParsedServerConfig,
18
+ createConnection,
19
+ } from '@serverless-ircd/irc-core';
20
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
21
+ import { AwsRuntime, type PostToConnection, cleanupConnection } from '../src/aws-runtime.js';
22
+ import { TABLE_DEFS } from '../src/cdk-table-defs.js';
23
+ import { createDynamoDocumentClient } from '../src/dynamo.js';
24
+ import { marshalChannelMember, marshalConnection, marshalNick } from '../src/serialize.js';
25
+ import type { TablesConfig } from '../src/tables.js';
26
+
27
+ const available = process.env.DDB_AVAILABLE === '1';
28
+ const endpoint = process.env.DYNAMO_ENDPOINT;
29
+
30
+ const SERVER_CONFIG: ParsedServerConfig = {
31
+ ...DEFAULT_SERVER_CONFIG,
32
+ serverName: 'irc.example.com',
33
+ networkName: 'ExampleNet',
34
+ motdLines: [],
35
+ };
36
+
37
+ interface Fixture {
38
+ tables: TablesConfig;
39
+ client: ReturnType<typeof createDynamoDocumentClient>;
40
+ cleanup: () => Promise<void>;
41
+ }
42
+
43
+ let fx: Fixture | null;
44
+
45
+ async function makeFixture(): Promise<Fixture> {
46
+ if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
47
+ const prefix = `g${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
48
+ const tables = Object.fromEntries(
49
+ Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
50
+ ) as TablesConfig;
51
+ const client = createDynamoDocumentClient({ endpoint });
52
+ for (const [logical, props] of Object.entries(TABLE_DEFS)) {
53
+ const pkName = props.partitionKey?.name;
54
+ const skName = props.sortKey?.name;
55
+ if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
56
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
57
+ { AttributeName: pkName, AttributeType: 'S' },
58
+ ];
59
+ const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
60
+ { AttributeName: pkName, KeyType: 'HASH' },
61
+ ];
62
+ if (skName !== undefined) {
63
+ attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
64
+ keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
65
+ }
66
+ await client.send(
67
+ new CreateTableCommand({
68
+ TableName: `${prefix}${logical}`,
69
+ AttributeDefinitions: attributeDefinitions,
70
+ KeySchema: keySchema,
71
+ BillingMode: 'PAY_PER_REQUEST',
72
+ }),
73
+ );
74
+ }
75
+ return {
76
+ tables,
77
+ client,
78
+ cleanup: async () => {
79
+ for (const logical of Object.keys(TABLE_DEFS)) {
80
+ try {
81
+ await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
82
+ } catch {
83
+ // best-effort
84
+ }
85
+ }
86
+ },
87
+ };
88
+ }
89
+
90
+ /** Seed a connection with a nick + a channel membership directly in Dynamo. */
91
+ async function seedConnection(
92
+ client: ReturnType<typeof createDynamoDocumentClient>,
93
+ tables: TablesConfig,
94
+ connId: string,
95
+ nick: string,
96
+ channel: string,
97
+ ): Promise<void> {
98
+ const now = Date.now();
99
+ const state = createConnection({ id: connId, connectedSince: now });
100
+ state.registration = 'registered';
101
+ state.nick = nick as Nick;
102
+ state.user = nick;
103
+ state.host = 'x';
104
+ state.realname = nick;
105
+ state.joinedChannels = new Set([channel as never]);
106
+ state.lastSeen = now;
107
+ await client.send(
108
+ new PutCommand({ TableName: tables.Connections, Item: marshalConnection(state, now) }),
109
+ );
110
+ await client.send(
111
+ new PutCommand({
112
+ TableName: tables.Nicks,
113
+ Item: marshalNick(nick as Nick, connId, now),
114
+ }),
115
+ );
116
+ await client.send(
117
+ new PutCommand({
118
+ TableName: tables.ChannelMembers,
119
+ Item: marshalChannelMember(channel, {
120
+ conn: connId,
121
+ nick: nick as Nick,
122
+ op: false,
123
+ voice: false,
124
+ }),
125
+ }),
126
+ );
127
+ }
128
+
129
+ describe.skipIf(!available)('GoneException cleanup', () => {
130
+ beforeEach(async () => {
131
+ fx = await makeFixture();
132
+ });
133
+ afterEach(async () => {
134
+ if (fx) await fx.cleanup();
135
+ fx = null;
136
+ });
137
+
138
+ it('send() to a gone connection triggers cleanupConnection and deletes Connections/Nicks/Members rows', async () => {
139
+ await seedConnection(fx!.client, fx!.tables, 'gone', 'bob', '#room');
140
+
141
+ // A self-connection runtime that tries to `send` to 'gone'.
142
+ const mgmt: PostToConnection = {
143
+ async postToConnection() {
144
+ throw new GoneException({
145
+ $metadata: {},
146
+ message: 'Gone',
147
+ });
148
+ },
149
+ };
150
+
151
+ const runtime = new AwsRuntime({
152
+ dynamo: fx!.client,
153
+ tables: fx!.tables,
154
+ connId: 'self',
155
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
156
+ managementApi: mgmt,
157
+ });
158
+
159
+ await runtime.send('gone', [{ text: ':self PRIVMSG bob :hi' } as never]);
160
+
161
+ const probe = new AwsRuntime({
162
+ dynamo: fx!.client,
163
+ tables: fx!.tables,
164
+ connId: '__probe__',
165
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
166
+ managementApi: null,
167
+ });
168
+ expect(await probe.getConnectionInfo('gone')).toBeNull();
169
+ expect(await probe.lookupNick('bob' as Nick)).toBeNull();
170
+ const members = await probe.getChannelConnections('#room' as never);
171
+ expect(members.size).toBe(0);
172
+ });
173
+
174
+ it('send() to a non-gone error rethrows', async () => {
175
+ await seedConnection(fx!.client, fx!.tables, 'present', 'carol', '#room');
176
+ const mgmt: PostToConnection = {
177
+ async postToConnection() {
178
+ throw new Error('network down');
179
+ },
180
+ };
181
+ const runtime = new AwsRuntime({
182
+ dynamo: fx!.client,
183
+ tables: fx!.tables,
184
+ connId: 'self',
185
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
186
+ managementApi: mgmt,
187
+ });
188
+ await expect(
189
+ runtime.send('present', [{ text: ':self PRIVMSG carol :hi' } as never]),
190
+ ).rejects.toThrow('network down');
191
+ // The target row should NOT have been cleaned up.
192
+ const probe = new AwsRuntime({
193
+ dynamo: fx!.client,
194
+ tables: fx!.tables,
195
+ connId: '__probe__',
196
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
197
+ managementApi: null,
198
+ });
199
+ expect(await probe.getConnectionInfo('present')).not.toBeNull();
200
+ });
201
+
202
+ it('cleanupConnection is idempotent when called twice', async () => {
203
+ await seedConnection(fx!.client, fx!.tables, 'gone2', 'dave', '#room');
204
+ await cleanupConnection(fx!.client, fx!.tables, 'gone2', null);
205
+ await cleanupConnection(fx!.client, fx!.tables, 'gone2', null);
206
+ const probe = new AwsRuntime({
207
+ dynamo: fx!.client,
208
+ tables: fx!.tables,
209
+ connId: '__probe__',
210
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
211
+ managementApi: null,
212
+ });
213
+ expect(await probe.getConnectionInfo('gone2')).toBeNull();
214
+ });
215
+
216
+ it('cleanupConnection is safe when the connection row is missing', async () => {
217
+ await expect(
218
+ cleanupConnection(fx!.client, fx!.tables, 'never-existed', null),
219
+ ).resolves.toBeUndefined();
220
+ });
221
+
222
+ it('broadcast() skips and cleans up gone members', async () => {
223
+ await seedConnection(fx!.client, fx!.tables, 'gone3', 'eve', '#room');
224
+ const mgmt: PostToConnection = {
225
+ async postToConnection() {
226
+ throw new GoneException({ $metadata: {}, message: 'Gone' });
227
+ },
228
+ };
229
+ const runtime = new AwsRuntime({
230
+ dynamo: fx!.client,
231
+ tables: fx!.tables,
232
+ connId: 'self',
233
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
234
+ managementApi: mgmt,
235
+ });
236
+ // `broadcast` iterates ChannelMembers and skips `except`.
237
+ await runtime.broadcast('#room' as never, [{ text: ':self PRIVMSG #room :hi' } as never]);
238
+ const probe = new AwsRuntime({
239
+ dynamo: fx!.client,
240
+ tables: fx!.tables,
241
+ connId: '__probe__',
242
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
243
+ managementApi: null,
244
+ });
245
+ expect(await probe.lookupNick('eve' as Nick)).toBeNull();
246
+ });
247
+
248
+ it('send() with managementApi=null is a silent no-op (no throw, no cleanup)', async () => {
249
+ await seedConnection(fx!.client, fx!.tables, 'present2', 'frank', '#room');
250
+ const runtime = new AwsRuntime({
251
+ dynamo: fx!.client,
252
+ tables: fx!.tables,
253
+ connId: 'self',
254
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
255
+ managementApi: null,
256
+ });
257
+ await runtime.send('present2', [{ text: ':self PRIVMSG frank :hi' } as never]);
258
+ const probe = new AwsRuntime({
259
+ dynamo: fx!.client,
260
+ tables: fx!.tables,
261
+ connId: '__probe__',
262
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
263
+ managementApi: null,
264
+ });
265
+ expect(await probe.getConnectionInfo('present2')).not.toBeNull();
266
+ });
267
+
268
+ it('static SERVER_CONFIG is valid (sanity)', () => {
269
+ expect(SERVER_CONFIG.serverName).toBe('irc.example.com');
270
+ });
271
+ });