serverless-ircd 0.2.0 → 0.4.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 (221) 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 +258 -18
  8. package/README.md +72 -30
  9. package/apps/aws-stack/README.md +2 -2
  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 +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  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/scripts/smoke.mjs +0 -0
  34. package/apps/cf-worker/src/worker.ts +8 -2
  35. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  36. package/apps/cf-worker/wrangler.test.toml +11 -1
  37. package/apps/cf-worker/wrangler.toml +69 -19
  38. package/apps/local-cli/package.json +1 -1
  39. package/apps/local-cli/src/config-loader.ts +11 -0
  40. package/apps/local-cli/src/main.ts +1 -1
  41. package/apps/local-cli/src/server.ts +46 -3
  42. package/apps/local-cli/tests/e2e.test.ts +52 -0
  43. package/package.json +19 -16
  44. package/packages/aws-adapter/package.json +3 -3
  45. package/packages/aws-adapter/src/account-store.ts +121 -0
  46. package/packages/aws-adapter/src/admission.ts +74 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +124 -34
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  49. package/packages/aws-adapter/src/config-loader.ts +62 -0
  50. package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  52. package/packages/aws-adapter/src/handlers/default.ts +43 -2
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  54. package/packages/aws-adapter/src/handlers/index.ts +120 -9
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  56. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  57. package/packages/aws-adapter/src/index.ts +13 -0
  58. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
  59. package/packages/aws-adapter/tests/account-store.test.ts +279 -0
  60. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  61. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  62. package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
  63. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
  65. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  66. package/packages/aws-adapter/tests/handlers.test.ts +194 -47
  67. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  68. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  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 +1 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +40 -22
  73. package/packages/cf-adapter/src/channel-do.ts +40 -1
  74. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  75. package/packages/cf-adapter/src/config-loader.ts +62 -0
  76. package/packages/cf-adapter/src/connection-do.ts +181 -31
  77. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  78. package/packages/cf-adapter/src/env.ts +58 -8
  79. package/packages/cf-adapter/src/index.ts +11 -9
  80. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  81. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  82. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  83. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  84. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  85. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  86. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  87. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  88. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  89. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +9 -1
  91. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  92. package/packages/cf-adapter/wrangler.test.toml +18 -1
  93. package/packages/in-memory-runtime/package.json +1 -1
  94. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  95. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  96. package/packages/irc-core/package.json +8 -2
  97. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  98. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  99. package/packages/irc-core/src/case-fold.ts +64 -0
  100. package/packages/irc-core/src/cloak.ts +1 -1
  101. package/packages/irc-core/src/commands/cap.ts +8 -1
  102. package/packages/irc-core/src/commands/index.ts +11 -0
  103. package/packages/irc-core/src/commands/invite.ts +6 -9
  104. package/packages/irc-core/src/commands/ison.ts +61 -0
  105. package/packages/irc-core/src/commands/isupport.ts +1 -1
  106. package/packages/irc-core/src/commands/join.ts +4 -3
  107. package/packages/irc-core/src/commands/kick.ts +4 -11
  108. package/packages/irc-core/src/commands/list.ts +5 -4
  109. package/packages/irc-core/src/commands/mode.ts +5 -9
  110. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  111. package/packages/irc-core/src/commands/motd.ts +6 -110
  112. package/packages/irc-core/src/commands/oper.ts +151 -0
  113. package/packages/irc-core/src/commands/quit.ts +12 -0
  114. package/packages/irc-core/src/commands/registration.ts +26 -19
  115. package/packages/irc-core/src/commands/sasl.ts +72 -9
  116. package/packages/irc-core/src/commands/server-info.ts +129 -0
  117. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  118. package/packages/irc-core/src/commands/userhost.ts +84 -0
  119. package/packages/irc-core/src/commands/whowas.ts +113 -0
  120. package/packages/irc-core/src/config.ts +84 -3
  121. package/packages/irc-core/src/credential-hashing.ts +124 -0
  122. package/packages/irc-core/src/effects.ts +6 -29
  123. package/packages/irc-core/src/index.ts +3 -0
  124. package/packages/irc-core/src/ports.ts +240 -7
  125. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  126. package/packages/irc-core/src/types.ts +60 -1
  127. package/packages/irc-core/tests/account-store.test.ts +131 -0
  128. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  129. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  130. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  131. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  132. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  133. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  134. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  135. package/packages/irc-core/tests/commands/registration.test.ts +256 -19
  136. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  137. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  138. package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
  139. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  140. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  141. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  142. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  143. package/packages/irc-core/tests/config.test.ts +170 -1
  144. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  145. package/packages/irc-core/tests/effects.test.ts +0 -27
  146. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  147. package/packages/irc-core/tests/numerics.test.ts +12 -0
  148. package/packages/irc-core/tests/types.test.ts +35 -1
  149. package/packages/irc-core/tsconfig.build.json +1 -1
  150. package/packages/irc-core/tsconfig.test.json +1 -1
  151. package/packages/irc-server/package.json +1 -1
  152. package/packages/irc-server/src/actor.ts +182 -14
  153. package/packages/irc-server/src/dispatch.ts +0 -3
  154. package/packages/irc-server/src/index.ts +10 -2
  155. package/packages/irc-server/src/routing.ts +21 -0
  156. package/packages/irc-server/src/transport.ts +101 -0
  157. package/packages/irc-server/tests/actor.test.ts +617 -1
  158. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  159. package/packages/irc-server/tests/routing.test.ts +7 -0
  160. package/packages/irc-server/tests/transport.test.ts +230 -0
  161. package/packages/irc-test-support/package.json +1 -1
  162. package/packages/irc-test-support/src/harness.ts +44 -9
  163. package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
  164. package/packages/irc-test-support/src/index.ts +3 -0
  165. package/packages/irc-test-support/src/scenarios.ts +132 -2
  166. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  167. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  168. package/pnpm-workspace.yaml +9 -0
  169. package/tools/ci-hardening/package.json +1 -1
  170. package/tools/package.json +4 -0
  171. package/tools/seed-aws-accounts.ts +79 -0
  172. package/tools/seed-cf-accounts.ts +104 -0
  173. package/tools/tcp-ws-forwarder/package.json +1 -1
  174. package/AGENTS.md +0 -5
  175. package/MOTD.txt +0 -3
  176. package/PLAN-FIXES.md +0 -358
  177. package/PLAN.md +0 -420
  178. package/dashboards/cloudwatch-irc.json +0 -139
  179. package/docs/AWS-Adapter-Architecture.md +0 -494
  180. package/docs/AWS-Deployment.md +0 -1107
  181. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  182. package/docs/Home.md +0 -1
  183. package/docs/Observability.md +0 -87
  184. package/docs/PlanIRCv3Websocket.md +0 -489
  185. package/docs/PlanWebClient.md +0 -451
  186. package/docs/Release-Process.md +0 -440
  187. package/progress.md +0 -107
  188. package/tickets.md +0 -2485
  189. package/webircgateway/LICENSE +0 -201
  190. package/webircgateway/Makefile +0 -44
  191. package/webircgateway/README.md +0 -134
  192. package/webircgateway/config.conf.example +0 -135
  193. package/webircgateway/go.mod +0 -16
  194. package/webircgateway/go.sum +0 -89
  195. package/webircgateway/main.go +0 -118
  196. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  197. package/webircgateway/pkg/identd/identd.go +0 -86
  198. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  199. package/webircgateway/pkg/irc/isupport.go +0 -56
  200. package/webircgateway/pkg/irc/message.go +0 -217
  201. package/webircgateway/pkg/irc/state.go +0 -79
  202. package/webircgateway/pkg/proxy/proxy.go +0 -129
  203. package/webircgateway/pkg/proxy/server.go +0 -237
  204. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  205. package/webircgateway/pkg/webircgateway/client.go +0 -741
  206. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  207. package/webircgateway/pkg/webircgateway/config.go +0 -385
  208. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  209. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  210. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  211. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  212. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  213. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  214. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  215. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  216. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  217. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  218. package/webircgateway/plugins/example/plugin.go +0 -11
  219. package/webircgateway/plugins/stats/plugin.go +0 -52
  220. package/webircgateway/staticcheck.conf +0 -1
  221. package/webircgateway/webircgateway.svg +0 -3
