serverless-ircd 0.4.0 → 0.6.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 (243) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +435 -0
  6. package/README.md +206 -27
  7. package/apps/aws-stack/README.md +37 -3
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +106 -13
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +49 -3
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +138 -28
  27. package/apps/local-cli/tests/e2e.test.ts +113 -29
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  56. package/packages/aws-adapter/src/handlers/default.ts +66 -5
  57. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
  59. package/packages/aws-adapter/src/index.ts +2 -0
  60. package/packages/aws-adapter/src/serialize.ts +40 -2
  61. package/packages/aws-adapter/src/stats.ts +80 -0
  62. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  63. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  64. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  65. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  66. package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
  67. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  68. package/packages/aws-adapter/tests/connect.test.ts +100 -4
  69. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  70. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  72. package/packages/aws-adapter/tests/handlers.test.ts +238 -4
  73. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  74. package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
  75. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  76. package/packages/cf-adapter/package.json +6 -1
  77. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  78. package/packages/cf-adapter/src/channel-do.ts +2 -2
  79. package/packages/cf-adapter/src/connection-do.ts +185 -54
  80. package/packages/cf-adapter/src/env.ts +25 -6
  81. package/packages/cf-adapter/src/index.ts +2 -0
  82. package/packages/cf-adapter/src/registry-do.ts +22 -3
  83. package/packages/cf-adapter/src/serialize.ts +25 -4
  84. package/packages/cf-adapter/src/sharding.ts +1 -2
  85. package/packages/cf-adapter/src/stats.ts +65 -0
  86. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  87. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  88. package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
  89. package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
  90. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  91. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  92. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  93. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  94. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  95. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  96. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  97. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  98. package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
  99. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  100. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  101. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  102. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  103. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  104. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  105. package/packages/cf-adapter/vitest.config.ts +1 -1
  106. package/packages/cf-adapter/wrangler.test.toml +7 -0
  107. package/packages/in-memory-runtime/package.json +1 -1
  108. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  109. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  110. package/packages/irc-core/package.json +1 -1
  111. package/packages/irc-core/src/admission.ts +16 -15
  112. package/packages/irc-core/src/caps/capabilities.ts +38 -4
  113. package/packages/irc-core/src/caps/index.ts +1 -0
  114. package/packages/irc-core/src/caps/sts.ts +84 -0
  115. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  116. package/packages/irc-core/src/commands/away.ts +9 -3
  117. package/packages/irc-core/src/commands/cap.ts +23 -5
  118. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  119. package/packages/irc-core/src/commands/index.ts +42 -0
  120. package/packages/irc-core/src/commands/invite.ts +2 -4
  121. package/packages/irc-core/src/commands/isupport.ts +59 -2
  122. package/packages/irc-core/src/commands/kick.ts +2 -4
  123. package/packages/irc-core/src/commands/kill.ts +127 -0
  124. package/packages/irc-core/src/commands/list.ts +1 -1
  125. package/packages/irc-core/src/commands/lusers.ts +204 -0
  126. package/packages/irc-core/src/commands/mode.ts +12 -9
  127. package/packages/irc-core/src/commands/monitor.ts +327 -0
  128. package/packages/irc-core/src/commands/multiline.ts +256 -0
  129. package/packages/irc-core/src/commands/names.ts +3 -5
  130. package/packages/irc-core/src/commands/part.ts +2 -4
  131. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  132. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  133. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  134. package/packages/irc-core/src/commands/registration.ts +8 -0
  135. package/packages/irc-core/src/commands/rehash.ts +119 -0
  136. package/packages/irc-core/src/commands/sasl.ts +24 -1
  137. package/packages/irc-core/src/commands/setname.ts +109 -0
  138. package/packages/irc-core/src/commands/stats.ts +152 -0
  139. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  140. package/packages/irc-core/src/commands/topic.ts +2 -4
  141. package/packages/irc-core/src/commands/trace.ts +137 -0
  142. package/packages/irc-core/src/commands/wallops.ts +118 -0
  143. package/packages/irc-core/src/commands/whois.ts +5 -0
  144. package/packages/irc-core/src/config.ts +72 -10
  145. package/packages/irc-core/src/effects.ts +41 -1
  146. package/packages/irc-core/src/index.ts +2 -0
  147. package/packages/irc-core/src/ports.ts +568 -0
  148. package/packages/irc-core/src/protocol/index.ts +14 -0
  149. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  150. package/packages/irc-core/src/protocol/numerics.ts +57 -11
  151. package/packages/irc-core/src/protocol/outbound.ts +36 -4
  152. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  153. package/packages/irc-core/src/state/connection.ts +32 -1
  154. package/packages/irc-core/src/types.ts +120 -1
  155. package/packages/irc-core/src/ws-framing.ts +132 -0
  156. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  157. package/packages/irc-core/stryker.commands.conf.json +1 -2
  158. package/packages/irc-core/tests/admission.test.ts +18 -0
  159. package/packages/irc-core/tests/away-store.test.ts +73 -0
  160. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  161. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  162. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  163. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  164. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  165. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  166. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  167. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  168. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  169. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  170. package/packages/irc-core/tests/commands/mode.test.ts +129 -0
  171. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  172. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  173. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  174. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  175. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  176. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  177. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  178. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  179. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  180. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  181. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  182. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  183. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  184. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  185. package/packages/irc-core/tests/config.test.ts +103 -13
  186. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  187. package/packages/irc-core/tests/effects.test.ts +14 -0
  188. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  189. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  190. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  191. package/packages/irc-core/tests/numerics.test.ts +90 -0
  192. package/packages/irc-core/tests/outbound.test.ts +51 -0
  193. package/packages/irc-core/tests/ports.test.ts +22 -0
  194. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  195. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  196. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  197. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  198. package/packages/irc-core/tests/types.test.ts +27 -0
  199. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  200. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  201. package/packages/irc-server/package.json +1 -1
  202. package/packages/irc-server/src/actor.ts +699 -19
  203. package/packages/irc-server/src/dispatch.ts +109 -16
  204. package/packages/irc-server/src/routing.ts +3 -0
  205. package/packages/irc-server/src/runtime.ts +31 -0
  206. package/packages/irc-server/src/transport.ts +10 -7
  207. package/packages/irc-server/tests/actor.test.ts +2523 -42
  208. package/packages/irc-server/tests/dispatch.test.ts +300 -2
  209. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  210. package/packages/irc-server/tests/routing.test.ts +1 -0
  211. package/packages/irc-server/tests/runtime.test.ts +7 -0
  212. package/packages/irc-test-support/package.json +1 -1
  213. package/packages/irc-test-support/src/index.ts +6 -0
  214. package/packages/irc-test-support/src/scenarios.ts +9 -1
  215. package/packages/irc-test-support/src/test-config.ts +54 -0
  216. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  217. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/tools/ci-hardening/package.json +1 -1
  220. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  221. package/tools/load-test/package.json +33 -0
  222. package/tools/load-test/src/client.ts +351 -0
  223. package/tools/load-test/src/config.ts +313 -0
  224. package/tools/load-test/src/harness.ts +116 -0
  225. package/tools/load-test/src/main.ts +120 -0
  226. package/tools/load-test/src/metrics.ts +168 -0
  227. package/tools/load-test/src/report.ts +106 -0
  228. package/tools/load-test/tests/client.test.ts +212 -0
  229. package/tools/load-test/tests/config.test.ts +152 -0
  230. package/tools/load-test/tests/framing.test.ts +37 -0
  231. package/tools/load-test/tests/harness.test.ts +165 -0
  232. package/tools/load-test/tests/metrics.test.ts +174 -0
  233. package/tools/load-test/tests/report.test.ts +161 -0
  234. package/tools/load-test/tests/smoke.test.ts +67 -0
  235. package/tools/load-test/tsconfig.build.json +12 -0
  236. package/tools/load-test/tsconfig.test.json +10 -0
  237. package/tools/load-test/vitest.config.ts +29 -0
  238. package/tools/package.json +6 -1
  239. package/tools/seed-cf-accounts.ts +4 -1
  240. package/tools/tcp-ws-forwarder/package.json +1 -1
  241. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  242. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  243. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -0,0 +1,155 @@
