serverless-ircd 0.1.0 → 0.2.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 (198) 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/AGENTS.md +5 -0
  6. package/CHANGELOG.md +352 -0
  7. package/MOTD.txt +3 -0
  8. package/PLAN-FIXES.md +358 -0
  9. package/PLAN.md +420 -0
  10. package/README.md +115 -81
  11. package/apps/aws-stack/README.md +73 -0
  12. package/apps/aws-stack/bin/aws.ts +49 -0
  13. package/apps/aws-stack/cdk.json +10 -0
  14. package/apps/aws-stack/package.json +41 -0
  15. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  16. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  17. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  18. package/apps/aws-stack/src/aws-stack.ts +263 -0
  19. package/apps/aws-stack/src/tables.ts +10 -0
  20. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  21. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  22. package/apps/aws-stack/tests/stack.test.ts +464 -0
  23. package/apps/aws-stack/tsconfig.build.json +11 -0
  24. package/apps/aws-stack/tsconfig.test.json +8 -0
  25. package/apps/aws-stack/vitest.config.ts +20 -0
  26. package/apps/cf-worker/package.json +1 -1
  27. package/apps/cf-worker/wrangler.toml +25 -0
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +56 -0
  30. package/apps/local-cli/src/server.ts +241 -32
  31. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  32. package/apps/local-cli/tests/e2e.test.ts +74 -0
  33. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  34. package/biome.json +3 -1
  35. package/dashboards/cloudwatch-irc.json +139 -0
  36. package/docs/AWS-Adapter-Architecture.md +494 -0
  37. package/docs/AWS-Deployment.md +1107 -0
  38. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  39. package/docs/Home.md +1 -0
  40. package/docs/Observability.md +87 -0
  41. package/docs/PlanIRCv3Websocket.md +489 -0
  42. package/docs/PlanWebClient.md +451 -0
  43. package/docs/Release-Process.md +440 -0
  44. package/package.json +8 -2
  45. package/packages/aws-adapter/README.md +35 -0
  46. package/packages/aws-adapter/package.json +52 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +783 -0
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
  49. package/packages/aws-adapter/src/config-loader.ts +96 -0
  50. package/packages/aws-adapter/src/dynamo.ts +61 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  52. package/packages/aws-adapter/src/handlers/default.ts +322 -0
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
  54. package/packages/aws-adapter/src/handlers/index.ts +248 -0
  55. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  56. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  57. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  58. package/packages/aws-adapter/src/index.ts +67 -0
  59. package/packages/aws-adapter/src/message-store.ts +34 -0
  60. package/packages/aws-adapter/src/serialize.ts +283 -0
  61. package/packages/aws-adapter/src/tables.ts +53 -0
  62. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  63. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  65. package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
  66. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  67. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  68. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  69. package/packages/aws-adapter/tests/handlers.test.ts +427 -0
  70. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  71. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  72. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  73. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  74. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  76. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  77. package/packages/aws-adapter/tsconfig.build.json +16 -0
  78. package/packages/aws-adapter/tsconfig.test.json +14 -0
  79. package/packages/aws-adapter/vitest.config.ts +23 -0
  80. package/packages/cf-adapter/package.json +2 -1
  81. package/packages/cf-adapter/src/config-loader.ts +106 -0
  82. package/packages/cf-adapter/src/connection-do.ts +34 -0
  83. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  84. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  85. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  86. package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
  87. package/packages/in-memory-runtime/package.json +1 -1
  88. package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
  89. package/packages/in-memory-runtime/src/index.ts +1 -1
  90. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
  91. package/packages/irc-core/package.json +12 -2
  92. package/packages/irc-core/src/admission.ts +216 -0
  93. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  94. package/packages/irc-core/src/cloak.ts +81 -0
  95. package/packages/irc-core/src/commands/cap.ts +1 -2
  96. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  97. package/packages/irc-core/src/commands/index.ts +7 -0
  98. package/packages/irc-core/src/commands/join.ts +59 -7
  99. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  100. package/packages/irc-core/src/commands/registration.ts +71 -14
  101. package/packages/irc-core/src/commands/sasl.ts +251 -0
  102. package/packages/irc-core/src/config.ts +247 -0
  103. package/packages/irc-core/src/flood-control.ts +175 -0
  104. package/packages/irc-core/src/index.ts +5 -0
  105. package/packages/irc-core/src/ports.ts +655 -0
  106. package/packages/irc-core/src/protocol/base64.ts +16 -0
  107. package/packages/irc-core/src/protocol/index.ts +2 -1
  108. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  109. package/packages/irc-core/src/protocol/parser.ts +27 -2
  110. package/packages/irc-core/src/state/connection.ts +41 -0
  111. package/packages/irc-core/src/types.ts +66 -2
  112. package/packages/irc-core/stryker.commands.conf.json +41 -0
  113. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  114. package/packages/irc-core/tests/admission.test.ts +229 -0
  115. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  116. package/packages/irc-core/tests/cloak.test.ts +78 -0
  117. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  118. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  119. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  120. package/packages/irc-core/tests/commands/registration.test.ts +277 -9
  121. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  122. package/packages/irc-core/tests/config.test.ts +646 -0
  123. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  124. package/packages/irc-core/tests/message-store.test.ts +530 -0
  125. package/packages/irc-core/tests/parser.test.ts +44 -1
  126. package/packages/irc-core/tests/ports.test.ts +263 -0
  127. package/packages/irc-server/package.json +1 -1
  128. package/packages/irc-server/src/actor.ts +162 -44
  129. package/packages/irc-server/src/dispatch.ts +89 -5
  130. package/packages/irc-server/src/index.ts +2 -0
  131. package/packages/irc-server/src/routing.ts +151 -0
  132. package/packages/irc-server/tests/actor.test.ts +470 -14
  133. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  134. package/packages/irc-server/tests/routing.test.ts +201 -0
  135. package/packages/irc-test-support/README.md +32 -0
  136. package/packages/irc-test-support/package.json +37 -0
  137. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
  138. package/packages/irc-test-support/src/index.ts +24 -0
  139. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  140. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  141. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  142. package/packages/irc-test-support/tsconfig.build.json +16 -0
  143. package/packages/irc-test-support/tsconfig.test.json +14 -0
  144. package/packages/irc-test-support/vitest.config.ts +20 -0
  145. package/progress.md +107 -0
  146. package/tickets.md +2485 -0
  147. package/tools/ci-hardening/package.json +30 -0
  148. package/tools/ci-hardening/src/index.ts +6 -0
  149. package/tools/ci-hardening/src/validate.ts +103 -0
  150. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  151. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  152. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  153. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  154. package/tools/ci-hardening/tsconfig.build.json +12 -0
  155. package/tools/ci-hardening/tsconfig.test.json +10 -0
  156. package/tools/ci-hardening/vitest.config.ts +21 -0
  157. package/tools/tcp-ws-forwarder/package.json +1 -1
  158. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  159. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  160. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  161. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  162. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  163. package/webircgateway/LICENSE +201 -0
  164. package/webircgateway/Makefile +44 -0
  165. package/webircgateway/README.md +134 -0
  166. package/webircgateway/config.conf.example +135 -0
  167. package/webircgateway/go.mod +16 -0
  168. package/webircgateway/go.sum +89 -0
  169. package/webircgateway/main.go +118 -0
  170. package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
  171. package/webircgateway/pkg/identd/identd.go +86 -0
  172. package/webircgateway/pkg/identd/rpcclient.go +59 -0
  173. package/webircgateway/pkg/irc/isupport.go +56 -0
  174. package/webircgateway/pkg/irc/message.go +217 -0
  175. package/webircgateway/pkg/irc/state.go +79 -0
  176. package/webircgateway/pkg/proxy/proxy.go +129 -0
  177. package/webircgateway/pkg/proxy/server.go +237 -0
  178. package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
  179. package/webircgateway/pkg/webircgateway/client.go +741 -0
  180. package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
  181. package/webircgateway/pkg/webircgateway/config.go +385 -0
  182. package/webircgateway/pkg/webircgateway/gateway.go +278 -0
  183. package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
  184. package/webircgateway/pkg/webircgateway/hooks.go +152 -0
  185. package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
  186. package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
  187. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
  188. package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
  189. package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
  190. package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
  191. package/webircgateway/pkg/webircgateway/utils.go +147 -0
  192. package/webircgateway/plugins/example/plugin.go +11 -0
  193. package/webircgateway/plugins/stats/plugin.go +52 -0
  194. package/webircgateway/staticcheck.conf +1 -0
  195. package/webircgateway/webircgateway.svg +3 -0
  196. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  197. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  198. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Unit tests for `loadServerConfigFromLambdaEnv` and `buildLambdaConfigInput`.