@@ -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.2.0",
3
+ "version": "0.4.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",
@@ -38,7 +38,9 @@ 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
+ import type { PersistedConnectionState } from './serialize.js';
43
+ import { deserialize } from './serialize.js';
42
44
  import { DEFAULT_REGISTRY_SHARDS, registryKeyForNick, shardNick } from './sharding.js';
43
45
 
44
46
  /** Per-connection transport callbacks the ConnectionDO wires up. */
@@ -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,35 @@ 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(this.env.CONNECTION_DO.idFromString(id));
251
+ const dto = await (connStub as unknown as ConnectionRpc).getConnState();
252
+ if (dto !== null) {
253
+ out.set(id, deserialize(dto));
254
+ }
255
+ }),
256
+ );
257
+ return out;
251
258
  }
252
259
 
253
260
  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 [];
261
+ const names = await this.channelRegistryRpc().list();
262
+ const snapshots = await Promise.all(
263
+ names.map((nameLower) => this.getChannelSnapshot(nameLower)),
264
+ );
265
+ return snapshots.filter((s): s is ChanSnapshot => s !== null);
258
266
  }
259
267
 
260
268
  // -------------------------------------------------------------------------
@@ -278,6 +286,7 @@ export class CfRuntime implements IrcRuntime {
278
286
  broadcast(lines: unknown[], except?: string): Promise<void>;
279
287
  applyChannelDelta(delta: unknown): Promise<void>;
280
288
  getChannelSnapshot(): Promise<unknown>;
289
+ listMembers(): Promise<string[]>;
281
290
  } {
282
291
  const lower = name.toLowerCase();
283
292
  const stub = this.env.CHANNEL_DO.get(this.env.CHANNEL_DO.idFromName(lower));
@@ -285,8 +294,17 @@ export class CfRuntime implements IrcRuntime {
285
294
  broadcast(lines: unknown[], except?: string): Promise<void>;
286
295
  applyChannelDelta(delta: unknown): Promise<void>;
287
296
  getChannelSnapshot(): Promise<unknown>;
297
+ listMembers(): Promise<string[]>;
288
298
  };
289
299
  }