1
+ /**
2
+ * CI-hardening acceptance test for the aws-stack coverage command.
3
+ *
4
+ * Drives the real `pnpm --filter @serverless-ircd/aws-stack coverage`
5
+ * pipeline in a subprocess and asserts two things the ticket requires:
6
+ *
7
+ * 1. The command exits 0 (no `[ELIFECYCLE]` / OOM crash).
8
+ * 2. The vitest text reporter prints an `All files` coverage row.
9
+ *
10
+ * Before the bundle-stub fix this command exhausted CI memory re-running
11
+ * esbuild for every `NodejsFunction` synth (one per stack-related test,
12
+ * multiplied by 3–4 Lambda constructs each), and died with
13
+ * `[ELIFECYCLE] Command failed with exit code N` before the coverage
14
+ * table could print. The stub (`makeLambda` in `src/aws-stack.ts`,
15
+ * armed by `tests/vitest.setup.ts`) makes the synth a no-op for
16
+ * bundling so the pipeline completes in seconds.
17
+ *
18
+ * Recursion guard: the subprocess inherits
19
+ * `IRC_AWS_COVERAGE_SELF_TEST=1`, which makes this describe block skip
20
+ * itself when re-entered. Without that, the outer coverage run would
21
+ * spawn the coverage command, which spawns the coverage command, … .
22
+ *
23
+ * Slow-path: the subprocess still spins up pnpm + vitest + the v8
24
+ * coverage provider, so the test carries a generous timeout. It is
25
+ * intentionally part of the default `pnpm test` run because it is the
26
+ * end-to-end guard against the coverage pipeline silently regressing
27
+ * back into the bundling loop.
28
+ */
29
+
30
+ import { spawnSync } from 'node:child_process';
31
+ import { describe, expect, it } from 'vitest';
32
+
33
+ /** Skip-self env var handed to the subprocess so it does not re-spawn. */
34
+ const SELF_TEST_FLAG = 'IRC_AWS_COVERAGE_SELF_TEST';
35
+
36
+ /** Hard ceiling for the subprocess; the stubbed suite finishes well under this. */
37
+ const COVERAGE_TIMEOUT_MS = 240_000;
38
+
39
+ /**
40
+ * Strips ANSI color / cursor escapes from `combined` so the assertion
41
+ * regexes below can be plain text. Vitest always writes the coverage
42
+ * banner and table through its colorizer even when stdout is piped, so
43
+ * without this the banner reads `% \u001b[2mCoverage report from …`
44
+ * and a literal `/Coverage report from v8/` regex fails to match.
45
+ */
46
+ function stripAnsi(text: string): string {
47
+ // The ANSI color escape is the literal ESC byte (0x1B) followed by `[`
48
+ // and a parameter list. biome's noControlCharactersInRegex flags the
49
+ // inline `\u001b` literal, so the regex is built from a hex string to
50
+ // make the intent explicit while satisfying the linter.
51
+ const ansiPattern = new RegExp(`[${String.fromCharCode(27)}]\\[[0-9;]*m`, 'g');
52
+ return text.replace(ansiPattern, '');
53
+ }
54
+
55
+ describe.skipIf(process.env[SELF_TEST_FLAG] === '1')(
56
+ 'aws-stack coverage command (ci-hardening acceptance)',
57
+ () => {
58
+ it(
59
+ 'completes and prints an "All files" coverage row',
60
+ () => {
61
+ // Strip the `npm_package_*` / `npm_config_*` env vars that the outer
62
+ // `pnpm run coverage` sets. When those leak into the spawned pnpm,
63
+ // its `--filter` resolution short-circuits to the current package
64
+ // and fails with `ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT` even though the
65
+ // workspace package exposes the script. The inner pnpm must resolve
66
+ // the filter against the workspace root, not the parent run context.
67
+ //
68
+ // Also strip AI-agent env vars (CLAUDECODE, OPENCODE, …): vitest's
69
+ // std-env dependency detects them and flips the text reporter into
70
+ // `skipFull` mode, which hides the `All files` row this test exists
71
+ // to assert on. CI runners never set these; the strip only matters
72
+ // when the suite is launched from inside an agent shell.
73
+ const cleanedEnv: Record<string, string | undefined> = { ...process.env };
74
+ for (const key of Object.keys(cleanedEnv)) {
75
+ if (
76
+ key.startsWith('npm_package_') ||
77
+ key.startsWith('npm_config_') ||
78
+ key.startsWith('npm_lifecycle_') ||
79
+ key === 'INIT_CWD' ||
80
+ key === 'OPENCODE' ||
81
+ key === 'CLAUDECODE' ||
82
+ key === 'CLAUDE_CODE' ||
83
+ key === 'GEMINI_CLI' ||
84
+ key === 'CODEX_SANDBOX' ||
85
+ key === 'CODEX_THREAD_ID' ||
86
+ key === 'AUGMENT_AGENT' ||
87
+ key === 'GOOSE_PROVIDER'
88
+ ) {
89
+ cleanedEnv[key] = '';
90
+ }
91
+ }
92
+ cleanedEnv[SELF_TEST_FLAG] = '1';
93
+
94
+ const result = spawnSync(
95
+ 'pnpm',
96
+ [
97
+ '--filter',
98
+ '@serverless-ircd/aws-stack',
99
+ 'exec',
100
+ 'vitest',
101
+ 'run',
102
+ '--coverage',
103
+ // Send the inner run's coverage output to a sibling directory so
104
+ // it cannot race the outer vitest that spawned us (both would
105
+ // otherwise write to `./coverage` and the loser crashes with
106
+ // "Something removed the coverage directory").
107
+ '--coverage.reportsDirectory=coverage-self-test',
108
+ ],
109
+ {
110
+ encoding: 'utf8',
111
+ env: cleanedEnv,
112
+ },
113
+ );
114
+
115
+ // Exit code 0 = no `[ELIFECYCLE]` / early exit. Report the captured
116
+ // output in the assertion message so a CI failure shows what the
117
+ // subprocess actually printed instead of a bare status code.
118
+ if (result.status !== 0) {
119
+ throw new Error(
120
+ `coverage command exited with status ${result.status as number | string}\n` +
121
+ `--- stdout ---\n${result.stdout}\n` +
122
+ `--- stderr ---\n${result.stderr}`,
123
+ );
124
+ }
125
+
126
+ const combined = stripAnsi(`${result.stdout}\n${result.stderr}`);
127
+
128
+ // The vitest text reporter prefixes its table with this banner; the
129
+ // `All files` summary row follows. Asserting both makes the test
130
+ // robust to incidental wording changes elsewhere in the output.
131
+ expect(combined, 'coverage command must emit the v8 coverage banner').toMatch(
132
+ /Coverage report from v8/,
133
+ );
134
+ expect(combined, 'coverage command must emit an "All files" summary row').toMatch(
135
+ /All files/,
136
+ );
137
+
138
+ // Belt-and-braces: if the bundle stub ever regresses, the subprocess
139
+ // would flood `Bundling asset TestStack/IrcHandler/Code/Stage…` once
140
+ // per Lambda per test (dozens to hundreds of times) and eventually
141
+ // OOM in CI. With the stub active the count is at most a handful
142
+ // (only the deliberately-ungated production-path test triggers real
143
+ // esbuild runs, and it bounds itself to one stack). Assert the count
144
+ // stays small so a regression fails loudly here instead of looking
145
+ // like a mysterious `[ELIFECYCLE]` exit on the CI runner.
146
+ const bundleCount = (combined.match(/Bundling asset/g) ?? []).length;
147
+ expect(
148
+ bundleCount,
149
+ 'coverage command must not loop esbuild bundling across the test synth',
150
+ ).toBeLessThan(10);
151
+ },
152
+ COVERAGE_TIMEOUT_MS,
153
+ );
154
+ },
155
+ );
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Synth-time assertions for the `apps/aws-stack` CDK stack.
3
3
  *