3
+ *
4
+ * Pure-function tests; no DynamoDB / Lambda runtime needed.
5
+ */
6
+
7
+ import { describe, expect, it } from 'vitest';
8
+ import {
9
+ type LambdaConfigEnv,
10
+ buildLambdaConfigInput,
11
+ loadServerConfigFromLambdaEnv,
12
+ } from '../src/config-loader.js';
13
+
14
+ describe('buildLambdaConfigInput', () => {
15
+ it('passes SERVER_NAME and NETWORK_NAME through', () => {
16
+ const out = buildLambdaConfigInput({ SERVER_NAME: 'irc.x', NETWORK_NAME: 'NetX' });
17
+ expect(out.serverName).toBe('irc.x');
18
+ expect(out.networkName).toBe('NetX');
19
+ });
20
+
21
+ it('splits MOTD on newlines into motdLines', () => {
22
+ const out = buildLambdaConfigInput({ MOTD: 'first\nsecond' });
23
+ expect(out.motdLines).toEqual(['first', 'second']);
24
+ });
25
+
26
+ it('omits motdLines when MOTD is empty', () => {
27
+ const out = buildLambdaConfigInput({ MOTD: '' });
28
+ expect('motdLines' in out).toBe(false);
29
+ });
30
+
31
+ it('omits motdLines when MOTD is undefined', () => {
32
+ const out = buildLambdaConfigInput({});
33
+ expect('motdLines' in out).toBe(false);
34
+ });
35
+
36
+ it('parses MAX_CLIENTS as int', () => {
37
+ expect(buildLambdaConfigInput({ MAX_CLIENTS: '42' }).maxClients).toBe(42);
38
+ });
39
+
40
+ it('passes CHANNEL_PREFIXES through', () => {
41
+ expect(buildLambdaConfigInput({ CHANNEL_PREFIXES: '#&' }).channelPrefixes).toBe('#&');
42
+ });
43
+
44
+ it('builds operCreds when OPER_USER alone is set', () => {
45
+ const out = buildLambdaConfigInput({ OPER_USER: 'alice' });
46
+ expect(out.operCreds).toEqual([{ user: 'alice', password: '' }]);
47
+ });
48
+
49
+ it('builds operCreds when OPER_PASSWORD alone is set', () => {
50
+ const out = buildLambdaConfigInput({ OPER_PASSWORD: 'pw' });
51
+ expect(out.operCreds).toEqual([{ user: '', password: 'pw' }]);
52
+ });
53
+
54
+ it('omits operCreds when neither OPER_* is set', () => {
55
+ expect('operCreds' in buildLambdaConfigInput({})).toBe(false);
56
+ });
57
+
58
+ it('parses all numeric knobs', () => {
59
+ const out = buildLambdaConfigInput({
60
+ MAX_CHANNELS_PER_USER: '5',
61
+ MAX_TARGETS_PER_COMMAND: '3',
62
+ NICK_LEN: '9',
63
+ CHANNEL_LEN: '12',
64
+ TOPIC_LEN: '100',
65
+ MAX_LIST_ENTRIES: '7',
66
+ });
67
+ expect(out.maxChannelsPerUser).toBe(5);
68
+ expect(out.maxTargetsPerCommand).toBe(3);
69
+ expect(out.nickLen).toBe(9);
70
+ expect(out.channelLen).toBe(12);
71
+ expect(out.topicLen).toBe(100);
72
+ expect(out.maxListEntries).toBe(7);
73
+ });
74
+
75
+ it('passes QUIT_MESSAGE through', () => {
76
+ expect(buildLambdaConfigInput({ QUIT_MESSAGE: 'bye' }).quitMessage).toBe('bye');
77
+ });
78
+ });
79
+
80
+ describe('loadServerConfigFromLambdaEnv', () => {
81
+ it('parses a minimal valid env', () => {
82
+ const env: LambdaConfigEnv = { SERVER_NAME: 'irc.test', NETWORK_NAME: 'TestNet' };
83
+ const cfg = loadServerConfigFromLambdaEnv(env);
84
+ expect(cfg.serverName).toBe('irc.test');
85
+ expect(cfg.networkName).toBe('TestNet');
86
+ });
87
+
88
+ it('applies schema defaults for unspecified numeric knobs', () => {
89
+ const cfg = loadServerConfigFromLambdaEnv({ SERVER_NAME: 's', NETWORK_NAME: 'n' });
90
+ expect(typeof cfg.maxClients).toBe('number');
91
+ expect(typeof cfg.nickLen).toBe('number');
92
+ });
93
+
94
+ it('throws when SERVER_NAME is missing', () => {
95
+ expect(() => loadServerConfigFromLambdaEnv({})).toThrow();
96
+ });
97
+
98
+ it('round-trips MOTD lines into ParsedServerConfig', () => {
99
+ const cfg = loadServerConfigFromLambdaEnv({
100
+ SERVER_NAME: 's',
101
+ NETWORK_NAME: 'n',
102
+ MOTD: 'a\nb',
103
+ });
104
+ expect(cfg.motdLines).toEqual(['a', 'b']);
105
+ });
106
+
107
+ it('round-trips the CDK default MOTD (newline-joined) into exactly two motdLines', () => {
108
+ const cdkDefaultMotd =
109
+ 'Welcome to the ServerlessIRCd deployment.\nThis server runs on AWS Lambda.';
110
+ const cfg = loadServerConfigFromLambdaEnv({
111
+ SERVER_NAME: 's',
112
+ NETWORK_NAME: 'n',
113
+ MOTD: cdkDefaultMotd,
114
+ });
115
+ expect(cfg.motdLines).toHaveLength(2);
116
+ expect(cfg.motdLines).toEqual([
117
+ 'Welcome to the ServerlessIRCd deployment.',
118
+ 'This server runs on AWS Lambda.',
119
+ ]);
120
+ });
121
+
122
+ it('treats a comma as part of a line, not a line delimiter', () => {
123
+ const cfg = loadServerConfigFromLambdaEnv({
124
+ SERVER_NAME: 's',
125
+ NETWORK_NAME: 'n',
126
+ MOTD: 'first line, still first line',
127
+ });
128
+ expect(cfg.motdLines).toEqual(['first line, still first line']);
129
+ expect(cfg.motdLines).toHaveLength(1);
130
+ });
131
+
132
+ it('honours CHANNEL_PREFIXES override', () => {
133
+ const cfg = loadServerConfigFromLambdaEnv({
134
+ SERVER_NAME: 's',
135
+ NETWORK_NAME: 'n',
136
+ CHANNEL_PREFIXES: '&',
137
+ });
138
+ expect(cfg.channelPrefixes).toBe('&');
139
+ });
140
+
141
+ it('honours MAX_CLIENTS override', () => {
142
+ const cfg = loadServerConfigFromLambdaEnv({
143
+ SERVER_NAME: 's',
144
+ NETWORK_NAME: 'n',
145
+ MAX_CLIENTS: '5000',
146
+ });
147
+ expect(cfg.maxClients).toBe(5000);
148
+ });
149
+
150
+ it('honours QUIT_MESSAGE override', () => {
151
+ const cfg = loadServerConfigFromLambdaEnv({
152
+ SERVER_NAME: 's',
153
+ NETWORK_NAME: 'n',
154
+ QUIT_MESSAGE: 'goodbye',
155
+ });
156
+ expect(cfg.quitMessage).toBe('goodbye');
157
+ });
158
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Unit tests for `createDynamoDocumentClient`.
3
+ *
4
+ * The factory is intentionally tiny; these tests exercise every branch
5
+ * (with/without endpoint, with/without region, env fallback) without
6
+ * actually sending a command.
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest';
10
+ import { createDynamoDocumentClient } from '../src/dynamo.js';
11
+
12
+ describe('createDynamoDocumentClient', () => {
13
+ it('builds a client with an explicit endpoint', () => {
14
+ const c = createDynamoDocumentClient({ endpoint: 'http://localhost:8000' });
15
+ expect(c).toBeDefined();
16
+ expect(typeof c.send).toBe('function');
17
+ });
18
+
19
+ it('builds a client with explicit endpoint + region', () => {
20
+ const c = createDynamoDocumentClient({
21
+ endpoint: 'http://localhost:8000',
22
+ region: 'us-west-2',
23
+ });
24
+ expect(c).toBeDefined();
25
+ });
26
+
27
+ it('builds a client with region only (no endpoint)', () => {
28
+ const c = createDynamoDocumentClient({ region: 'eu-west-1' });
29
+ expect(c).toBeDefined();
30
+ });
31
+
32
+ it('falls back to AWS_REGION env when neither arg is supplied', () => {
33
+ const prev = process.env.AWS_REGION;
34
+ process.env.AWS_REGION = 'ap-southeast-2';
35
+ try {
36
+ const c = createDynamoDocumentClient({});
37
+ expect(c).toBeDefined();
38
+ } finally {
39
+ if (prev === undefined) {
40
+ Reflect.deleteProperty(process.env, 'AWS_REGION');
41
+ } else {
42
+ process.env.AWS_REGION = prev;
43
+ }
44
+ }
45
+ });
46
+
47
+ it('falls back to us-east-1 when nothing is supplied', () => {
48
+ const prev = process.env.AWS_REGION;
49
+ Reflect.deleteProperty(process.env, 'AWS_REGION');
50
+ try {
51
+ const c = createDynamoDocumentClient({});
52
+ expect(c).toBeDefined();
53
+ } finally {
54
+ if (prev !== undefined) process.env.AWS_REGION = prev;
55
+ }
56
+ });
57
+ });
@@ -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
+ }