300
+
301
+ /** Returns the channel registry DO RPC stub. */
302
+ private channelRegistryRpc(): ChannelRegistryRpc {
303
+ const stub = this.env.CHANNEL_REGISTRY_DO.get(
304
+ this.env.CHANNEL_REGISTRY_DO.idFromName('channels'),
305
+ );
306
+ return stub as unknown as ChannelRegistryRpc;
307
+ }
290
308
  }
291
309
 
292
310
  /**
@@ -46,7 +46,7 @@ import {
46
46
  createChannel,
47
47
  toSnapshot,
48
48
  } from '@serverless-ircd/irc-core';
49
- import type { Env } from './env.js';
49
+ import type { ChannelRegistryRpc, Env } from './env.js';
50
50
 
51
51
  /** Storage key under which the per-channel state is persisted. */
52
52
  const STATE_STORAGE_KEY = 'channel-state';
@@ -129,8 +129,10 @@ export class ChannelDO extends DurableObject<Env> {
129
129
  */
130
130
  async applyChannelDelta(delta: ChannelDelta): Promise<void> {
131
131
  const state = await this.loadState();
132
+ const wasEmpty = state.members.size === 0;
132
133
  const next = applyChannelDelta(state, delta);
133
134
  await this.persistState(next);
135
+ await this.syncRegistry(wasEmpty, next);
134
136
  }
135
137
 
136
138
  // -------------------------------------------------------------------------
@@ -160,10 +162,12 @@ export class ChannelDO extends DurableObject<Env> {
160
162
  const payload = lines.map((text) => ({ text }));
161
163
  const pruned = await this.fanoutAndCollect(state, payload, except);
162
164
  if (pruned.length > 0) {
165
+ const wasEmpty = false;
163
166
  const next = applyChannelDelta(state, {
164
167
  memberships: pruned.map((conn) => ({ type: 'remove' as const, conn })),
165
168
  });
166
169
  await this.persistState(next);
170
+ await this.syncRegistry(wasEmpty, next);
167
171
  }
168
172
  }
169
173
 
@@ -182,10 +186,12 @@ export class ChannelDO extends DurableObject<Env> {
182
186
  // OPEN-socket count without sending bytes).
183
187
  const pruned = await this.fanoutAndCollect(state, [], undefined);
184
188
  if (pruned.length > 0) {
189
+ const wasEmpty = false;
185
190
  const next = applyChannelDelta(state, {
186
191
  memberships: pruned.map((conn) => ({ type: 'remove' as const, conn })),
187
192
  });
188
193
  await this.persistState(next);
194
+ await this.syncRegistry(wasEmpty, next);
189
195
  }
190
196
  return pruned.length;
191
197
  }
@@ -206,6 +212,16 @@ export class ChannelDO extends DurableObject<Env> {
206
212
  return toDto(state);
207
213
  }
208
214
 
215
+ /**
216
+ * Returns the ConnIds of every member of this channel. Used by
217
+ * {@link CfRuntime.getChannelConnections} to enumerate the
218
+ * ConnectionStates of all members (for WHO).
219
+ */
220
+ async listMembers(): Promise<string[]> {
221
+ const state = await this.loadState();
222
+ return Array.from(state.members.keys());
223
+ }
224
+
209
225
  // -------------------------------------------------------------------------
210
226
  // Internal helpers
211
227
  // -------------------------------------------------------------------------
@@ -289,6 +305,29 @@ export class ChannelDO extends DurableObject<Env> {
289
305
  }
290
306
  }