4
- * These are the primary red→green tests for the infrastructure ticket:
4
+ * These are the primary red→green tests for the infrastructure stack:
5
5
  * they assert the exact CloudFormation shape that `cdk synth` must
6
6
  * produce — five DynamoDB tables with the PLAN §4 schema, a single
7
7
  * Node 24 Lambda wired to all three WebSocket routes, the API Gateway
@@ -149,6 +149,52 @@ describe('IrcAwsStack — API Gateway v2 WebSocket API', () => {
149
149
  const { template } = makeTemplate();
150
150
  template.resourceCountIs('AWS::ApiGatewayV2::Route', 3);
151
151
  });
152
+
153
+ it('enables the $connect route to return a response (route response selection)', () => {
154
+ const { template } = makeTemplate();
155
+ template.hasResourceProperties('AWS::ApiGatewayV2::Route', {
156
+ RouteKey: '$connect',
157
+ RouteResponseSelectionExpression: '$default',
158
+ });
159
+ });
160
+
161
+ it('creates a $connect route response so the upgrade can carry a response', () => {
162
+ const { template } = makeTemplate();
163
+ template.hasResourceProperties('AWS::ApiGatewayV2::RouteResponse', {
164
+ RouteResponseKey: '$default',
165
+ });
166
+ });
167
+ });
168
+
169
+ describe('IrcAwsStack — IRCv3 WebSocket subprotocol echo', () => {
170
+ it('attaches an integration response on the $connect route mapping Sec-WebSocket-Protocol', () => {
171
+ const { template } = makeTemplate();
172
+ template.hasResourceProperties('AWS::ApiGatewayV2::IntegrationResponse', {
173
+ ResponseParameters: {
174
+ 'method.response.header.Sec-WebSocket-Protocol':
175
+ 'integration.response.header.Sec-WebSocket-Protocol',
176
+ },
177
+ });
178
+ });
179
+
180
+ it('binds the subprotocol integration response to the $connect integration', () => {
181
+ const { template, stack } = makeTemplate();
182
+ const responses = template.findResources('AWS::ApiGatewayV2::IntegrationResponse');
183
+ // Exactly one integration response is configured (the $connect subprotocol echo).
184
+ expect(Object.keys(responses)).toHaveLength(1);
185
+ // The IntegrationId Ref must resolve to a CfnIntegration under the $connect route.
186
+ const stackName = stack.stackName;
187
+ void stackName;
188
+ const cfnIntegrations = template.findResources('AWS::ApiGatewayV2::Integration');
189
+ expect(Object.keys(cfnIntegrations).length).toBeGreaterThanOrEqual(3);
190
+ });
191
+
192
+ it('uses the $default integration response key', () => {
193
+ const { template } = makeTemplate();
194
+ template.hasResourceProperties('AWS::ApiGatewayV2::IntegrationResponse', {
195
+ IntegrationResponseKey: '$default',
196
+ });
197
+ });
152
198
  });
