serverless-ircd 0.3.0 → 0.5.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 (223) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +349 -18
  8. package/README.md +132 -30
  9. package/apps/aws-stack/README.md +6 -5
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +150 -10
  13. package/apps/aws-stack/tests/stack.test.ts +145 -4
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +149 -24
  38. package/apps/local-cli/tests/e2e.test.ts +1 -1
  39. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  40. package/package.json +14 -10
  41. package/packages/aws-adapter/package.json +3 -3
  42. package/packages/aws-adapter/src/account-store.ts +1 -1
  43. package/packages/aws-adapter/src/admission.ts +74 -0
  44. package/packages/aws-adapter/src/aws-runtime.ts +75 -4
  45. package/packages/aws-adapter/src/config-loader.ts +32 -0
  46. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  47. package/packages/aws-adapter/src/handlers/connect.ts +96 -9
  48. package/packages/aws-adapter/src/handlers/default.ts +98 -7
  49. package/packages/aws-adapter/src/handlers/index.ts +109 -4
  50. package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
  51. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  52. package/packages/aws-adapter/src/index.ts +8 -0
  53. package/packages/aws-adapter/src/serialize.ts +11 -1
  54. package/packages/aws-adapter/src/stats.ts +80 -0
  55. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  56. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  57. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  58. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  59. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  60. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  61. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  62. package/packages/aws-adapter/tests/connect.test.ts +174 -0
  63. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  64. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  65. package/packages/aws-adapter/tests/handlers.test.ts +302 -53
  66. package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
  67. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  68. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +5 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +68 -5
  73. package/packages/cf-adapter/src/channel-do.ts +2 -2
  74. package/packages/cf-adapter/src/config-loader.ts +33 -0
  75. package/packages/cf-adapter/src/connection-do.ts +278 -85
  76. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  77. package/packages/cf-adapter/src/env.ts +54 -11
  78. package/packages/cf-adapter/src/index.ts +11 -8
  79. package/packages/cf-adapter/src/registry-do.ts +22 -3
  80. package/packages/cf-adapter/src/sharding.ts +1 -2
  81. package/packages/cf-adapter/src/stats.ts +65 -0
  82. package/packages/cf-adapter/tests/cf-harness.ts +12 -2
  83. package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
  84. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  85. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  86. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  87. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  88. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  89. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  90. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  91. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  92. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  93. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  94. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  95. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  96. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  97. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  98. package/packages/cf-adapter/tests/worker/main.ts +15 -8
  99. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  100. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  101. package/packages/cf-adapter/wrangler.test.toml +15 -0
  102. package/packages/in-memory-runtime/package.json +1 -1
  103. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  104. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  105. package/packages/irc-core/package.json +6 -1
  106. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  107. package/packages/irc-core/src/admission.ts +16 -15
  108. package/packages/irc-core/src/caps/capabilities.ts +24 -3
  109. package/packages/irc-core/src/cloak.ts +1 -1
  110. package/packages/irc-core/src/commands/cap.ts +8 -1
  111. package/packages/irc-core/src/commands/index.ts +17 -0
  112. package/packages/irc-core/src/commands/invite.ts +2 -4
  113. package/packages/irc-core/src/commands/ison.ts +61 -0
  114. package/packages/irc-core/src/commands/isupport.ts +6 -2
  115. package/packages/irc-core/src/commands/kick.ts +2 -4
  116. package/packages/irc-core/src/commands/kill.ts +127 -0
  117. package/packages/irc-core/src/commands/list.ts +1 -1
  118. package/packages/irc-core/src/commands/lusers.ts +204 -0
  119. package/packages/irc-core/src/commands/mode.ts +4 -8
  120. package/packages/irc-core/src/commands/names.ts +3 -5
  121. package/packages/irc-core/src/commands/part.ts +2 -4
  122. package/packages/irc-core/src/commands/quit.ts +12 -0
  123. package/packages/irc-core/src/commands/registration.ts +18 -12
  124. package/packages/irc-core/src/commands/rehash.ts +119 -0
  125. package/packages/irc-core/src/commands/sasl.ts +72 -9
  126. package/packages/irc-core/src/commands/server-info.ts +129 -0
  127. package/packages/irc-core/src/commands/setname.ts +109 -0
  128. package/packages/irc-core/src/commands/stats.ts +152 -0
  129. package/packages/irc-core/src/commands/topic.ts +2 -4
  130. package/packages/irc-core/src/commands/trace.ts +137 -0
  131. package/packages/irc-core/src/commands/userhost.ts +84 -0
  132. package/packages/irc-core/src/commands/wallops.ts +118 -0
  133. package/packages/irc-core/src/commands/whowas.ts +113 -0
  134. package/packages/irc-core/src/config.ts +65 -0
  135. package/packages/irc-core/src/credential-hashing.ts +124 -0
  136. package/packages/irc-core/src/effects.ts +33 -30
  137. package/packages/irc-core/src/index.ts +3 -0
  138. package/packages/irc-core/src/ports.ts +360 -12
  139. package/packages/irc-core/src/protocol/numerics.ts +48 -11
  140. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  141. package/packages/irc-core/src/types.ts +46 -2
  142. package/packages/irc-core/src/ws-framing.ts +132 -0
  143. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  144. package/packages/irc-core/tests/account-store.test.ts +45 -2
  145. package/packages/irc-core/tests/admission.test.ts +18 -0
  146. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  147. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  148. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  149. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  150. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  151. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  152. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  153. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  154. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  155. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  156. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  157. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  158. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  159. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  160. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  161. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  162. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  163. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  164. package/packages/irc-core/tests/config.test.ts +95 -1
  165. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  166. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  167. package/packages/irc-core/tests/effects.test.ts +14 -27
  168. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  169. package/packages/irc-core/tests/numerics.test.ts +102 -0
  170. package/packages/irc-core/tests/outbound.test.ts +51 -0
  171. package/packages/irc-core/tests/ports.test.ts +22 -0
  172. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  173. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  174. package/packages/irc-core/tests/types.test.ts +35 -1
  175. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  176. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  177. package/packages/irc-core/tsconfig.build.json +1 -1
  178. package/packages/irc-core/tsconfig.test.json +1 -1
  179. package/packages/irc-server/package.json +1 -1
  180. package/packages/irc-server/src/actor.ts +393 -16
  181. package/packages/irc-server/src/dispatch.ts +1 -3
  182. package/packages/irc-server/src/index.ts +10 -2
  183. package/packages/irc-server/src/routing.ts +15 -0
  184. package/packages/irc-server/src/runtime.ts +31 -0
  185. package/packages/irc-server/src/transport.ts +104 -0
  186. package/packages/irc-server/tests/actor.test.ts +1489 -4
  187. package/packages/irc-server/tests/dispatch.test.ts +37 -17
  188. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  189. package/packages/irc-server/tests/routing.test.ts +5 -0
  190. package/packages/irc-server/tests/runtime.test.ts +7 -0
  191. package/packages/irc-server/tests/transport.test.ts +230 -0
  192. package/packages/irc-test-support/package.json +1 -1
  193. package/packages/irc-test-support/src/harness.ts +44 -9
  194. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  195. package/packages/irc-test-support/src/index.ts +3 -0
  196. package/packages/irc-test-support/src/scenarios.ts +141 -3
  197. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  198. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  199. package/pnpm-workspace.yaml +10 -1
  200. package/tools/ci-hardening/package.json +1 -1
  201. package/tools/package.json +9 -0
  202. package/tools/seed-aws-accounts.ts +5 -6
  203. package/tools/seed-cf-accounts.ts +107 -0
  204. package/tools/tcp-ws-forwarder/package.json +1 -1
  205. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  206. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  207. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
  208. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  209. package/docs/ADR-002-location-of-authority.md +0 -82
  210. package/docs/ADR-003-durable-object-sharding.md +0 -93
  211. package/docs/ADR-004-dynamodb-schema.md +0 -96
  212. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  213. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  214. package/docs/ADR-007-deterministic-ports.md +0 -82
  215. package/docs/ADR-008-monorepo-tooling.md +0 -60
  216. package/docs/AWS-Adapter-Architecture.md +0 -496
  217. package/docs/AWS-Deployment.md +0 -1186
  218. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  219. package/docs/Home.md +0 -11
  220. package/docs/Observability.md +0 -87
  221. package/docs/PlanIRCv3Websocket.md +0 -489
  222. package/docs/PlanWebClient.md +0 -451
  223. package/docs/Release-Process.md +0 -443
