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,317 @@
1
+ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
2
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
3
+ import { type Clock, createConnection } from '@serverless-ircd/irc-core';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { AwsRuntime } from '../src/aws-runtime.js';
6
+ import type { AwsRuntimeHandlers } from '../src/aws-runtime.js';
7
+ import { TABLE_DEFS } from '../src/cdk-table-defs.js';
8
+ import { createDynamoDocumentClient } from '../src/dynamo.js';
9
+ import { AwsStats } from '../src/stats.js';
10
+ import type { TablesConfig } from '../src/tables.js';
11
+
12
+ const available = process.env.DDB_AVAILABLE === '1';
13
+ const endpoint = process.env.DYNAMO_ENDPOINT;
14
+
15
+ interface Fixture {
16
+ tables: TablesConfig;
17
+ client: ReturnType<typeof createDynamoDocumentClient>;
18
+ cleanup: () => Promise<void>;
19
+ }
20
+
21
+ let fx: Fixture;
22
+
23
+ async function makeFixture(): Promise<Fixture> {
24
+ if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
25
+ const prefix = `t${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
26
+ const tables = Object.fromEntries(
27
+ Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
28
+ ) as TablesConfig;
29
+ const client = createDynamoDocumentClient({ endpoint });
30
+ for (const [logical, props] of Object.entries(TABLE_DEFS)) {
31
+ const pkName = props.partitionKey?.name;
32
+ const skName = props.sortKey?.name;
33
+ if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
34
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
35
+ { AttributeName: pkName, AttributeType: 'S' },
36
+ ];
37
+ const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
38
+ { AttributeName: pkName, KeyType: 'HASH' },
39
+ ];
40
+ if (skName !== undefined) {
41
+ attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
42
+ keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
43
+ }
44
+ await client.send(
45
+ new CreateTableCommand({
46
+ TableName: `${prefix}${logical}`,
47
+ AttributeDefinitions: attributeDefinitions,
48
+ KeySchema: keySchema,
49
+ BillingMode: 'PAY_PER_REQUEST',
50
+ }),
51
+ );
52
+ }
53
+ return {
54
+ tables,
55
+ client,
56
+ cleanup: async () => {
57
+ for (const logical of Object.keys(TABLE_DEFS)) {
58
+ await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
59
+ }
60
+ },
61
+ };
62
+ }
63
+
64
+ function noopHandlers(): AwsRuntimeHandlers {
65
+ return {
66
+ send: () => {},
67
+ disconnect: () => {},
68
+ snapshot: () => undefined,
69
+ };
70
+ }
71
+
72
+ function makeRuntime(connId: string, clockFn: () => number = () => 1_000): AwsRuntime {
73
+ const clock: Clock = { now: clockFn };
74
+ return new AwsRuntime({
75
+ dynamo: fx.client,
76
+ tables: fx.tables,
77
+ connId,
78
+ handlers: noopHandlers(),
79
+ managementApi: null,
80
+ clock,
81
+ });
82
+ }
83
+
84
+ describe.skipIf(!available)('AwsStats', () => {
85
+ beforeEach(async () => {
86
+ fx = await makeFixture();
87
+ });
88
+ afterEach(async () => {
89
+ if (fx) await fx.cleanup();
90
+ });
91
+
92
+ it('returns zero counts for an empty deployment', async () => {
93
+ const stats = new AwsStats({
94
+ dynamo: fx.client,
95
+ tables: fx.tables,
96
+ uptimeStartedAt: 1_000,
97
+ });
98
+ const snap = await stats.getStats();
99
+ expect(snap.users).toBe(0);
100
+ expect(snap.channels).toBe(0);
101
+ expect(snap.servers).toBe(1);
102
+ expect(snap.localConns).toBe(0);
103
+ });
104
+
105
+ it('counts registered connections (visible, invisible, opers)', async () => {
106
+ const rt = makeRuntime('self');
107
+ const a = createConnection({ id: 'c1', connectedSince: 0 });
108
+ a.nick = 'alice';
109
+ a.registration = 'registered';
110
+ const b = createConnection({ id: 'c2', connectedSince: 0 });
111
+ b.nick = 'bob';
112
+ b.registration = 'registered';
113
+ b.userModes.invisible = true;
114
+ const c = createConnection({ id: 'c3', connectedSince: 0 });
115
+ c.nick = 'carol';
116
+ c.registration = 'registered';
117
+ c.userModes.oper = true;
118
+ const d = createConnection({ id: 'c4', connectedSince: 0 });
119
+ d.registration = 'pre-registration';
120
+ await rt.persistConnectionState(a);
121
+ await rt.persistConnectionState(b);
122
+ await rt.persistConnectionState(c);
123
+ await rt.persistConnectionState(d);
124
+
125
+ const stats = new AwsStats({
126
+ dynamo: fx.client,
127
+ tables: fx.tables,
128
+ uptimeStartedAt: 1_000,
129
+ });
130
+ const snap = await stats.getStats();
131
+ // 3 registered (2 visible + 1 invisible), 1 oper, 1 unknown, 4 total.
132
+ expect(snap.users).toBe(2);
133
+ expect(snap.invisible).toBe(1);
134
+ expect(snap.opers).toBe(1);
135
+ expect(snap.unknownConnections).toBe(1);
136
+ expect(snap.localConns).toBe(4);
137
+ });
138
+
139
+ it('counts channels via the ChannelMeta table', async () => {
140
+ const rt = makeRuntime('self');
141
+ await rt.applyChannelDelta('#a', {
142
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
143
+ });
144
+ await rt.applyChannelDelta('#b', {
145
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
146
+ });
147
+
148
+ const stats = new AwsStats({
149
+ dynamo: fx.client,
150
+ tables: fx.tables,
151
+ uptimeStartedAt: 1_000,
152
+ });
153
+ const snap = await stats.getStats();
154
+ expect(snap.channels).toBe(2);
155
+ });
156
+
157
+ it('echoes the supplied uptimeStartedAt', async () => {
158
+ const stats = new AwsStats({
159
+ dynamo: fx.client,
160
+ tables: fx.tables,
161
+ uptimeStartedAt: 7_777,
162
+ });
163
+ const snap = await stats.getStats();
164
+ expect(snap.uptimeStartedAt).toBe(7_777);
165
+ });
166
+
167
+ it('honours supplied maxLocalConns / maxGlobalConns overrides', async () => {
168
+ const rt = makeRuntime('self');
169
+ const a = createConnection({ id: 'c1', connectedSince: 0 });
170
+ a.nick = 'alice';
171
+ a.registration = 'registered';
172
+ await rt.persistConnectionState(a);
173
+
174
+ const stats = new AwsStats({
175
+ dynamo: fx.client,
176
+ tables: fx.tables,
177
+ uptimeStartedAt: 1_000,
178
+ maxLocalConns: 99,
179
+ maxGlobalConns: 88,
180
+ });
181
+ const snap = await stats.getStats();
182
+ expect(snap.maxLocalConns).toBe(99);
183
+ expect(snap.maxGlobalConns).toBe(88);
184
+ });
185
+ });
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Mock-based unit tests — exercise the defensive Scan fallbacks without
189
+ // requiring DynamoDB Local.
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /**
193
+ * Builds a stub DocumentClient whose `send` returns canned Scan output.
194
+ * Exercises the `?? []` and `?? 0` fallbacks in {@link AwsStats.getStats}
195
+ * that the DynamoDB-Local integration suite cannot reach (the SDK always
196
+ * returns defined arrays / counts there).
197
+ *
198
+ * Pass `connectionsItems: undefined` to simulate the SDK omitting the
199
+ * `Items` field entirely (the `?? []` fallback path).
200
+ */
201
+ function stubDynamo(
202
+ connectionsItems: unknown[] | undefined,
203
+ channelCount: number | undefined,
204
+ ): DynamoDBDocumentClient {
205
+ const calls: { TableName: string }[] = [];
206
+ const stub = {
207
+ send: async (cmd: {
208
+ input?: { TableName?: string; Select?: string };
209
+ TableName?: string;
210
+ Select?: string;
211
+ }) => {
212
+ // ScanCommand exposes its input via `.input`; both shapes are tolerated.
213
+ const input = (cmd as { input?: { TableName?: string; Select?: string } }).input ?? cmd;
214
+ const tableName = input.TableName ?? '';
215
+ const select = input.Select;
216
+ calls.push({ TableName: tableName });
217
+ void calls;
218
+ // Heuristic: the Connections scan omits `Select`, the ChannelMeta
219
+ // scan sets `Select: 'COUNT'`.
220
+ if (select === 'COUNT') {
221
+ return { Count: channelCount };
222
+ }
223
+ // Returning `{}` (no Items key) exercises the `?? []` fallback.
224
+ return connectionsItems === undefined ? {} : { Items: connectionsItems };
225
+ },
226
+ };
227
+ // Expose calls for any future assertions.
228
+ (stub as { _calls?: unknown })._calls = calls;
229
+ return stub as unknown as DynamoDBDocumentClient;
230
+ }
231
+
232
+ describe('AwsStats — defensive Scan fallbacks (mocked client)', () => {
233
+ // These tests do NOT require DynamoDB Local — they exercise the
234
+ // `?? []` / `?? 0` fallbacks by feeding a stub client.
235
+ it('returns empty arrays when the Connections Scan yields no Items field', async () => {
236
+ const tables: TablesConfig = {
237
+ Connections: 'Connections',
238
+ ChannelMeta: 'ChannelMeta',
239
+ ChannelMembers: 'ChannelMembers',
240
+ Nicks: 'Nicks',
241
+ Accounts: 'Accounts',
242
+ };
243
+ // Pass `undefined` so the stub returns `{}` — no Items key — to
244
+ // exercise the `?? []` fallback on the Connections scan, and
245
+ // `undefined` for Count to exercise the `?? 0` fallback.
246
+ const dynamo = stubDynamo(undefined, undefined);
247
+ const stats = new AwsStats({ dynamo, tables, uptimeStartedAt: 1_000 });
248
+ const snap = await stats.getStats();
249
+ expect(snap.users).toBe(0);
250
+ expect(snap.unknownConnections).toBe(0);
251
+ expect(snap.channels).toBe(0);
252
+ expect(snap.localConns).toBe(0);
253
+ });
254
+
255
+ it('honours maxLocalConns / maxGlobalConns overrides via the stub client', async () => {
256
+ const tables: TablesConfig = {
257
+ Connections: 'Connections',
258
+ ChannelMeta: 'ChannelMeta',
259
+ ChannelMembers: 'ChannelMembers',
260
+ Nicks: 'Nicks',
261
+ Accounts: 'Accounts',
262
+ };
263
+ const dynamo = stubDynamo([], 0);
264
+ const stats = new AwsStats({
265
+ dynamo,
266
+ tables,
267
+ uptimeStartedAt: 1_000,
268
+ maxLocalConns: 42,
269
+ maxGlobalConns: 43,
270
+ });
271
+ const snap = await stats.getStats();
272
+ expect(snap.maxLocalConns).toBe(42);
273
+ expect(snap.maxGlobalConns).toBe(43);
274
+ });
275
+
276
+ it('counts connections returned by the stub client', async () => {
277
+ const tables: TablesConfig = {
278
+ Connections: 'Connections',
279
+ ChannelMeta: 'ChannelMeta',
280
+ ChannelMembers: 'ChannelMembers',
281
+ Nicks: 'Nicks',
282
+ Accounts: 'Accounts',
283
+ };
284
+ // Two registered connection rows.
285
+ const items = [
286
+ {
287
+ connectionId: 'c1',
288
+ registration: 'registered',
289
+ capNegotiating: false,
290
+ caps: [],
291
+ userModes: { invisible: false, oper: false, wallops: false, serverNotices: false },
292
+ lastSeen: 0,
293
+ connectedSince: 0,
294
+ idleSince: 0,
295
+ version: 1,
296
+ },
297
+ {
298
+ connectionId: 'c2',
299
+ registration: 'registered',
300
+ capNegotiating: false,
301
+ caps: [],
302
+ userModes: { invisible: true, oper: false, wallops: false, serverNotices: false },
303
+ lastSeen: 0,
304
+ connectedSince: 0,
305
+ idleSince: 0,
306
+ version: 1,
307
+ },
308
+ ];
309
+ const dynamo = stubDynamo(items, 3);
310
+ const stats = new AwsStats({ dynamo, tables, uptimeStartedAt: 1_000 });
311
+ const snap = await stats.getStats();
312
+ expect(snap.users).toBe(1); // 2 registered − 1 invisible
313
+ expect(snap.invisible).toBe(1);
314
+ expect(snap.channels).toBe(3);
315
+ expect(snap.localConns).toBe(2);
316
+ });
317
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-adapter",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Workers adapter: Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and CfRuntime implementing IrcRuntime",
6
6
  "license": "BSD-3-Clause",
@@ -11,6 +11,10 @@
11
11
  ".": {
12
12
  "types": "./dist/index.d.ts",
13
13
  "import": "./dist/index.js"
14
+ },
15
+ "./accounts": {
16
+ "types": "./dist/d1-account-store.d.ts",
17
+ "import": "./dist/d1-account-store.js"
14
18
  }
15
19
  },
16
20
  "scripts": {
@@ -31,6 +35,7 @@
31
35
  "@cloudflare/workers-types": "^5.20260714.1",
32
36
  "@serverless-ircd/in-memory-runtime": "workspace:*",
33
37
  "@serverless-ircd/irc-test-support": "workspace:*",
38
+ "@vitest/coverage-istanbul": "^4.1.0",
34
39
  "@vitest/coverage-v8": "^4.1.0",
35
40
  "rimraf": "^6.0.0",
36
41
  "typescript": "^5.6.0",
@@ -35,9 +35,12 @@ import {
35
35
  type Nick,
36
36
  type RawLine,
37
37
  type RosterEntry,
38
+ type ServerConfig,
38
39
  toSnapshot,
39
40
  } from '@serverless-ircd/irc-core';
40
41
  import type { IrcRuntime } from '@serverless-ircd/irc-server';
42
+ import type { CfConfigEnv } from './config-loader.js';
43
+ import { loadServerConfigFromCfEnv } from './config-loader.js';
41
44
  import type { ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
42
45
  import type { PersistedConnectionState } from './serialize.js';
43
46
  import { deserialize } from './serialize.js';
@@ -144,6 +147,48 @@ export class CfRuntime implements IrcRuntime {
144
147
  }
145
148
  }
146
149
 
150
+ /**
151
+ * Global cross-connection fan-out for `WALLOPS`. Enumerates every registry
152
+ * shard to collect all live connection ids, fetches each connection's
153
+ * state to honour the `+w` recipient gate, and delivers via the
154
+ * ConnectionDO `deliver` RPC. `except` (the originator) is skipped, as is
155
+ * the bound connection's own live state shortcut.
156
+ *
157
+ * Cost: this is N shard RPCs + one `getConnState` per online connection +
158
+ * one `deliver` per `+w` recipient. At very high connection counts the
159
+ * per-WALLOPS cost grows linearly with the fleet; deployments should bound
160
+ * the oper's WALLOPS rate (an oper flooding WALLOPS is a self-DoS). The
161
+ * shard walk is parallelised via `Promise.all` so wall-clock stays near one
162
+ * round-trip for the enumeration phase.
163
+ */
164
+ async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
165
+ // Phase 1 — enumerate every connId across all registry shards.
166
+ const shardEntries = await Promise.all(
167
+ Array.from({ length: this.registryShards }, (_, i) => this.registryShardRpc(i).listEntries()),
168
+ );
169
+ const seen = new Set<ConnId>();
170
+ for (const entries of shardEntries) {
171
+ for (const [, connId] of entries) {
172
+ seen.add(connId);
173
+ }
174
+ }
175
+
176
+ // Phase 2 — fetch each candidate's state, filter +w, deliver.
177
+ // Bound the per-call fan-out by skipping the excepted/bound connection
178
+ // up front (the bound connection's own +w never needs a self-RPC).
179
+ await Promise.all(
180
+ Array.from(seen, async (connId) => {
181
+ if (connId === except || connId === this.connId) return;
182
+ const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(connId));
183
+ const dto = await (stub as unknown as ConnectionRpc).getConnState();
184
+ if (dto === null) return;
185
+ const state = deserialize(dto);
186
+ if (!state.userModes.wallops) return;
187
+ await (stub as unknown as ConnectionRpc).deliver(lines);
188
+ }),
189
+ );
190
+ }
191
+
147
192
  // -------------------------------------------------------------------------
148
193
  // Nick registry
149
194
  // -------------------------------------------------------------------------
@@ -265,6 +310,16 @@ export class CfRuntime implements IrcRuntime {
265
310
  return snapshots.filter((s): s is ChanSnapshot => s !== null);
266
311
  }
267
312
 
313
+ /**
314
+ * Re-reads the bound Workers env (vars + secrets, the config source for
315
+ * Cloudflare deployments) and reparses it through the shared schema. The
316
+ * actor swaps its live config with the result; a rejection leaves the
317
+ * previous config in effect.
318
+ */
319
+ async reloadConfig(): Promise<ServerConfig> {
320
+ return loadServerConfigFromCfEnv(this.env as unknown as CfConfigEnv);
321
+ }
322
+
268
323
  // -------------------------------------------------------------------------
269
324
  // Internal
270
325
  // -------------------------------------------------------------------------
@@ -281,6 +336,16 @@ export class CfRuntime implements IrcRuntime {
281
336
  return stub as unknown as RegistryRpc;
282
337
  }
283
338
 
339
+ /**
340
+ * Returns the registry DO RPC stub for shard index `i` (`0 <= i < shards`).
341
+ * Used by global fan-out paths (WALLOPS) that must enumerate every shard
342
+ * rather than address the one owning a specific nick.
343
+ */
344
+ private registryShardRpc(i: number): RegistryRpc {
345
+ const stub = this.env.REGISTRY_DO.get(this.env.REGISTRY_DO.idFromName(`s${i}`));
346
+ return stub as unknown as RegistryRpc;
347
+ }
348
+
284
349
  /** Returns the channel DO RPC stub for `name` (keyed by lowercased name). */
285
350
  private channelRpc(name: ChanName): {
286
351
  broadcast(lines: unknown[], except?: string): Promise<void>;
@@ -319,7 +384,7 @@ export function makeCfRuntime(
319
384
  handlers: CfConnectionHandlers,
320
385
  ): IrcRuntime {
321
386
  // `_registryKey` is ignored — the production CfRuntime always shards the
322
- // registry by nick (the prior per-conn key was a 033 scaffolding
387
+ // registry by nick (the prior per-conn key was a scaffolding
323
388
  // convention that the real RegistryDO does not use).
324
389
  void _registryKey;
325
390
  return new CfRuntime(env, connId, handlers);
@@ -30,7 +30,7 @@
30
30
  * RPC return-type note: snapshot reads return the JSON-friendly
31
31
  * {@link ChannelSnapshotDto} (members/banMasks/pendingInvites as
32
32
  * arrays) so they survive the DO RPC boundary without relying on
33
- * structured-clone of `Map`/`Set`. The CfRuntime (036)
33
+ * structured-clone of `Map`/`Set`. The CfRuntime
34
34
  * rehydrates these into a full {@link ChanSnapshot} at the call site.
35
35
  */
36
36
 
@@ -89,7 +89,7 @@ export interface ConnectionDeliveryRpc {
89
89
  * JSON-friendly snapshot DTO returned by `getChannelSnapshot`. Maps and
90
90
  * Sets are flattened to arrays so the value survives the DO RPC
91
91
  * boundary in both production and the test pool. The CfRuntime
92
- * (036) rehydrates this into a {@link ChanSnapshot} at the
92
+ * rehydrates this into a {@link ChanSnapshot} at the
93
93
  * call site.
94
94
  */
95
95
  export interface ChannelSnapshotDto {