153
199
 
154
200
  describe('IrcAwsStack — Lambda', () => {
@@ -437,11 +483,11 @@ describe('IrcAwsStack — server identity props (configurable deploy identity)',
437
483
  );
438
484
  });
439
485
 
440
- it('applies the default server identity when the props are omitted', () => {
486
+ it('applies a non-placeholder fallback server identity when the props are omitted', () => {
441
487
  const { template } = makeTemplate();
442
488
  expect(
443
489
  findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.SERVER_NAME,
444
- ).toBe('irc.example.com');
490
+ ).toBe('irc.localhost');
445
491
  expect(
446
492
  findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.NETWORK_NAME,
447
493
  ).toBe('ExampleNet');
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Red→green driver for the aws-stack test-mode bundle stub.
3
+ *
4
+ * The IrcAwsStack constructs up to four `NodejsFunction` resources. Each
5
+ * one invokes esbuild at synth time to bundle the Lambda handler entry.
6
+ * Repeated across the full `apps/aws-stack` test suite, the bundler ran
7
+ * dozens of times per coverage invocation and exhausted CI memory before
8
+ * the vitest coverage table could be printed (the failure mode behind
9
+ * the coverage-command crash).
10
+ *
11
+ * Setting `IRC_AWS_STACK_TEST_NO_BUNDLE=1` (done once for the whole
12
+ * suite by `tests/vitest.setup.ts`) flips the stack onto a stub code
13
+ * path that emits an inline Lambda zip and skips esbuild entirely,
14
+ * while preserving every other CloudFormation shape the rest of the
15
+ * suite asserts on (runtime, env vars, IAM grants, route wiring, …).
16
+ *
17
+ * The assertions below lock that stub in place. They fail loudly the
18
+ * moment a refactor re-routes the stack back to `NodejsFunction` under
19
+ * test, or drops the env-var guard, so the coverage pipeline cannot
20
+ * silently regress back into an unbounded esbuild loop.
21
+ */
22
+
23
+ import { App } from 'aws-cdk-lib';
24
+ import { Template } from 'aws-cdk-lib/assertions';
25
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
26
+ import { IrcAwsStack } from '../src/aws-stack.js';
27
+
28
+ /**
29
+ * Collects every `AWS::Lambda::Function`'s `Code` block from a synthesized
30
+ * template. Under the stub each Code must be `{ ZipFile: … }` (inline) and
31
+ * must NOT be `{ S3Bucket: …, S3Key: … }` (the shape CDK emits only after
32
+ * esbuild has run).
33
+ */
34
+ function lambdaCodeBlocks(template: Template): Array<Record<string, unknown>> {
35
+ const fns = template.findResources('AWS::Lambda::Function') as Record<
36
+ string,
37
+ { Properties?: { Code?: Record<string, unknown> } }
38
+ >;
39
+ return Object.values(fns).map((fn) => fn.Properties?.Code ?? {});
40
+ }
41
+
42
+ describe('IrcAwsStack — test-mode bundle stub (no esbuild)', () => {
43
+ it('emits inline (ZipFile) Lambda code for the default wss stack so esbuild is skipped', () => {
44
+ const app = new App();
45
+ const stack = new IrcAwsStack(app, 'TestStack');
46
+ const template = Template.fromStack(stack);
47
+ const codes = lambdaCodeBlocks(template);
48
+ // handler + sweeper + pingChecker.
49
+ expect(codes).toHaveLength(3);
50
+ for (const code of codes) {
51
+ expect(code).toHaveProperty('ZipFile');
52
+ expect(code).not.toHaveProperty('S3Bucket');
53
+ expect(code).not.toHaveProperty('S3Key');
54
+ }
55
+ });
56
+
57
+ it('emits inline (ZipFile) Lambda code for the NLB streaming handler when tcpTlsDomainName is set', () => {
58
+ const app = new App();
59
+ const stack = new IrcAwsStack(app, 'TestStack', {
60
+ environmentName: 'staging',
61
+ tcpTlsDomainName: 'irc.example.com',
62
+ });
63
+ const template = Template.fromStack(stack);
64
+ const codes = lambdaCodeBlocks(template);
65
+ // handler + sweeper + pingChecker + nlbHandler.
66
+ expect(codes).toHaveLength(4);
67
+ for (const code of codes) {
68
+ expect(code).toHaveProperty('ZipFile');
69
+ expect(code).not.toHaveProperty('S3Bucket');
70
+ }
71
+ });
72
+
73
+ it('preserves the Node 24 runtime on every stubbed Lambda', () => {
74
+ const app = new App();
75
+ const stack = new IrcAwsStack(app, 'TestStack', {
76
+ tcpTlsDomainName: 'irc.example.com',
77
+ });
78
+ const template = Template.fromStack(stack);
79
+ template.allResourcesProperties('AWS::Lambda::Function', {
80
+ Runtime: 'nodejs24.x',
81
+ });
82
+ });
83
+ });
84
+
85
+ /**
86
+ * Production-path coverage. The stub above is the test-only fast path; the
87
+ * `NodejsFunction` branch in `makeLambda` is what every real `cdk synth`
88
+ * takes. Exercising it once here covers the otherwise-unreached return and
89
+ * asserts the production Code shape (`S3Bucket` / asset-backed) so a
90
+ * refactor that accidentally drops the production path fails loudly.
91
+ *
92
+ * Bounded to a single synth (one stack, no `tcpTlsDomainName`) so esbuild
93
+ * runs at most three times — enough to cover the branch without
94
+ * re-introducing the unbounded bundling loop the stub exists to dodge.
95
+ */
96
+ describe('IrcAwsStack — production bundling path (no stub)', () => {
97
+ let savedFlag: string | undefined;
98
+
99
+ beforeEach(() => {
100
+ savedFlag = process.env.IRC_AWS_STACK_TEST_NO_BUNDLE;
101
+ process.env.IRC_AWS_STACK_TEST_NO_BUNDLE = '';
102
+ });
103
+
104
+ afterEach(() => {
105
+ if (savedFlag !== undefined) {
106
+ process.env.IRC_AWS_STACK_TEST_NO_BUNDLE = savedFlag;
107
+ } else {
108
+ process.env.IRC_AWS_STACK_TEST_NO_BUNDLE = '';
109
+ }
110
+ });
111
+
112
+ it('uses NodejsFunction (S3-bundled asset) when the stub env var is unset', () => {
113
+ const app = new App();
114
+ const stack = new IrcAwsStack(app, 'TestStack');
115
+ const template = Template.fromStack(stack);
116
+ const fns = template.findResources('AWS::Lambda::Function') as Record<
117
+ string,
118
+ { Properties?: { Code?: Record<string, unknown> } }
119
+ >;
120
+ const codes = Object.values(fns).map((fn) => fn.Properties?.Code ?? {});
121
+ expect(codes).toHaveLength(3);
122
+ for (const code of codes) {
123
+ // Production synth emits an S3-backed asset (esbuild ran). No
124
+ // `ZipFile` should appear on any production Lambda.
125
+ expect(code).toHaveProperty('S3Bucket');
126
+ expect(code).not.toHaveProperty('ZipFile');
127
+ }
128
+ });
129
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Vitest setup for the aws-stack test suite.
3
+ *
4
+ * Runs once per test file before any test body executes. Sets the flag
5
+ * consumed by `makeLambda()` in `src/aws-stack.ts` to route every Lambda
6
+ * construct onto the inline-zip stub, so synthesizing the stack under
7
+ * test does NOT invoke esbuild. Without this, the ~80 stack-related
8
+ * tests each triggered one esbuild bundle per Lambda (4 functions × 80
9
+ * synths = 320+ bundles per coverage run), which exhausted CI memory
10
+ * before the vitest coverage table could print.
11
+ *
12
+ * The stub preserves every CloudFormation shape the rest of the suite
13
+ * asserts on (runtime, env vars, IAM grants, route wiring, …) while
14
+ * making the bundling step a no-op. Production synth (`bin/aws.ts`) is
15
+ * driven by `cdk synth` / the CDK CLI, never via vitest, so it cannot
16
+ * accidentally inherit this env var.
17
+ */
18
+ process.env.IRC_AWS_STACK_TEST_NO_BUNDLE = '1';
@@ -4,6 +4,9 @@ export default defineConfig({
4
4
  test: {
5
5
  include: ['tests/**/*.test.ts'],
6
6
  exclude: ['cdk.out/**', 'dist/**', 'node_modules/**'],
7
+ setupFiles: ['./tests/vitest.setup.ts'],
8
+ testTimeout: 60_000,
9
+ fileParallelism: false,
7
10
  coverage: {
8
11
  provider: 'v8',
9
12
  all: false,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-tcp-container",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Container TCP origin for the irc+tls :6697 transport — Spectrum terminates TLS at the edge, this container runs the IRC core over plaintext TCP",
6
6
  "license": "BSD-3-Clause",
@@ -58,14 +58,13 @@ const DEFAULT_TCP_PORT = 6667;
58
58
  const DEFAULT_TCP_HOST = '0.0.0.0';
59
59
  const DEFAULT_SNAPSHOT_INTERVAL_MS = 60_000;
60
60
 
61
- /** Default server name / network when env vars are missing. */
62
- const DEFAULT_SERVER_NAME = 'irc.example.com';
63
- const DEFAULT_NETWORK_NAME = 'ExampleNet';
64
-
65
61
  /**
66
62
  * Loads and validates the IRC server config plus container-specific knobs
67
63
  * from a platform env. Throws on an invalid IRC config (bad port number,
68
- * missing required fields, etc.) so boot fails fast.
64
+ * missing required fields, etc.) so boot fails fast. `SERVER_NAME` and
65
+ * `NETWORK_NAME` are required — omitting them produces a readable error
66
+ * pointing at the missing knob rather than silently advertising a
67
+ * placeholder identity.
69
68
  */
70
69
  export function loadContainerConfig(env: ContainerConfigEnv): ContainerConfig {
71
70
  const server = parseServerConfig(buildServerInput(env));
@@ -89,8 +88,8 @@ function parseIntOrDefault(raw: string | undefined, def: number): number {
89
88
 
90
89
  function buildServerInput(env: ContainerConfigEnv): Record<string, unknown> {
91
90
  const input: Record<string, unknown> = {
92
- serverName: env.SERVER_NAME ?? DEFAULT_SERVER_NAME,
93
- networkName: env.NETWORK_NAME ?? DEFAULT_NETWORK_NAME,
91
+ serverName: env.SERVER_NAME,
92
+ networkName: env.NETWORK_NAME,
94
93
  };
95
94
  if (env.SERVER_VERSION !== undefined) input.serverVersion = env.SERVER_VERSION;
96
95
  if (env.CREATED_AT !== undefined) input.createdAt = parseCreatedAt(env.CREATED_AT);
@@ -286,6 +286,9 @@ function attachConnection(
286
286
  history,
287
287
  logger,
288
288
  transport: new TcpByteStreamTransport(),
289
+ // Spectrum terminates TLS at the edge before forwarding plaintext TCP
290
+ // to this origin, so every connection is secure → user mode `S`.
291
+ secure: true,
289
292
  });
290
293
 
291
294
  runtime.registerConnection(
@@ -40,9 +40,16 @@ describe('config-loader — IRC server config', () => {
40
40
  ]);
41
41
  });
42
42
 
43
- it('applies schema default serverName when unset', () => {
44
- const cfg = loadContainerConfig({ NETWORK_NAME: 'TestNet' } as ContainerConfigEnv);
45
- expect(cfg.server.serverName).toBe('irc.example.com');
43
+ it('throws a readable error naming serverName when SERVER_NAME is unset', () => {
44
+ expect(() =>
45
+ loadContainerConfig({ NETWORK_NAME: 'TestNet' } as ContainerConfigEnv),
46
+ ).toThrowError(/serverName/u);
47
+ });
48
+
49
+ it('throws a readable error naming networkName when NETWORK_NAME is unset', () => {
50
+ expect(() =>
51
+ loadContainerConfig({ SERVER_NAME: 'irc.test' } as ContainerConfigEnv),
52
+ ).toThrowError(/networkName/u);
46
53
  });
47
54
  });
48
55
 
@@ -47,7 +47,7 @@ new_sqlite_classes = ["IrcTcpOrigin"]
47
47
 
48
48
  # --- Environment ---
49
49
  [vars]
50
- SERVER_NAME = "irc.example.com"
50
+ SERVER_NAME = "irc.your-domain.invalid"
51
51
  NETWORK_NAME = "ExampleNet"
52
52
  MOTD_LINES = "Welcome to the ServerlessIRCd TCP origin."
53
53
  TCP_PORT = "6667"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-worker",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Worker deploy glue: WebSocket edge entry point + DO bindings + wrangler pipeline",
6
6
  "license": "BSD-3-Clause",
@@ -27,6 +27,7 @@
27
27
  "@cloudflare/vitest-pool-workers": "0.18.6",
28
28
  "@cloudflare/workers-types": "^5.20260714.1",
29
29
  "@types/ws": "^8.5.13",
30
+ "@vitest/coverage-istanbul": "^4.1.0",
30
31
  "@vitest/coverage-v8": "^4.1.0",
31
32
  "rimraf": "^6.0.0",
32
33
  "typescript": "^5.6.0",
@@ -5,7 +5,7 @@ export default defineConfig({
5
5
  test: {
6
6
  include: ['tests/**/*.test.ts'],
7
7
  coverage: {
8
- provider: 'v8',
8
+ provider: 'istanbul',
9
9
  all: false,
10
10
  include: ['src/**/*.ts'],
11
11
  reporter: ['text', 'html', 'json-summary'],
@@ -45,10 +45,12 @@ enabled = true
45
45
  invocation_logs = true
46
46
 
47
47
  ## Observable per-deployment knobs. Override per environment below.
48
- ## Production deployments SHOULD override `SERVER_NAME` to the public
49
- ## hostname clients will see in numerics (001, 005, etc.).
48
+ ## `SERVER_NAME` is REQUIRED — the CF config loader
49
+ ## (`loadServerConfigFromCfEnv`) fails fast at boot when it is unset.
50
+ ## Production deployments MUST override it to the public hostname
51
+ ## clients see in numerics (001, 005, etc.).
50
52
  [vars]
51
- SERVER_NAME = "irc.example.com"
53
+ SERVER_NAME = "irc.your-domain.invalid"
52
54
  NETWORK_NAME = "ServerlessIRCd"
53
55
  MOTD_LINES = """
54
56
  Welcome to ServerlessIRCd.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/local-cli",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "private": true,
5
5
  "description": "Runnable WebSocket IRC server using the in-memory runtime; manual-test harness and e2e fixture target",
6
6
  "license": "BSD-3-Clause",
@@ -13,14 +13,14 @@ import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc
13
13
 
14
14
  /**
15
15
  * CLI / programmatic input for `loadServerConfigFromCliArgs`. Every
16
- * field is optional except the two required schema fields
17
- * (`serverName`, `networkName`); the schema applies defaults to the
18
- * rest. Field types match the schema's input shape (numbers stay
19
- * numbers, etc.) so callers don't need to coerce strings.
16
+ * field is optional; the schema rejects a missing `serverName` or
17
+ * `networkName` with a readable error so the caller learns at boot
18
+ * rather than mid-session. Field types match the schema's input shape
19
+ * (numbers stay numbers, etc.) so callers don't need to coerce strings.
20
20
  */
21
21
  export interface CliConfigInput {
22
- serverName: string;
23
- networkName: string;
22
+ serverName?: string | undefined;
23
+ networkName?: string | undefined;
24
24
  /**
25
25
  * Server version surfaced in `002`/`004`/`351`/`371`. When omitted the
26
26
  * schema default ({@link DEFAULT_SERVER_VERSION}) applies.
@@ -31,9 +31,15 @@ interface CliArgs {
31
31
  tcpPort: number | undefined;
32
32
  /** undefined when --motd-file is not given; built-in default MOTD applies. */
33
33
  motdFile: string | undefined;
34
+ /** Server identity shown in 001/005 numerics. */
35
+ serverName: string;
36
+ /** Network name advertised in 005 NETWORK=... */
37
+ networkName: string;
34
38
  }
35
39
 
36
40
  const DEFAULT_PORT = 6667;
41
+ const DEFAULT_SERVER_NAME = 'irc.localhost';
42
+ const DEFAULT_NETWORK_NAME = 'LocalNet';
37
43
 
38
44
  function parseArgs(argv: string[]): CliArgs {
39
45
  const out: CliArgs = {
@@ -41,6 +47,8 @@ function parseArgs(argv: string[]): CliArgs {
41
47
  hostname: '127.0.0.1',
42
48
  tcpPort: undefined,
43
49
  motdFile: undefined,
50
+ serverName: DEFAULT_SERVER_NAME,
51
+ networkName: DEFAULT_NETWORK_NAME,
44
52
  };
45
53
  let tcpDisabled = false;
46
54
  let tcpPortOverride: number | undefined;
@@ -59,6 +67,12 @@ function parseArgs(argv: string[]): CliArgs {
59
67
  } else if (a === '--motd-file' && next !== undefined) {
60
68
  out.motdFile = next;
61
69
  i++;
70
+ } else if (a === '--server-name' && next !== undefined) {
71
+ out.serverName = next;
72
+ i++;
73
+ } else if (a === '--network-name' && next !== undefined) {
74
+ out.networkName = next;
75
+ i++;
62
76
  } else if (a === '--no-tcp') {
63
77
  tcpDisabled = true;
64
78
  } else if (a === '--help' || a === '-h') {
@@ -74,6 +88,10 @@ function parseArgs(argv: string[]): CliArgs {
74
88
  ' --no-tcp Disable the TCP listener (WebSocket only).',
75
89
  ' --motd-file <path> Read MOTD lines from this file (one per line).',
76
90
  ' Optional; overrides the built-in default MOTD.',
91
+ ' --server-name <h> Server hostname in 001/005 numerics.',
92
+ ' Optional; defaults to irc.localhost for the local CLI.',
93
+ ' --network-name <n> Network name in 005 NETWORK=...',
94
+ ' Optional; defaults to LocalNet.',
77
95
  ' -h, --help Show this help and exit.',
78
96
  '',
79
97
  ].join('\n'),
@@ -99,6 +117,8 @@ async function main(): Promise<void> {
99
117
  port: args.port,
100
118
  hostname: args.hostname,
101
119
  tcpPort: args.tcpPort,
120
+ serverName: args.serverName,
121
+ networkName: args.networkName,
102
122
  ...(motdLines !== undefined ? { motdLines } : {}),
103
123
  });
104
124