serverless-ircd 0.1.0 → 0.2.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 +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +67 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +427 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +66 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +162 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/progress.md +107 -0
- package/tickets.md +2485 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /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.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Workers config loader.
|
|
3
|
+
*
|
|
4
|
+
* Adapts the platform-native env vars (Workers `vars` in `wrangler.toml`
|
|
5
|
+
* or per-environment secrets) into the shared `ServerConfigSchema` from
|
|
6
|
+
* `irc-core`. Both adapters (CF + AWS) consume the same schema; only the
|
|
7
|
+
* env-shape mapping differs per platform.
|
|
8
|
+
*
|
|
9
|
+
* String env vars are coerced into the schema's expected types here so
|
|
10
|
+
* operators can express every config knob as a wrangler var/secret
|
|
11
|
+
* without per-type plumbing in the worker.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The subset of the Workers `Env` that this loader reads. Mirrors the
|
|
18
|
+
* production {@link Env} in `env.ts` plus the optional knobs the schema
|
|
19
|
+
* recognises. Optional members are omitted from the input object when
|
|
20
|
+
* undefined so the schema's defaults apply.
|
|
21
|
+
*/
|
|
22
|
+
export interface CfConfigEnv {
|
|
23
|
+
SERVER_NAME?: string;
|
|
24
|
+
NETWORK_NAME?: string;
|
|
25
|
+
MOTD_LINES?: string;
|
|
26
|
+
MAX_CLIENTS?: string;
|
|
27
|
+
CHANNEL_PREFIXES?: string;
|
|
28
|
+
OPER_USER?: string;
|
|
29
|
+
OPER_PASSWORD?: string;
|
|
30
|
+
MAX_CHANNELS_PER_USER?: string;
|
|
31
|
+
MAX_TARGETS_PER_COMMAND?: string;
|
|
32
|
+
NICK_LEN?: string;
|
|
33
|
+
CHANNEL_LEN?: string;
|
|
34
|
+
TOPIC_LEN?: string;
|
|
35
|
+
MAX_LIST_ENTRIES?: string;
|
|
36
|
+
QUIT_MESSAGE?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Builds the input object the shared schema expects from a Workers env.
|
|
41
|
+
*
|
|
42
|
+
* Helper extracted so the assemble→parse boundary is unit-testable in
|
|
43
|
+
* isolation: the produced input is a plain object that exactly matches
|
|
44
|
+
* `ServerConfigSchema`'s shape (strings converted to numbers / arrays
|
|
45
|
+
* as needed). Missing optional fields are kept out so the schema
|
|
46
|
+
* defaults kick in.
|
|
47
|
+
*/
|
|
48
|
+
function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
|
|
49
|
+
const input: Record<string, unknown> = {
|
|
50
|
+
serverName: env.SERVER_NAME,
|
|
51
|
+
networkName: env.NETWORK_NAME,
|
|
52
|
+
};
|
|
53
|
+
if (env.MOTD_LINES !== undefined && env.MOTD_LINES.length > 0) {
|
|
54
|
+
input.motdLines = env.MOTD_LINES.split('\n');
|
|
55
|
+
}
|
|
56
|
+
if (env.MAX_CLIENTS !== undefined) {
|
|
57
|
+
input.maxClients = Number.parseInt(env.MAX_CLIENTS, 10);
|
|
58
|
+
}
|
|
59
|
+
if (env.CHANNEL_PREFIXES !== undefined) {
|
|
60
|
+
input.channelPrefixes = env.CHANNEL_PREFIXES;
|
|
61
|
+
}
|
|
62
|
+
if (env.OPER_USER !== undefined || env.OPER_PASSWORD !== undefined) {
|
|
63
|
+
// Build the cred object from whatever was supplied; the schema
|
|
64
|
+
// surfaces a `user`/`password` error if only one half was provided.
|
|
65
|
+
input.operCreds = [
|
|
66
|
+
{
|
|
67
|
+
user: env.OPER_USER ?? '',
|
|
68
|
+
password: env.OPER_PASSWORD ?? '',
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
if (env.MAX_CHANNELS_PER_USER !== undefined) {
|
|
73
|
+
input.maxChannelsPerUser = Number.parseInt(env.MAX_CHANNELS_PER_USER, 10);
|
|
74
|
+
}
|
|
75
|
+
if (env.MAX_TARGETS_PER_COMMAND !== undefined) {
|
|
76
|
+
input.maxTargetsPerCommand = Number.parseInt(env.MAX_TARGETS_PER_COMMAND, 10);
|
|
77
|
+
}
|
|
78
|
+
if (env.NICK_LEN !== undefined) {
|
|
79
|
+
input.nickLen = Number.parseInt(env.NICK_LEN, 10);
|
|
80
|
+
}
|
|
81
|
+
if (env.CHANNEL_LEN !== undefined) {
|
|
82
|
+
input.channelLen = Number.parseInt(env.CHANNEL_LEN, 10);
|
|
83
|
+
}
|
|
84
|
+
if (env.TOPIC_LEN !== undefined) {
|
|
85
|
+
input.topicLen = Number.parseInt(env.TOPIC_LEN, 10);
|
|
86
|
+
}
|
|
87
|
+
if (env.MAX_LIST_ENTRIES !== undefined) {
|
|
88
|
+
input.maxListEntries = Number.parseInt(env.MAX_LIST_ENTRIES, 10);
|
|
89
|
+
}
|
|
90
|
+
if (env.QUIT_MESSAGE !== undefined) {
|
|
91
|
+
input.quitMessage = env.QUIT_MESSAGE;
|
|
92
|
+
}
|
|
93
|
+
return input;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Loads and validates the server config from a Cloudflare Workers env.
|
|
98
|
+
*
|
|
99
|
+
* Wraps the shared {@link parseServerConfig} so a single boot-time
|
|
100
|
+
* failure mode covers every adapter: invalid config throws a readable
|
|
101
|
+
* `Error` listing every offending field, surfacing the problem before
|
|
102
|
+
* any connection is accepted.
|
|
103
|
+
*/
|
|
104
|
+
export function loadServerConfigFromCfEnv(env: CfConfigEnv): ParsedServerConfig {
|
|
105
|
+
return parseServerConfig(buildConfigInput(env));
|
|
106
|
+
}
|