serverless-ircd 0.3.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 +174 -18
- package/README.md +63 -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/wrangler.test.toml +6 -0
- package/apps/cf-worker/wrangler.toml +28 -2
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +10 -0
- package/apps/local-cli/src/server.ts +20 -3
- package/package.json +14 -10
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +1 -1
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +6 -4
- package/packages/aws-adapter/src/config-loader.ts +32 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +35 -2
- package/packages/aws-adapter/src/handlers/index.ts +69 -3
- 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 +6 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
- package/packages/aws-adapter/tests/account-store.test.ts +19 -20
- 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 +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +154 -53
- 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 +2 -4
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +111 -46
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +33 -9
- package/packages/cf-adapter/src/index.ts +9 -8
- 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/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/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 +8 -1
- package/packages/cf-adapter/wrangler.test.toml +8 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +6 -1
- 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/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- 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/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +58 -0
- 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 +1 -0
- package/packages/irc-core/src/ports.ts +181 -12
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +38 -1
- package/packages/irc-core/tests/account-store.test.ts +45 -2
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- 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/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- 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 +9 -35
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +95 -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 +158 -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 +12 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +400 -3
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +4 -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 +73 -9
- 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 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
- package/docs/ADR-002-location-of-authority.md +0 -82
- package/docs/ADR-003-durable-object-sharding.md +0 -93
- package/docs/ADR-004-dynamodb-schema.md +0 -96
- package/docs/ADR-005-wss-only-transport-v1.md +0 -83
- package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
- package/docs/ADR-007-deterministic-ports.md +0 -82
- package/docs/ADR-008-monorepo-tooling.md +0 -60
- package/docs/AWS-Adapter-Architecture.md +0 -496
- package/docs/AWS-Deployment.md +0 -1186
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -11
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -443
|
@@ -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",
|
|
@@ -39,9 +39,9 @@ import {
|
|
|
39
39
|
} from '@serverless-ircd/irc-core';
|
|
40
40
|
import type { IrcRuntime } from '@serverless-ircd/irc-server';
|
|
41
41
|
import type { ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
|
|
42
|
-
import { DEFAULT_REGISTRY_SHARDS, registryKeyForNick, shardNick } from './sharding.js';
|
|
43
42
|
import type { PersistedConnectionState } from './serialize.js';
|
|
44
43
|
import { deserialize } from './serialize.js';
|
|
44
|
+
import { DEFAULT_REGISTRY_SHARDS, registryKeyForNick, shardNick } from './sharding.js';
|
|
45
45
|
|
|
46
46
|
/** Per-connection transport callbacks the ConnectionDO wires up. */
|
|
47
47
|
export interface CfConnectionHandlers {
|
|
@@ -247,9 +247,7 @@ export class CfRuntime implements IrcRuntime {
|
|
|
247
247
|
const out = new Map<ConnId, ConnectionState>();
|
|
248
248
|
await Promise.all(
|
|
249
249
|
memberIds.map(async (id) => {
|
|
250
|
-
const connStub = this.env.CONNECTION_DO.get(
|
|
251
|
-
this.env.CONNECTION_DO.idFromString(id),
|
|
252
|
-
);
|
|
250
|
+
const connStub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(id));
|
|
253
251
|
const dto = await (connStub as unknown as ConnectionRpc).getConnState();
|
|
254
252
|
if (dto !== null) {
|
|
255
253
|
out.set(id, deserialize(dto));
|
|
@@ -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;
|
|
@@ -56,6 +67,12 @@ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
|
|
|
56
67
|
serverName: env.SERVER_NAME,
|
|
57
68
|
networkName: env.NETWORK_NAME,
|
|
58
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
|
+
}
|
|
59
76
|
if (env.MOTD_LINES !== undefined && env.MOTD_LINES.length > 0) {
|
|
60
77
|
input.motdLines = env.MOTD_LINES.split('\n');
|
|
61
78
|
}
|
|
@@ -102,6 +119,22 @@ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
|
|
|
102
119
|
return input;
|
|
103
120
|
}
|
|
104
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
|
+
|
|
105
138
|
/**
|
|
106
139
|
* Parses a newline-delimited `username:password` string into the config's
|
|
107
140
|
* `saslAccounts` shape. Malformed entries are skipped so a trailing
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* `HibernatingWebSocketHandler` so idle WebSocket connections cost
|
|
7
7
|
* nothing between messages. When a message arrives the DO wakes, replays
|
|
8
8
|
* the persisted {@link ConnectionState} from `state.storage`, runs the
|
|
9
|
-
* frame through {@link ConnectionActor} with
|
|
10
|
-
*
|
|
9
|
+
* frame through {@link ConnectionActor} with the {@link CfRuntime}, and
|
|
10
|
+
* persists the updated state back.
|
|
11
11
|
*
|
|
12
12
|
* Lifecycle:
|
|
13
13
|
* - `fetch()` WebSocket upgrade; `acceptWebSocket`.
|
|
@@ -16,15 +16,16 @@
|
|
|
16
16
|
* - `alarm()` PING sweep; missing PONG disconnects.
|
|
17
17
|
*
|
|
18
18
|
* Cross-DO coordination:
|
|
19
|
-
* - `REGISTRY_DO`
|
|
20
|
-
* - `CHANNEL_DO` per lowercased channel
|
|
19
|
+
* - `REGISTRY_DO` — sharded nick registry (034).
|
|
20
|
+
* - `CHANNEL_DO` per lowercased channel — authoritative roster/modes
|
|
21
|
+
* (035).
|
|
21
22
|
*
|
|
22
23
|
* The actor's `ActorChannelAccess` is a passthrough that returns
|
|
23
24
|
* throwaway {@link ChannelState} objects — reducers mutate the local
|
|
24
25
|
* view and emit `ApplyChannelDelta` effects that the CfRuntime forwards
|
|
25
|
-
* to the authoritative ChannelDO.
|
|
26
|
-
*
|
|
27
|
-
* state)
|
|
26
|
+
* to the authoritative ChannelDO. Connection-authority commands and
|
|
27
|
+
* JOIN/QUIT fan-out are fully wired; richer channel reads (NAMES of
|
|
28
|
+
* remote state) are served via the ChannelDO snapshot RPC.
|
|
28
29
|
*/
|
|
29
30
|
|
|
30
31
|
import { DurableObject } from 'cloudflare:workers';
|
|
@@ -37,18 +38,20 @@ import {
|
|
|
37
38
|
type ConnectionState,
|
|
38
39
|
ConsoleLogger,
|
|
39
40
|
type IdFactory,
|
|
40
|
-
InMemoryAccountStore,
|
|
41
41
|
InMemoryMessageStore,
|
|
42
|
+
InMemoryMtlsIdentityProvider,
|
|
43
|
+
InMemoryNickHistoryStore,
|
|
42
44
|
LogLevel,
|
|
43
45
|
type Logger,
|
|
44
46
|
type MessageStore,
|
|
45
47
|
type MotdProvider,
|
|
48
|
+
type MtlsIdentityProvider,
|
|
49
|
+
type NickHistoryStore,
|
|
46
50
|
type RawLine,
|
|
47
51
|
type SaslAccountCredential,
|
|
48
52
|
type ServerConfig,
|
|
49
53
|
SystemClock,
|
|
50
54
|
UuidIdFactory,
|
|
51
|
-
applyChannelDelta,
|
|
52
55
|
createChannel,
|
|
53
56
|
createConnection,
|
|
54
57
|
toSnapshot,
|
|
@@ -56,8 +59,9 @@ import {
|
|
|
56
59
|
import { ConnectionActor } from '@serverless-ircd/irc-server';
|
|
57
60
|
import { makeCfRuntime } from './cf-runtime.js';
|
|
58
61
|
import type { CfConnectionHandlers } from './cf-runtime.js';
|
|
62
|
+
import { resolveAccountStore } from './d1-account-store.js';
|
|
59
63
|
import type { Env } from './env.js';
|
|
60
|
-
import {
|
|
64
|
+
import { STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
|
|
61
65
|
import type { PersistedConnectionState } from './serialize.js';
|
|
62
66
|
|
|
63
67
|
/** Default PING cadence (ms) — tuned for typical IRC client behavior. */
|
|
@@ -109,18 +113,52 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
109
113
|
*/
|
|
110
114
|
private messages: MessageStore | undefined;
|
|
111
115
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
+
* Per-connection nick-history store backing `WHOWAS`. The minimum-viable
|
|
117
|
+
* in-worker ring buffer is scoped to this connection: sign-offs this
|
|
118
|
+
* connection records are queryable by the same connection. A
|
|
119
|
+
* Durable-Object-backed persistent variant (shared across connections,
|
|
120
|
+
* keyed by nick) is a documented follow-up. Lazily constructed.
|
|
121
|
+
*/
|
|
122
|
+
private history: NickHistoryStore | undefined;
|
|
123
|
+
/**
|
|
124
|
+
* SASL account store, resolved once via {@link loadAccountStore} before the
|
|
125
|
+
* first frame dispatches. The two-phase load mirrors AWS's
|
|
126
|
+
* `loadDynamoAccountStore`: D1 `accounts` table (authoritative when it has
|
|
127
|
+
* rows) is queried at boot; when empty/unreachable the `SASL_ACCOUNTS`
|
|
128
|
+
* env-var seed provides the fallback `InMemoryAccountStore`. The
|
|
129
|
+
* `AccountStore.verify` port is synchronous, so pre-loading MUST complete
|
|
130
|
+
* before the first reducer runs.
|
|
116
131
|
*/
|
|
117
132
|
private accounts: AccountStore | undefined;
|
|
133
|
+
/** Guards {@link loadAccountStore} so the D1 query runs at most once. */
|
|
134
|
+
private accountsResolved = false;
|
|
135
|
+
/**
|
|
136
|
+
* Verified client-cert subject captured at WebSocket upgrade time when
|
|
137
|
+
* CF API Shield mTLS is configured. Surfaced to the SASL EXTERNAL reducer
|
|
138
|
+
* via an {@link MtlsIdentityProvider} so `AUTHENTICATE EXTERNAL` can map
|
|
139
|
+
* the cert subject to an account.
|
|
140
|
+
*
|
|
141
|
+
* CF API Shield mTLS setup:
|
|
142
|
+
* 1. Upload the client CA to API Shield (dashboard → Security → API Shield).
|
|
143
|
+
* 2. Create an mTLS policy on the custom hostname that requires client certs.
|
|
144
|
+
* 3. The verified subject is injected into `request.cf.tlsClientAuthCertSubject`
|
|
145
|
+
* on every request, which the Worker reads at upgrade time.
|
|
146
|
+
* When unset (no mTLS configured), EXTERNAL auth fails with `904`.
|
|
147
|
+
*/
|
|
148
|
+
private mtlsCertSubject: string | undefined;
|
|
118
149
|
private readonly clock: Clock = SystemClock;
|
|
119
150
|
private readonly ids: IdFactory = new UuidIdFactory();
|
|
120
151
|
private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
|
|
121
152
|
private readonly pongTimeoutMs: number = DEFAULT_PONG_TIMEOUT_MS;
|
|
122
|
-
|
|
123
|
-
// (
|
|
153
|
+
// Outbound lines are batched per call, not via a reserved instance
|
|
154
|
+
// field: `deliver()` joins every line of a ChannelDO broadcast into a
|
|
155
|
+
// single `WebSocket.send()`, and the actor `send` handler in
|
|
156
|
+
// `buildActor` does the same for one frame's response. Cross-call
|
|
157
|
+
// per-microtask coalescing (the optimization previously reserved here)
|
|
158
|
+
// was dropped — it would add a drain queue and ordering hazards between
|
|
159
|
+
// independent fan-out calls for no measured benefit (see ADR-003 and
|
|
160
|
+
// the CF adapter load-test report). Revisit only if WS round-trips
|
|
161
|
+
// become a demonstrated bottleneck.
|
|
124
162
|
|
|
125
163
|
// -------------------------------------------------------------------------
|
|
126
164
|
// WebSocket upgrade
|
|
@@ -134,6 +172,14 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
134
172
|
const pair = new WebSocketPair();
|
|
135
173
|
const server = pair[0];
|
|
136
174
|
const client = pair[1];
|
|
175
|
+
// Capture the verified client-cert subject when CF API Shield mTLS is
|
|
176
|
+
// active. `request.cf` is a Cloudflare-specific extension; the property
|
|
177
|
+
// is present only when a client certificate was verified against the
|
|
178
|
+
// uploaded CA pool.
|
|
179
|
+
const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
|
|
180
|
+
if (cf !== undefined && typeof cf.tlsClientAuthCertSubject === 'string') {
|
|
181
|
+
this.mtlsCertSubject = cf.tlsClientAuthCertSubject;
|
|
182
|
+
}
|
|
137
183
|
// Hibernation: `acceptWebSocket` accepts the server side implicitly.
|
|
138
184
|
// Calling `server.accept()` first throws "already accepted".
|
|
139
185
|
this.ctx.acceptWebSocket(server);
|
|
@@ -150,9 +196,9 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
150
196
|
override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
151
197
|
const text = typeof message === 'string' ? message : new TextDecoder().decode(message);
|
|
152
198
|
const state = await this.loadState();
|
|
153
|
-
const before = new Set(state.joinedChannels);
|
|
154
199
|
const beforeNick = state.nick;
|
|
155
200
|
|
|
201
|
+
await this.loadAccountStore();
|
|
156
202
|
const actor = this.buildActor(ws, state);
|
|
157
203
|
try {
|
|
158
204
|
await actor.receiveTextFrame(text);
|
|
@@ -165,17 +211,11 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
165
211
|
if (beforeNick === undefined && state.nick !== undefined) {
|
|
166
212
|
await this.ctx.storage.setAlarm(Date.now() + this.pingIntervalMs);
|
|
167
213
|
}
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
if (!before.has(chan)) {
|
|
174
|
-
// joinReducer already emitted an ApplyChannelDelta; the stub
|
|
175
|
-
// ChannelDO records it. No extra work needed here for 033.
|
|
176
|
-
void chan;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
214
|
+
// No per-ConnectionDO channel-registration step is needed here: channel
|
|
215
|
+
// membership is driven solely by the `ApplyChannelDelta` effect the
|
|
216
|
+
// joinReducer emits, which the CfRuntime forwards to the authoritative
|
|
217
|
+
// ChannelDO. The ChannelDO roster IS the fan-out registration target, so
|
|
218
|
+
// a second loop over newly joined channels here would be redundant.
|
|
179
219
|
}
|
|
180
220
|
|
|
181
221
|
/** QUIT fanout + nick release. RFC 1459: a missing close is a QUIT. */
|
|
@@ -310,9 +350,9 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
310
350
|
},
|
|
311
351
|
snapshot: (): ConnectionState | undefined => this.cached,
|
|
312
352
|
};
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
353
|
+
// The CfRuntime shards the registry by nick (034); the per-connection
|
|
354
|
+
// key passed here is ignored by `makeCfRuntime` (kept in the signature
|
|
355
|
+
// for call-site stability).
|
|
316
356
|
const registryKey = this.ctx.id.toString();
|
|
317
357
|
const runtime = makeCfRuntime(this.env, state.id, registryKey, handlers);
|
|
318
358
|
if (this.channelAccess === undefined) {
|
|
@@ -323,6 +363,10 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
323
363
|
// per-connection in the Workers dashboard.
|
|
324
364
|
const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
|
|
325
365
|
const accounts = this.accountStore();
|
|
366
|
+
const mtlsIdentity: MtlsIdentityProvider | undefined =
|
|
367
|
+
this.mtlsCertSubject !== undefined
|
|
368
|
+
? new InMemoryMtlsIdentityProvider([{ connId: state.id, identity: this.mtlsCertSubject }])
|
|
369
|
+
: undefined;
|
|
326
370
|
return new ConnectionActor({
|
|
327
371
|
state,
|
|
328
372
|
runtime,
|
|
@@ -332,7 +376,9 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
332
376
|
ids: this.ids,
|
|
333
377
|
motd,
|
|
334
378
|
messages: this.messageStore(),
|
|
379
|
+
history: this.historyStore(),
|
|
335
380
|
...(accounts !== undefined ? { accounts } : {}),
|
|
381
|
+
...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
|
|
336
382
|
logger,
|
|
337
383
|
});
|
|
338
384
|
}
|
|
@@ -340,6 +386,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
340
386
|
/** Tears down a connection: emit QUIT effects, release nick, close WS. */
|
|
341
387
|
private async tearDown(ws: WebSocket): Promise<void> {
|
|
342
388
|
const state = await this.loadState();
|
|
389
|
+
await this.loadAccountStore();
|
|
343
390
|
const actor = this.buildActor(ws, state);
|
|
344
391
|
// Drive QUIT through the actor so the same effect pipeline handles
|
|
345
392
|
// fanout as during normal operation.
|
|
@@ -372,6 +419,8 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
372
419
|
...DEFAULT_SERVER_CONFIG,
|
|
373
420
|
...(this.env.SERVER_NAME !== undefined ? { serverName: this.env.SERVER_NAME } : {}),
|
|
374
421
|
...(this.env.NETWORK_NAME !== undefined ? { networkName: this.env.NETWORK_NAME } : {}),
|
|
422
|
+
...(this.env.SERVER_VERSION !== undefined ? { serverVersion: this.env.SERVER_VERSION } : {}),
|
|
423
|
+
...(this.env.CREATED_AT !== undefined ? { createdAt: this.env.CREATED_AT } : {}),
|
|
375
424
|
};
|
|
376
425
|
}
|
|
377
426
|
|
|
@@ -398,17 +447,37 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
398
447
|
}
|
|
399
448
|
|
|
400
449
|
/**
|
|
401
|
-
* Lazily constructs the
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
|
|
405
|
-
|
|
450
|
+
* Lazily constructs the per-connection nick-history store. A future
|
|
451
|
+
* persistent variant (DO/KV-backed, keyed by nick) will replace this
|
|
452
|
+
* without touching the actor wiring.
|
|
453
|
+
*/
|
|
454
|
+
private historyStore(): NickHistoryStore {
|
|
455
|
+
if (this.history === undefined) {
|
|
456
|
+
this.history = new InMemoryNickHistoryStore(this.clock);
|
|
457
|
+
}
|
|
458
|
+
return this.history;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Resolves the SASL account store once (D1 table → `SASL_ACCOUNTS` seed),
|
|
463
|
+
* caching the result. Safe to call on every frame; the D1 query runs at
|
|
464
|
+
* most once per ConnectionDO instance. Must complete before the first
|
|
465
|
+
* reducer dispatches so the synchronous `AccountStore.verify` port has its
|
|
466
|
+
* data ready.
|
|
467
|
+
*/
|
|
468
|
+
private async loadAccountStore(): Promise<void> {
|
|
469
|
+
if (this.accountsResolved) return;
|
|
470
|
+
this.accountsResolved = true;
|
|
471
|
+
this.accounts = await resolveAccountStore(this.env.ACCOUNTS_DB, this.env.SASL_ACCOUNTS);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Returns the resolved account store, or `undefined` when no accounts are
|
|
476
|
+
* configured (so the actor's `ctx.accounts` stays unset: `AUTHENTICATE
|
|
477
|
+
* PLAIN` → `904`). Populated by {@link loadAccountStore}; callers MUST
|
|
478
|
+
* `await loadAccountStore()` before reading this.
|
|
406
479
|
*/
|
|
407
480
|
private accountStore(): AccountStore | undefined {
|
|
408
|
-
if (this.accounts !== undefined) return this.accounts;
|
|
409
|
-
const creds = parseSaslAccountsEnv(this.env.SASL_ACCOUNTS);
|
|
410
|
-
if (creds.length === 0) return undefined;
|
|
411
|
-
this.accounts = new InMemoryAccountStore(creds);
|
|
412
481
|
return this.accounts;
|
|
413
482
|
}
|
|
414
483
|
|
|
@@ -437,6 +506,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
437
506
|
private async tearDownNoSocket(state: ConnectionState): Promise<void> {
|
|
438
507
|
// Emit QUIT effects without dispatching transport calls (the socket
|
|
439
508
|
// is already gone). The cross-DO fanout still runs.
|
|
509
|
+
await this.loadAccountStore();
|
|
440
510
|
const noOpHandlers: CfConnectionHandlers = {
|
|
441
511
|
send: (): void => {},
|
|
442
512
|
disconnect: (): void => {},
|
|
@@ -456,6 +526,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
456
526
|
ids: this.ids,
|
|
457
527
|
motd: this.motdProvider(),
|
|
458
528
|
messages: this.messageStore(),
|
|
529
|
+
history: this.historyStore(),
|
|
459
530
|
});
|
|
460
531
|
await actor.receiveTextFrame('QUIT\r\n');
|
|
461
532
|
await this.releaseAndPersist(state);
|
|
@@ -629,12 +700,6 @@ class PassthroughChannelAccess {
|
|
|
629
700
|
}
|
|
630
701
|
}
|
|
631
702
|
|
|
632
|
-
// Reference `applyChannelDelta` to ensure the import is used downstream by
|
|
633
|
-
// tests that exercise state migrations; harmless if the symbol is unused
|
|
634
|
-
// in production today. (Removed from the ConnectionDO path because the
|
|
635
|
-
// authoritative delta application lives in the ChannelDO stub today.)
|
|
636
|
-
export { applyChannelDelta, PERSISTED_STATE_VERSION };
|
|
637
|
-
|
|
638
703
|
/**
|
|
639
704
|
* Parses the `SASL_ACCOUNTS` env var into credential entries.
|
|
640
705
|
*
|