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.
- package/.github/workflows/ci.yml +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +258 -18
- package/README.md +72 -30
- package/apps/aws-stack/README.md +2 -2
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +118 -6
- package/apps/aws-stack/tests/stack.test.ts +98 -3
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +11 -1
- package/apps/cf-worker/wrangler.toml +69 -19
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +11 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +46 -3
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- package/package.json +19 -16
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +124 -34
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +62 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +43 -2
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +120 -9
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +13 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
- package/packages/aws-adapter/tests/account-store.test.ts +279 -0
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +194 -47
- package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +40 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +62 -0
- package/packages/cf-adapter/src/connection-do.ts +181 -31
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +58 -8
- package/packages/cf-adapter/src/index.ts +11 -9
- package/packages/cf-adapter/tests/cf-harness.ts +11 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/worker/main.ts +9 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +8 -2
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/caps/capabilities.ts +23 -2
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +11 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +4 -3
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +26 -19
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -0
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +84 -3
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +6 -29
- package/packages/irc-core/src/index.ts +3 -0
- package/packages/irc-core/src/ports.ts +240 -7
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +60 -1
- package/packages/irc-core/tests/account-store.test.ts +131 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -0
- package/packages/irc-core/tests/commands/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +256 -19
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +170 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/effects.test.ts +0 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +12 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- package/packages/irc-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +182 -14
- package/packages/irc-server/src/dispatch.ts +0 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +21 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +617 -1
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +7 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +132 -2
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +9 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +79 -0
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/AGENTS.md +0 -5
- package/MOTD.txt +0 -3
- package/PLAN-FIXES.md +0 -358
- package/PLAN.md +0 -420
- package/dashboards/cloudwatch-irc.json +0 -139
- package/docs/AWS-Adapter-Architecture.md +0 -494
- package/docs/AWS-Deployment.md +0 -1107
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -1
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -440
- package/progress.md +0 -107
- package/tickets.md +0 -2485
- package/webircgateway/LICENSE +0 -201
- package/webircgateway/Makefile +0 -44
- package/webircgateway/README.md +0 -134
- package/webircgateway/config.conf.example +0 -135
- package/webircgateway/go.mod +0 -16
- package/webircgateway/go.sum +0 -89
- package/webircgateway/main.go +0 -118
- package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
- package/webircgateway/pkg/identd/identd.go +0 -86
- package/webircgateway/pkg/identd/rpcclient.go +0 -59
- package/webircgateway/pkg/irc/isupport.go +0 -56
- package/webircgateway/pkg/irc/message.go +0 -217
- package/webircgateway/pkg/irc/state.go +0 -79
- package/webircgateway/pkg/proxy/proxy.go +0 -129
- package/webircgateway/pkg/proxy/server.go +0 -237
- package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
- package/webircgateway/pkg/webircgateway/client.go +0 -741
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
- package/webircgateway/pkg/webircgateway/config.go +0 -385
- package/webircgateway/pkg/webircgateway/gateway.go +0 -278
- package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
- package/webircgateway/pkg/webircgateway/hooks.go +0 -152
- package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
- package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
- package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
- package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
- package/webircgateway/pkg/webircgateway/utils.go +0 -147
- package/webircgateway/plugins/example/plugin.go +0 -11
- package/webircgateway/plugins/stats/plugin.go +0 -52
- package/webircgateway/staticcheck.conf +0 -1
- 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:
|
|
85
|
-
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
|
|
108
|
+
await requireFx().client.send(
|
|
104
109
|
new PutCommand({
|
|
105
|
-
TableName:
|
|
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
|
|
117
|
+
await requireFx().client.send(
|
|
113
118
|
new PutCommand({
|
|
114
|
-
TableName:
|
|
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
|
|
129
|
+
await requireFx().client.send(
|
|
125
130
|
new PutCommand({
|
|
126
|
-
TableName:
|
|
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
|
|
135
|
-
new GetCommand({ TableName:
|
|
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
|
|
146
|
+
const res = await requireFx().client.send(
|
|
142
147
|
new QueryCommand({
|
|
143
|
-
TableName:
|
|
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
|
|
154
|
-
new GetCommand({ TableName:
|
|
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(
|
|
285
|
-
await expect(
|
|
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
|
|
307
|
+
await requireFx().client.send(
|
|
301
308
|
new PutCommand({
|
|
302
|
-
TableName:
|
|
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:
|
|
93
|
-
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
|
|
108
|
+
await requireFx().client.send(
|
|
104
109
|
new PutCommand({
|
|
105
|
-
TableName:
|
|
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
|
|
118
|
+
const res = await requireFx().client.send(
|
|
114
119
|
new GetCommand({
|
|
115
|
-
TableName:
|
|
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
|
|
129
|
+
const res = await requireFx().client.send(
|
|
125
130
|
new QueryCommand({
|
|
126
|
-
TableName:
|
|
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
|
|
145
|
+
await requireFx().client.send(
|
|
141
146
|
new PutCommand({
|
|
142
|
-
TableName:
|
|
147
|
+
TableName: requireFx().tables.Connections,
|
|
143
148
|
Item: marshalConnection(state, 1_000),
|
|
144
149
|
}),
|
|
145
150
|
);
|
|
146
|
-
await
|
|
151
|
+
await requireFx().client.send(
|
|
147
152
|
new PutCommand({
|
|
148
|
-
TableName:
|
|
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
|
|
186
|
-
expect((row
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
"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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
*
|