serverless-ircd 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Membership atomicity for JOIN / PART / KICK (the DynamoDB
3
+ * `TransactWriteItems` path).
4
+ *
5
+ * Each membership mutation must span BOTH tables in a single transaction:
6
+ * - `ChannelMembers` (the authoritative roster), AND
7
+ * - `Connections.joinedChannels` (the per-connection joined set used
8
+ * by `$disconnect` cleanup).
9
+ *
10
+ * Without the transaction, a concurrent JOIN of two different channels
11
+ * by the same connection leaves `joinedChannels` missing one of them
12
+ * (the second `persistConnectionState` overwrites the first). The
13
+ * stress test below proves the transactions serialise correctly.
14
+ *
15
+ * Gated on `process.env.DDB_AVAILABLE` — needs real DynamoDB Local
16
+ * (TransactWriteItems semantics aren't faithfully mockable).
17
+ */
18
+
19
+ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
20
+ import { GetCommand, PutCommand, QueryCommand, TransactWriteCommand } from '@aws-sdk/lib-dynamodb';
21
+ import { type ChanName, type Nick, createConnection } from '@serverless-ircd/irc-core';
22
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
23
+ import { AwsRuntime, type AwsRuntimeHandlers } from '../src/aws-runtime.js';
24
+ import { TABLE_DEFS } from '../src/cdk-table-defs.js';
25
+ import { createDynamoDocumentClient } from '../src/dynamo.js';
26
+ import { marshalChannelMember, marshalConnection } from '../src/serialize.js';
27
+ import type { TablesConfig } from '../src/tables.js';
28
+
29
+ const available = process.env.DDB_AVAILABLE === '1';
30
+ const endpoint = process.env.DYNAMO_ENDPOINT;
31
+
32
+ interface Fixture {
33
+ tables: TablesConfig;
34
+ client: ReturnType<typeof createDynamoDocumentClient>;
35
+ cleanup: () => Promise<void>;
36
+ }
37
+
38
+ let fx: Fixture | null;
39
+
40
+ async function makeFixture(): Promise<Fixture> {
41
+ if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
42
+ const prefix = `x${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
43
+ const tables = Object.fromEntries(
44
+ Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
45
+ ) as TablesConfig;
46
+ const client = createDynamoDocumentClient({ endpoint });
47
+ for (const [logical, props] of Object.entries(TABLE_DEFS)) {
48
+ const pkName = props.partitionKey?.name;
49
+ const skName = props.sortKey?.name;
50
+ if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
51
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
52
+ { AttributeName: pkName, AttributeType: 'S' },
53
+ ];
54
+ const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
55
+ { AttributeName: pkName, KeyType: 'HASH' },
56
+ ];
57
+ if (skName !== undefined) {
58
+ attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
59
+ keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
60
+ }
61
+ await client.send(
62
+ new CreateTableCommand({
63
+ TableName: `${prefix}${logical}`,
64
+ AttributeDefinitions: attributeDefinitions,
65
+ KeySchema: keySchema,
66
+ BillingMode: 'PAY_PER_REQUEST',
67
+ }),
68
+ );
69
+ }
70
+ return {
71
+ tables,
72
+ client,
73
+ cleanup: async () => {
74
+ for (const logical of Object.keys(TABLE_DEFS)) {
75
+ try {
76
+ await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
77
+ } catch {
78
+ // best-effort
79
+ }
80
+ }
81
+ },
82
+ };
83
+ }
84
+
85
+ function makeRuntime(connId: string): AwsRuntime {
86
+ const handlers: AwsRuntimeHandlers = {
87
+ send: () => {},
88
+ disconnect: () => {},
89
+ snapshot: () => undefined,
90
+ };
91
+ return new AwsRuntime({
92
+ dynamo: fx!.client,
93
+ tables: fx!.tables,
94
+ connId,
95
+ handlers,
96
+ managementApi: null,
97
+ });
98
+ }
99
+
100
+ /** Persists a fresh Connections row with no joined channels. */
101
+ async function seedConnection(connId: string, idleSince = 1_000): Promise<void> {
102
+ const state = createConnection({ id: connId, connectedSince: idleSince });
103
+ await fx!.client.send(
104
+ new PutCommand({
105
+ TableName: fx!.tables.Connections,
106
+ Item: marshalConnection(state, idleSince),
107
+ }),
108
+ );
109
+ }
110
+
111
+ /** Reads the raw Connections row (so we can inspect the stored shape). */
112
+ async function readConnection(connId: string): Promise<{ joinedChannels?: unknown } | undefined> {
113
+ const res = await fx!.client.send(
114
+ new GetCommand({
115
+ TableName: fx!.tables.Connections,
116
+ Key: { connectionId: connId },
117
+ }),
118
+ );
119
+ return res.Item as { joinedChannels?: unknown } | undefined;
120
+ }
121
+
122
+ /** Reads every ChannelMembers row for a channel. */
123
+ async function readMembers(channelKey: string): Promise<{ connectionId: string }[]> {
124
+ const res = await fx!.client.send(
125
+ new QueryCommand({
126
+ TableName: fx!.tables.ChannelMembers,
127
+ KeyConditionExpression: 'channelName = :k',
128
+ ExpressionAttributeValues: { ':k': channelKey },
129
+ }),
130
+ );
131
+ return (res.Items ?? []) as { connectionId: string }[];
132
+ }
133
+
134
+ /** Seeds a ChannelMembers row + reflects it on the connection's joined set. */
135
+ async function seedMembership(connId: string, nick: string, channel: ChanName): Promise<void> {
136
+ const state = createConnection({ id: connId, connectedSince: 1_000 });
137
+ state.registration = 'registered';
138
+ state.nick = nick as Nick;
139
+ state.joinedChannels.add(channel);
140
+ await fx!.client.send(
141
+ new PutCommand({
142
+ TableName: fx!.tables.Connections,
143
+ Item: marshalConnection(state, 1_000),
144
+ }),
145
+ );
146
+ await fx!.client.send(
147
+ new PutCommand({
148
+ TableName: fx!.tables.ChannelMembers,
149
+ Item: marshalChannelMember(channel.toLowerCase(), {
150
+ conn: connId,
151
+ nick: nick as Nick,
152
+ op: false,
153
+ voice: false,
154
+ }),
155
+ }),
156
+ );
157
+ }
158
+
159
+ describe.skipIf(!available)('membership transactions', () => {
160
+ beforeEach(async () => {
161
+ fx = await makeFixture();
162
+ });
163
+ afterEach(async () => {
164
+ if (fx) await fx.cleanup();
165
+ fx = null;
166
+ });
167
+
168
+ // -------------------------------------------------------------------------
169
+ // Atomicity: ChannelMembers ↔ Connections.joinedChannels
170
+ // -------------------------------------------------------------------------
171
+
172
+ it('JOIN writes both the ChannelMembers row and the channel to Connections.joinedChannels', async () => {
173
+ await seedConnection('c1');
174
+ const rt = makeRuntime('caller');
175
+
176
+ await rt.applyChannelDelta('#foo' as ChanName, {
177
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
178
+ });
179
+
180
+ const members = await readMembers('#foo');
181
+ expect(members.map((m) => m.connectionId)).toEqual(['c1']);
182
+
183
+ const row = await readConnection('c1');
184
+ expect(row).toBeDefined();
185
+ expect(row!.joinedChannels).toBeInstanceOf(Set);
186
+ expect((row!.joinedChannels as Set<string>).has('#foo')).toBe(true);
187
+ });
188
+
189
+ it('PART removes both the ChannelMembers row and the channel from joinedChannels', async () => {
190
+ await seedMembership('c1', 'Alice', '#foo' as ChanName);
191
+ const rt = makeRuntime('caller');
192
+
193
+ await rt.applyChannelDelta('#foo' as ChanName, {
194
+ memberships: [{ type: 'remove', conn: 'c1' }],
195
+ });
196
+
197
+ const members = await readMembers('#foo');
198
+ expect(members).toHaveLength(0);
199
+
200
+ const row = await readConnection('c1');
201
+ expect(row).toBeDefined();
202
+ expect((row!.joinedChannels as Set<string> | undefined)?.has('#foo') ?? false).toBe(false);
203
+ });
204
+
205
+ it('KICK (remove of a different connection) drops the target from joinedChannels', async () => {
206
+ await seedMembership('target', 'Bob', '#room' as ChanName);
207
+ const rt = makeRuntime('op');
208
+
209
+ await rt.applyChannelDelta('#room' as ChanName, {
210
+ memberships: [{ type: 'remove', conn: 'target' }],
211
+ });
212
+
213
+ const row = await readConnection('target');
214
+ expect((row!.joinedChannels as Set<string> | undefined)?.has('#room') ?? false).toBe(false);
215
+ expect(await readMembers('#room')).toHaveLength(0);
216
+ });
217
+
218
+ // -------------------------------------------------------------------------
219
+ // Stress: concurrent mutations by the same connection
220
+ // -------------------------------------------------------------------------
221
+
222
+ it('concurrent JOINs of different channels by the same connection leave both channels in joinedChannels', async () => {
223
+ await seedConnection('c1');
224
+ const rtA = makeRuntime('probe-a');
225
+ const rtB = makeRuntime('probe-b');
226
+
227
+ await Promise.all([
228
+ rtA.applyChannelDelta('#foo' as ChanName, {
229
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
230
+ }),
231
+ rtB.applyChannelDelta('#bar' as ChanName, {
232
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
233
+ }),
234
+ ]);
235
+
236
+ const row = await readConnection('c1');
237
+ const joined = row!.joinedChannels as Set<string>;
238
+ expect(joined.has('#foo')).toBe(true);
239
+ expect(joined.has('#bar')).toBe(true);
240
+
241
+ expect((await readMembers('#foo')).map((m) => m.connectionId)).toEqual(['c1']);
242
+ expect((await readMembers('#bar')).map((m) => m.connectionId)).toEqual(['c1']);
243
+ });
244
+
245
+ it('concurrent JOINs of the same channel by the same connection are idempotent (one roster row, channel listed once)', async () => {
246
+ await seedConnection('c1');
247
+ const rtA = makeRuntime('probe-a');
248
+ const rtB = makeRuntime('probe-b');
249
+
250
+ await expect(
251
+ Promise.all([
252
+ rtA.applyChannelDelta('#foo' as ChanName, {
253
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
254
+ }),
255
+ rtB.applyChannelDelta('#foo' as ChanName, {
256
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
257
+ }),
258
+ ]),
259
+ ).resolves.toBeDefined();
260
+
261
+ expect(await readMembers('#foo')).toHaveLength(1);
262
+ const row = await readConnection('c1');
263
+ const joined = row!.joinedChannels as Set<string>;
264
+ expect(joined.has('#foo')).toBe(true);
265
+ expect([...joined].filter((c) => c === '#foo')).toHaveLength(1);
266
+ });
267
+
268
+ it('concurrent PARTs of the same channel are idempotent (no rows left, joinedChannels empty)', async () => {
269
+ await seedMembership('c1', 'Alice', '#foo' as ChanName);
270
+ const rtA = makeRuntime('probe-a');
271
+ const rtB = makeRuntime('probe-b');
272
+
273
+ await expect(
274
+ Promise.all([
275
+ rtA.applyChannelDelta('#foo' as ChanName, {
276
+ memberships: [{ type: 'remove', conn: 'c1' }],
277
+ }),
278
+ rtB.applyChannelDelta('#foo' as ChanName, {
279
+ memberships: [{ type: 'remove', conn: 'c1' }],
280
+ }),
281
+ ]),
282
+ ).resolves.toBeDefined();
283
+
284
+ expect(await readMembers('#foo')).toHaveLength(0);
285
+ const row = await readConnection('c1');
286
+ expect((row!.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
287
+ });
288
+
289
+ // -------------------------------------------------------------------------
290
+ // Sequence: JOIN then PART leaves no trace
291
+ // -------------------------------------------------------------------------
292
+
293
+ it('JOIN then PART on the same connection leaves joinedChannels empty and no roster row', async () => {
294
+ await seedConnection('c1');
295
+ const rt = makeRuntime('caller');
296
+
297
+ await rt.applyChannelDelta('#foo' as ChanName, {
298
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
299
+ });
300
+ await rt.applyChannelDelta('#foo' as ChanName, {
301
+ memberships: [{ type: 'remove', conn: 'c1' }],
302
+ });
303
+
304
+ expect(await readMembers('#foo')).toHaveLength(0);
305
+ const row = await readConnection('c1');
306
+ expect((row!.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
307
+ });
308
+ });
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Retry / error-classification (no DynamoDB Local required — fake client)
312
+ // ---------------------------------------------------------------------------
313
+
314
+ /**
315
+ * Minimal fake document client. Counts `TransactWriteCommand` invocations
316
+ * and throws a configurable error for the first N transact attempts so
317
+ * the retry loop in `AwsRuntime.sendTransactWithRetry` is exercised
318
+ * without needing real DynamoDB concurrency.
319
+ */
320
+ class FakeDynamo {
321
+ transactCalls = 0;
322
+ otherCalls = 0;
323
+ constructor(
324
+ private readonly opts: {
325
+ failTransactTimes?: number;
326
+ error?: { name: string; CancellationReasons?: Array<{ Code?: string }> };
327
+ },
328
+ ) {}
329
+ async send(cmd: unknown): Promise<unknown> {
330
+ if (cmd instanceof TransactWriteCommand) {
331
+ this.transactCalls++;
332
+ const fail = this.opts.failTransactTimes ?? 0;
333
+ if (this.transactCalls <= fail) {
334
+ throw (
335
+ this.opts.error ?? {
336
+ name: 'TransactionCanceledException',
337
+ CancellationReasons: [{ Code: 'TransactionConflict' }],
338
+ }
339
+ );
340
+ }
341
+ return {};
342
+ }
343
+ this.otherCalls++;
344
+ // ensureMetaRow does a PutCommand (ChannelMeta); return success.
345
+ return {};
346
+ }
347
+ }
348
+
349
+ function fakeRuntime(client: FakeDynamo): AwsRuntime {
350
+ const handlers: AwsRuntimeHandlers = {
351
+ send: () => {},
352
+ disconnect: () => {},
353
+ snapshot: () => undefined,
354
+ };
355
+ return new AwsRuntime({
356
+ dynamo: client as unknown as ReturnType<typeof createDynamoDocumentClient>,
357
+ tables: {
358
+ Connections: 'C',
359
+ ChannelMeta: 'CM',
360
+ ChannelMembers: 'CMem',
361
+ Nicks: 'N',
362
+ Accounts: 'A',
363
+ },
364
+ connId: 'probe',
365
+ handlers,
366
+ managementApi: null,
367
+ });
368
+ }
369
+
370
+ describe('membership transaction retry / error classification', () => {
371
+ it('retries on TransactionConflict and succeeds once the conflict clears', async () => {
372
+ const fake = new FakeDynamo({ failTransactTimes: 2 });
373
+ const rt = fakeRuntime(fake);
374
+
375
+ await rt.applyChannelDelta('#foo' as ChanName, {
376
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
377
+ });
378
+
379
+ // 2 failed attempts + 1 successful = 3 transact calls.
380
+ expect(fake.transactCalls).toBe(3);
381
+ });
382
+
383
+ it('rethrows a non-conflict TransactionCanceledException immediately (no retry)', async () => {
384
+ const fake = new FakeDynamo({
385
+ failTransactTimes: 1,
386
+ error: {
387
+ name: 'TransactionCanceledException',
388
+ CancellationReasons: [{ Code: 'ConditionalCheckFailed' }],
389
+ },
390
+ });
391
+ const rt = fakeRuntime(fake);
392
+
393
+ await expect(
394
+ rt.applyChannelDelta('#foo' as ChanName, {
395
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
396
+ }),
397
+ ).rejects.toThrow();
398
+ expect(fake.transactCalls).toBe(1);
399
+ });
400
+
401
+ it('rethrows after exhausting the retry budget on persistent conflicts', async () => {
402
+ const fake = new FakeDynamo({ failTransactTimes: 999 });
403
+ const rt = fakeRuntime(fake);
404
+
405
+ await expect(
406
+ rt.applyChannelDelta('#foo' as ChanName, {
407
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
408
+ }),
409
+ ).rejects.toThrow();
410
+ // MAX_ATTEMPTS = 6.
411
+ expect(fake.transactCalls).toBe(6);
412
+ });
413
+
414
+ it('rethrows a non-transact error (e.g. ProvisionedThroughputExceeded) without retry', async () => {
415
+ const fake = new FakeDynamo({
416
+ failTransactTimes: 1,
417
+ error: { name: 'ProvisionedThroughputExceededException' },
418
+ });
419
+ const rt = fakeRuntime(fake);
420
+
421
+ await expect(
422
+ rt.applyChannelDelta('#foo' as ChanName, {
423
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
424
+ }),
425
+ ).rejects.toThrow();
426
+ expect(fake.transactCalls).toBe(1);
427
+ });
428
+
429
+ it('rejects a TransactionConflict classifier on null / non-object errors', async () => {
430
+ // Smoke-test the classifier's defensive branches by replaying the
431
+ // same fake-runtime path with a non-object error. The classifier
432
+ // must treat it as non-retryable and rethrow on the first attempt.
433
+ const fake = new FakeDynamo({
434
+ failTransactTimes: 1,
435
+ error: { name: 'TransactionCanceledException' }, // no CancellationReasons
436
+ });
437
+ const rt = fakeRuntime(fake);
438
+
439
+ await expect(
440
+ rt.applyChannelDelta('#foo' as ChanName, {
441
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
442
+ }),
443
+ ).rejects.toThrow();
444
+ expect(fake.transactCalls).toBe(1);
445
+ });
446
+ });
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "rootDir": "./src",
6
+ "outDir": "./dist",
7
+ "tsBuildInfoFile": "./.tsbuildinfo",
8
+ "types": ["node"]
9
+ },
10
+ "include": ["src/**/*.ts"],
11
+ "exclude": ["dist", "tests", "**/*.test.ts"],
12
+ "references": [
13
+ { "path": "../irc-core/tsconfig.build.json" },
14
+ { "path": "../irc-server/tsconfig.build.json" }
15
+ ]
16
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": ["node"]
6
+ },
7
+ "include": ["src/**/*.ts", "tests/**/*.ts"],
8
+ "exclude": ["dist"],
9
+ "references": [
10
+ { "path": "../irc-core/tsconfig.build.json" },
11
+ { "path": "../irc-server/tsconfig.build.json" },
12
+ { "path": "../irc-test-support/tsconfig.build.json" }
13
+ ]
14
+ }
@@ -0,0 +1,23 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globalSetup: ['./tests/global-setup.ts'],
6
+ setupFiles: [],
7
+ include: ['tests/**/*.test.ts'],
8
+ exclude: ['dist/**', 'node_modules/**'],
9
+ coverage: {
10
+ provider: 'v8',
11
+ all: false,
12
+ include: ['src/**/*.ts'],
13
+ exclude: ['src/**/index.ts'],
14
+ reporter: ['text', 'html', 'json-summary'],
15
+ thresholds: {
16
+ lines: 90,
17
+ functions: 90,
18
+ branches: 90,
19
+ statements: 90,
20
+ },
21
+ },
22
+ },
23
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-adapter",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",
@@ -30,6 +30,7 @@
30
30
  "@cloudflare/vitest-pool-workers": "0.18.6",
31
31
  "@cloudflare/workers-types": "^5.20260714.1",
32
32
  "@serverless-ircd/in-memory-runtime": "workspace:*",
33
+ "@serverless-ircd/irc-test-support": "workspace:*",
33
34
  "@vitest/coverage-v8": "^4.1.0",
34
35
  "rimraf": "^6.0.0",
35
36
  "typescript": "^5.6.0",
@@ -38,8 +38,10 @@ import {
38
38
  toSnapshot,
39
39
  } from '@serverless-ircd/irc-core';
40
40
  import type { IrcRuntime } from '@serverless-ircd/irc-server';
41
- import type { Env, RegistryRpc } from './env.js';
41
+ import type { ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
42
42
  import { DEFAULT_REGISTRY_SHARDS, registryKeyForNick, shardNick } from './sharding.js';
43
+ import type { PersistedConnectionState } from './serialize.js';
44
+ import { deserialize } from './serialize.js';
43
45
 
44
46
  /** Per-connection transport callbacks the ConnectionDO wires up. */
45
47
  export interface CfConnectionHandlers {
@@ -64,6 +66,10 @@ export interface ConnectionRpc {
64
66
  deliver(lines: RawLine[]): Promise<{ delivered: number }>;
65
67
  /** Returns a serializable snapshot of the connection state. */
66
68
  getConnSnapshot(): Promise<ConnSnapshot | null>;
69
+ /** Returns the full connection state as a serializable DTO. */
70
+ getConnState(): Promise<PersistedConnectionState | null>;
71
+ /** Cross-connection disconnect: closes the WebSocket and runs teardown. */
72
+ disconnect(reason?: string): Promise<void>;
67
73
  }
68
74
 
69
75
  /**
@@ -117,12 +123,8 @@ export class CfRuntime implements IrcRuntime {
117
123
  this.handlers.disconnect(reason);
118
124
  return;
119
125
  }
120
- // Cross-connection disconnect is a teardown signal; we route it via
121
- // the target's ConnectionDO RPC as well. (ConnectionDO doesn't expose
122
- // a dedicated `disconnect` RPC today — the canonical teardown path is
123
- // the WebSocket close, which is owned by the connection itself. We
124
- // therefore no-op for cross-connection disconnects; this preserves
125
- // the IrcRuntime contract without inventing a new RPC.)
126
+ const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(conn));
127
+ await (stub as unknown as ConnectionRpc).disconnect(reason);
126
128
  }
127
129
 
128
130
  async sendToNick(
@@ -232,29 +234,37 @@ export class CfRuntime implements IrcRuntime {
232
234
  }
233
235
 
234
236
  async getConnection(conn: ConnId): Promise<ConnectionState | null> {
235
- // CF stores ConnectionState inside each ConnectionDO; the only
236
- // authoritative view this runtime can hand out without a new RPC is
237
- // the local connection's live state. Remote connections return null
238
- // until a dedicated `getConnState` RPC lands alongside the broader
239
- // WHOIS-over-CF work — the actor degrades to "unknown nick" for them.
240
237
  if (conn === this.connId) {
241
238
  return this.handlers.snapshot() ?? null;
242
239
  }
243
- return null;
240
+ const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(conn));
241
+ const dto = await (stub as unknown as ConnectionRpc).getConnState();
242
+ return dto === null ? null : deserialize(dto);
244
243
  }
245
244
 
246
- async getChannelConnections(_chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
247
- // CF has no single source of truth for full ConnectionStates across
248
- // all ConnectionDOs. The WHO command (the only current consumer)
249
- // ships in a later ticket; return an empty map until then.
250
- return new Map();
245
+ async getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
246
+ const memberIds = await this.channelRpc(chan).listMembers();
247
+ const out = new Map<ConnId, ConnectionState>();
248
+ await Promise.all(
249
+ memberIds.map(async (id) => {
250
+ const connStub = this.env.CONNECTION_DO.get(
251
+ this.env.CONNECTION_DO.idFromString(id),
252
+ );
253
+ const dto = await (connStub as unknown as ConnectionRpc).getConnState();
254
+ if (dto !== null) {
255
+ out.set(id, deserialize(dto));
256
+ }
257
+ }),
258
+ );
259
+ return out;
251
260
  }
252
261
 
253
262
  async listChannels(): Promise<ChanSnapshot[]> {
254
- // CF has no central channel registry; each ChannelDO is independent.
255
- // The LIST command (the only current consumer) ships in a later
256
- // ticket; return an empty array until then.
257
- return [];
263
+ const names = await this.channelRegistryRpc().list();
264
+ const snapshots = await Promise.all(
265
+ names.map((nameLower) => this.getChannelSnapshot(nameLower)),
266
+ );
267
+ return snapshots.filter((s): s is ChanSnapshot => s !== null);
258
268
  }
259
269
 
260
270
  // -------------------------------------------------------------------------
@@ -278,6 +288,7 @@ export class CfRuntime implements IrcRuntime {
278
288
  broadcast(lines: unknown[], except?: string): Promise<void>;
279
289
  applyChannelDelta(delta: unknown): Promise<void>;
280
290
  getChannelSnapshot(): Promise<unknown>;
291
+ listMembers(): Promise<string[]>;
281
292
  } {
282
293
  const lower = name.toLowerCase();
283
294
  const stub = this.env.CHANNEL_DO.get(this.env.CHANNEL_DO.idFromName(lower));
@@ -285,8 +296,17 @@ export class CfRuntime implements IrcRuntime {
285
296
  broadcast(lines: unknown[], except?: string): Promise<void>;
286
297
  applyChannelDelta(delta: unknown): Promise<void>;
287
298
  getChannelSnapshot(): Promise<unknown>;
299
+ listMembers(): Promise<string[]>;
288
300
  };
289
301
  }
302
+
303
+ /** Returns the channel registry DO RPC stub. */
304
+ private channelRegistryRpc(): ChannelRegistryRpc {
305
+ const stub = this.env.CHANNEL_REGISTRY_DO.get(
306
+ this.env.CHANNEL_REGISTRY_DO.idFromName('channels'),
307
+ );
308
+ return stub as unknown as ChannelRegistryRpc;
309
+ }
290
310
  }
291
311
 
292
312
  /**