291
307
 
308
+ /**
309
+ * Keeps the channel registry in sync: registers the channel when it
310
+ * transitions from empty to non-empty, unregisters on the reverse
311
+ * transition. Called after every membership-mutating path so the
312
+ * registry always reflects the live channel set.
313
+ */
314
+ private async syncRegistry(wasEmpty: boolean, next: ChannelState): Promise<void> {
315
+ const isEmpty = next.members.size === 0;
316
+ if (wasEmpty && !isEmpty) {
317
+ await this.channelRegistry().register(next.nameLower);
318
+ } else if (!wasEmpty && isEmpty) {
319
+ await this.channelRegistry().unregister(next.nameLower);
320
+ }
321
+ }
322
+
323
+ /** Returns the channel registry DO RPC stub. */
324
+ private channelRegistry(): ChannelRegistryRpc {
325
+ const stub = this.env.CHANNEL_REGISTRY_DO.get(
326
+ this.env.CHANNEL_REGISTRY_DO.idFromName('channels'),
327
+ );
328
+ return stub as unknown as ChannelRegistryRpc;
329
+ }
330
+
292
331
  // -------------------------------------------------------------------------
293
332
  // Test hooks (only invoked from `runInDurableObject` in tests)
294
333
  // -------------------------------------------------------------------------
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `ChannelRegistryDO` — Cloudflare Durable Object that tracks the set
3
+ * of all channel names.
4
+ *
5
+ * A single DO instance (keyed by the literal name `channels`) maintains
6
+ * the complete channel-name set so {@link CfRuntime.listChannels} can
7
+ * enumerate every channel without scanning each per-channel DO
8
+ * individually.
9
+ *
10
+ * Storage layout:
11
+ * - One key per channel: `chan:<lower>` → `1`
12
+ *
13
+ * Per-channel keys keep storage proportional to the live channel count
14
+ * and let individual register/unregister calls stay O(1).
15
+ *
16
+ * RPC surface — implements {@link ChannelRegistryRpc}. The ChannelDO
17
+ * calls `register` / `unregister` whenever its membership transitions
18
+ * between empty and non-empty; CfRuntime calls `list` for LIST.
19
+ */
20
+
21
+ import { DurableObject } from 'cloudflare:workers';
22
+ import type { ChannelRegistryRpc } from './env.js';
23
+
24
+ /** Storage key prefix for per-channel entries. */
25
+ const CHAN_KEY_PREFIX = 'chan:';
26
+
27
+ /** Returns the storage key for `nameLower`. */
28
+ function chanKey(nameLower: string): string {
29
+ return `${CHAN_KEY_PREFIX}${nameLower}`;
30
+ }
31
+
32
+ /**
33
+ * Channel registry authority.
34
+ *
35
+ * The class is exported as a binding target in `wrangler.toml`; the
36
+ * Workers runtime instantiates it with `(ctx, env)`.
37
+ */
38
+ export class ChannelRegistryDO extends DurableObject implements ChannelRegistryRpc {
39
+ /** Adds `name` to the registry (idempotent). */
40
+ async register(name: string): Promise<void> {
41
+ await this.ctx.storage.put(chanKey(name.toLowerCase()), 1);
42
+ }
43
+
44
+ /** Removes `name` from the registry (idempotent). */
45
+ async unregister(name: string): Promise<void> {
46
+ await this.ctx.storage.delete(chanKey(name.toLowerCase()));
47
+ }
48
+
49
+ /** Returns all registered channel names (lowercased). */
50
+ async list(): Promise<string[]> {
51
+ const entries = await this.ctx.storage.list({ prefix: CHAN_KEY_PREFIX });
52
+ const names: string[] = [];
53
+ for (const key of entries.keys()) {
54
+ names.push(key.slice(CHAN_KEY_PREFIX.length));
55
+ }
56
+ return names;
57
+ }
58
+ }
@@ -22,6 +22,17 @@ import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc
22
22
  export interface CfConfigEnv {
23
23
  SERVER_NAME?: string;
24
24
  NETWORK_NAME?: string;
25
+ /**
26
+ * Server version surfaced in `002`/`004`/`351`/`371`. When unset the
27
+ * schema default ({@link DEFAULT_SERVER_VERSION}) applies.
28
+ */
29
+ SERVER_VERSION?: string;
30
+ /**
31
+ * "Created" text for `003 RPL_CREATED`. A numeric string is parsed into
32
+ * an epoch-ms number so the reducer formats it as a UTC timestamp; any
33
+ * other value is passed through verbatim.
34
+ */
35
+ CREATED_AT?: string;
25
36
  MOTD_LINES?: string;
26
37
  MAX_CLIENTS?: string;
27
38
  CHANNEL_PREFIXES?: string;
@@ -34,6 +45,12 @@ export interface CfConfigEnv {
34
45
  TOPIC_LEN?: string;
35
46
  MAX_LIST_ENTRIES?: string;
36
47
  QUIT_MESSAGE?: string;
48
+ /**
49
+ * SASL PLAIN accounts. Newline-delimited `username:password` pairs.
50
+ * Parsed into the `saslAccounts` config field so the config-driven
51
+ * path (vs the connection-do's direct env read) stays consistent.
52
+ */
53
+ SASL_ACCOUNTS?: string;
37
54
  }
38
55
 
39
56
  /**
@@ -50,6 +67,12 @@ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
50
67
  serverName: env.SERVER_NAME,
51
68
  networkName: env.NETWORK_NAME,
52
69
  };
70
+ if (env.SERVER_VERSION !== undefined) {
71
+ input.serverVersion = env.SERVER_VERSION;
72
+ }
73
+ if (env.CREATED_AT !== undefined) {
74
+ input.createdAt = parseCreatedAt(env.CREATED_AT);
75
+ }
53
76
  if (env.MOTD_LINES !== undefined && env.MOTD_LINES.length > 0) {
54
77
  input.motdLines = env.MOTD_LINES.split('\n');
55
78
  }
@@ -90,9 +113,48 @@ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
90
113
  if (env.QUIT_MESSAGE !== undefined) {
91
114
  input.quitMessage = env.QUIT_MESSAGE;
92
115
  }
116
+ if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
117
+ input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
118
+ }
93
119
  return input;
94
120
  }
95
121
 
122
+ /**
123
+ * Parses the `CREATED_AT` env var into the schema's `createdAt` field.
124
+ *
125
+ * A purely numeric string is treated as epoch-ms (returned as a number so
126
+ * the reducer formats it as a UTC timestamp); any other value is returned
127
+ * verbatim as a human-readable string. Empty strings fall through to the
128
+ * schema default (the caller only invokes this when the env var is set).
129
+ */
130
+ function parseCreatedAt(raw: string): string | number {
131
+ if (/^\d+$/.test(raw.trim())) {
132
+ const n = Number(raw.trim());
133
+ if (Number.isFinite(n)) return n;
134
+ }
135
+ return raw;
136
+ }
137
+
138
+ /**
139
+ * Parses a newline-delimited `username:password` string into the config's
140
+ * `saslAccounts` shape. Malformed entries are skipped so a trailing
141
+ * newline or partial edit does not break boot.
142
+ */
143
+ function parseSaslAccountsLines(raw: string): Array<{ username: string; password: string }> {
144
+ const out: Array<{ username: string; password: string }> = [];
145
+ for (const line of raw.split('\n')) {
146
+ const trimmed = line.trim();
147
+ if (trimmed.length === 0) continue;
148
+ const sep = trimmed.indexOf(':');
149
+ if (sep <= 0) continue;
150
+ const username = trimmed.slice(0, sep);
151
+ const password = trimmed.slice(sep + 1);
152
+ if (username.length === 0 || password.length === 0) continue;
153
+ out.push({ username, password });
154
+ }
155
+ return out;
156
+ }
157
+
96
158
  /**
97
159
  * Loads and validates the server config from a Cloudflare Workers env.
98
160
  *