@@ -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
+ });
@@ -34,6 +34,11 @@ interface Fixture {
34
34
 
35
35
  let fx: Fixture | null;
36
36
 
37
+ function requireFx(): Fixture {
38
+ if (!fx) throw new Error('test fixture not initialized');
39
+ return fx;
40
+ }
41
+
37
42
  async function makeFixture(): Promise<Fixture> {
38
43
  if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
39
44
  const prefix = `s${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
@@ -81,8 +86,8 @@ async function makeFixture(): Promise<Fixture> {
81
86
 
82
87
  function params(overrides: Partial<SweepParams> = {}): SweepParams {
83
88
  return {
84
- dynamo: fx!.client,
85
- tables: fx!.tables,
89
+ dynamo: requireFx().client,
90
+ tables: requireFx().tables,
86
91
  ...overrides,
87
92
  };
88
93
  }
@@ -100,18 +105,18 @@ async function seedConnection(opts: {
100
105
  if (opts.nick !== undefined) {
101
106
  state.nick = opts.nick as Nick;
102
107
  state.user = opts.nick;
103
- await fx!.client.send(
108
+ await requireFx().client.send(
104
109
  new PutCommand({
105
- TableName: fx!.tables.Nicks,
110
+ TableName: requireFx().tables.Nicks,
106
111
  Item: marshalNick(opts.nick as Nick, opts.connId, opts.idleSince),
107
112
  }),
108
113
  );
109
114
  }
110
115
  for (const chan of opts.channels ?? []) {
111
116
  state.joinedChannels.add(chan);
112
- await fx!.client.send(
117
+ await requireFx().client.send(
113
118
  new PutCommand({
114
- TableName: fx!.tables.ChannelMembers,
119
+ TableName: requireFx().tables.ChannelMembers,
115
120
  Item: marshalChannelMember(chan.toLowerCase(), {
116
121
  conn: opts.connId,
117
122
  nick: (opts.nick ?? 'x') as Nick,
@@ -121,9 +126,9 @@ async function seedConnection(opts: {
121
126
  }),
122
127
  );
123
128
  }
124
- await fx!.client.send(
129
+ await requireFx().client.send(
125
130
  new PutCommand({
126
- TableName: fx!.tables.Connections,
131
+ TableName: requireFx().tables.Connections,
127
132
  Item: marshalConnection(state, opts.idleSince),
128
133
  }),
129
134
  );
@@ -131,16 +136,16 @@ async function seedConnection(opts: {
131
136
 
132
137
  async function connectionExists(connId: string): Promise<boolean> {
133
138
  const { GetCommand } = await import('@aws-sdk/lib-dynamodb');
134
- const res = await fx!.client.send(
135
- new GetCommand({ TableName: fx!.tables.Connections, Key: { connectionId: connId } }),
139
+ const res = await requireFx().client.send(
140
+ new GetCommand({ TableName: requireFx().tables.Connections, Key: { connectionId: connId } }),
136
141
  );
137
142
  return res.Item !== undefined;
138
143
  }
139
144
 
140
145
  async function membersOf(channelKey: string): Promise<{ connectionId: string }[]> {
141
- const res = await fx!.client.send(
146
+ const res = await requireFx().client.send(
142
147
  new QueryCommand({
143
- TableName: fx!.tables.ChannelMembers,
148
+ TableName: requireFx().tables.ChannelMembers,
144
149
  KeyConditionExpression: 'channelName = :k',
145
150
  ExpressionAttributeValues: { ':k': channelKey },
146
151
  }),
@@ -150,8 +155,8 @@ async function membersOf(channelKey: string): Promise<{ connectionId: string }[]
150
155
 
151
156
  async function nickExists(nick: string): Promise<boolean> {
152
157
  const { GetCommand } = await import('@aws-sdk/lib-dynamodb');
153
- const res = await fx!.client.send(
154
- new GetCommand({ TableName: fx!.tables.Nicks, Key: { nickLower: nick.toLowerCase() } }),
158
+ const res = await requireFx().client.send(
159
+ new GetCommand({ TableName: requireFx().tables.Nicks, Key: { nickLower: nick.toLowerCase() } }),
155
160
  );
156
161
  return res.Item !== undefined;
157
162
  }
@@ -281,8 +286,10 @@ describe.skipIf(!available)('gone-connection sweeper', () => {
281
286
 
282
287
  it('cleanupConnection is safe to call on a row the sweeper already removed (no throw)', async () => {
283
288
  await seedConnection({ connId: 'stale', idleSince: 0, nick: 'zed' });
284
- await cleanupConnection(fx!.client, fx!.tables, 'stale', null);
285
- await expect(cleanupConnection(fx!.client, fx!.tables, 'stale', null)).resolves.toBeUndefined();
289
+ await cleanupConnection(requireFx().client, requireFx().tables, 'stale', null);
290
+ await expect(
291
+ cleanupConnection(requireFx().client, requireFx().tables, 'stale', null),
292
+ ).resolves.toBeUndefined();
286
293
  });
287
294
 
288
295
  it('uses Date.now() when now is omitted (real-clock default path)', async () => {
@@ -297,9 +304,9 @@ describe.skipIf(!available)('gone-connection sweeper', () => {
297
304
 
298
305
  it('skips a row whose idleSince is not a number (defensive — corrupted row)', async () => {
299
306
  // Write a Connections row directly with a non-numeric idleSince.
300
- await fx!.client.send(
307
+ await requireFx().client.send(
301
308
  new PutCommand({
302
- TableName: fx!.tables.Connections,
309
+ TableName: requireFx().tables.Connections,
303
310
  Item: {
304
311
  connectionId: 'corrupt',
305
312
  registration: 'registered',
@@ -37,6 +37,11 @@ interface Fixture {
37
37
 
38
38
  let fx: Fixture | null;
39
39
 
40
+ function requireFx(): Fixture {
41
+ if (!fx) throw new Error('test fixture not initialized');
42
+ return fx;
43
+ }
44
+
40
45
  async function makeFixture(): Promise<Fixture> {
41
46
  if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
42
47
  const prefix = `x${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
@@ -89,8 +94,8 @@ function makeRuntime(connId: string): AwsRuntime {
89
94
  snapshot: () => undefined,
90
95
  };
91
96
  return new AwsRuntime({
92
- dynamo: fx!.client,
93
- tables: fx!.tables,
97
+ dynamo: requireFx().client,
98
+ tables: requireFx().tables,
94
99
  connId,
95
100
  handlers,
96
101
  managementApi: null,
@@ -100,9 +105,9 @@ function makeRuntime(connId: string): AwsRuntime {
100
105
  /** Persists a fresh Connections row with no joined channels. */
101
106
  async function seedConnection(connId: string, idleSince = 1_000): Promise<void> {
102
107
  const state = createConnection({ id: connId, connectedSince: idleSince });
103
- await fx!.client.send(
108
+ await requireFx().client.send(
104
109
  new PutCommand({
105
- TableName: fx!.tables.Connections,
110
+ TableName: requireFx().tables.Connections,
106
111
  Item: marshalConnection(state, idleSince),
107
112
  }),
108
113
  );
@@ -110,9 +115,9 @@ async function seedConnection(connId: string, idleSince = 1_000): Promise<void>
110
115
 
111
116
  /** Reads the raw Connections row (so we can inspect the stored shape). */
112
117
  async function readConnection(connId: string): Promise<{ joinedChannels?: unknown } | undefined> {
113
- const res = await fx!.client.send(
118
+ const res = await requireFx().client.send(
114
119
  new GetCommand({
115
- TableName: fx!.tables.Connections,
120
+ TableName: requireFx().tables.Connections,
116
121
  Key: { connectionId: connId },
117
122
  }),
118
123
  );
@@ -121,9 +126,9 @@ async function readConnection(connId: string): Promise<{ joinedChannels?: unknow
121
126
 
122
127
  /** Reads every ChannelMembers row for a channel. */
123
128
  async function readMembers(channelKey: string): Promise<{ connectionId: string }[]> {
124
- const res = await fx!.client.send(
129
+ const res = await requireFx().client.send(
125
130
  new QueryCommand({
126
- TableName: fx!.tables.ChannelMembers,
131
+ TableName: requireFx().tables.ChannelMembers,
127
132
  KeyConditionExpression: 'channelName = :k',
128
133
  ExpressionAttributeValues: { ':k': channelKey },
129
134
  }),
@@ -137,15 +142,15 @@ async function seedMembership(connId: string, nick: string, channel: ChanName):
137
142
  state.registration = 'registered';
138
143
  state.nick = nick as Nick;
139
144
  state.joinedChannels.add(channel);
140
- await fx!.client.send(
145
+ await requireFx().client.send(
141
146
  new PutCommand({
142
- TableName: fx!.tables.Connections,
147
+ TableName: requireFx().tables.Connections,
143
148
  Item: marshalConnection(state, 1_000),
144
149
  }),
145
150
  );
146
- await fx!.client.send(
151
+ await requireFx().client.send(
147
152
  new PutCommand({
148
- TableName: fx!.tables.ChannelMembers,
153
+ TableName: requireFx().tables.ChannelMembers,
149
154
  Item: marshalChannelMember(channel.toLowerCase(), {
150
155
  conn: connId,
151
156
  nick: nick as Nick,
@@ -182,8 +187,8 @@ describe.skipIf(!available)('membership transactions', () => {
182
187
 
183
188
  const row = await readConnection('c1');
184
189
  expect(row).toBeDefined();
185
- expect(row!.joinedChannels).toBeInstanceOf(Set);
186
- expect((row!.joinedChannels as Set<string>).has('#foo')).toBe(true);
190
+ expect(row?.joinedChannels).toBeInstanceOf(Set);
191
+ expect((row?.joinedChannels as Set<string>).has('#foo')).toBe(true);
187
192
  });
188
193
 
189
194
  it('PART removes both the ChannelMembers row and the channel from joinedChannels', async () => {
@@ -199,7 +204,7 @@ describe.skipIf(!available)('membership transactions', () => {
199
204
 
200
205
  const row = await readConnection('c1');
201
206
  expect(row).toBeDefined();
202
- expect((row!.joinedChannels as Set<string> | undefined)?.has('#foo') ?? false).toBe(false);
207
+ expect((row?.joinedChannels as Set<string> | undefined)?.has('#foo') ?? false).toBe(false);
203
208
  });
204
209
 
205
210
  it('KICK (remove of a different connection) drops the target from joinedChannels', async () => {
@@ -211,7 +216,7 @@ describe.skipIf(!available)('membership transactions', () => {
211
216
  });
212
217
 
213
218
  const row = await readConnection('target');
214
- expect((row!.joinedChannels as Set<string> | undefined)?.has('#room') ?? false).toBe(false);
219
+ expect((row?.joinedChannels as Set<string> | undefined)?.has('#room') ?? false).toBe(false);
215
220
  expect(await readMembers('#room')).toHaveLength(0);
216
221
  });
217
222
 
@@ -234,7 +239,7 @@ describe.skipIf(!available)('membership transactions', () => {
234
239
  ]);
235
240
 
236
241
  const row = await readConnection('c1');
237
- const joined = row!.joinedChannels as Set<string>;
242
+ const joined = row?.joinedChannels as Set<string>;
238
243
  expect(joined.has('#foo')).toBe(true);
239
244
  expect(joined.has('#bar')).toBe(true);
240
245
 
@@ -260,7 +265,7 @@ describe.skipIf(!available)('membership transactions', () => {
260
265
 
261
266
  expect(await readMembers('#foo')).toHaveLength(1);
262
267
  const row = await readConnection('c1');
263
- const joined = row!.joinedChannels as Set<string>;
268
+ const joined = row?.joinedChannels as Set<string>;
264
269
  expect(joined.has('#foo')).toBe(true);
265
270
  expect([...joined].filter((c) => c === '#foo')).toHaveLength(1);
266
271
  });
@@ -283,7 +288,7 @@ describe.skipIf(!available)('membership transactions', () => {
283
288
 
284
289
  expect(await readMembers('#foo')).toHaveLength(0);
285
290
  const row = await readConnection('c1');
286
- expect((row!.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
291
+ expect((row?.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
287
292
  });
288
293
 
289
294
  // -------------------------------------------------------------------------
@@ -303,7 +308,7 @@ describe.skipIf(!available)('membership transactions', () => {
303
308
 
304
309
  expect(await readMembers('#foo')).toHaveLength(0);
305
310
  const row = await readConnection('c1');
306
- expect((row!.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
311
+ expect((row?.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
307
312
  });
308
313
  });
309
314
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-adapter",
3
- "version": "0.3.0",
3
+ "version": "0.5.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": {