serverless-ircd 0.7.0 → 0.8.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 +3 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +273 -29
- package/README.md +155 -55
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/src/worker.ts +4 -4
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/smoke.test.ts +5 -5
- package/apps/cf-worker/wrangler.test.toml +6 -6
- package/apps/cf-worker/wrangler.toml +7 -5
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +1 -0
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/web/landing/index.html +14 -16
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +23 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/tests/build-smoke.test.ts +14 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- package/docs/AWS-Deployment.md +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +546 -0
- package/docs/WebClientGuide.md +32 -31
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +5 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
- package/packages/aws-adapter/tests/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +112 -2
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +172 -0
- package/packages/irc-core/src/commands/chanserv.ts +882 -0
- package/packages/irc-core/src/commands/hostserv.ts +487 -0
- package/packages/irc-core/src/commands/index.ts +12 -0
- package/packages/irc-core/src/commands/join.ts +164 -8
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +390 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +61 -6
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +37 -0
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +21 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
- package/packages/irc-core/tests/commands/join.test.ts +393 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +788 -14
- package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +71 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +623 -12
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
- package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
10
|
+
import { GetCommand } from '@aws-sdk/lib-dynamodb';
|
|
10
11
|
import {
|
|
11
12
|
InMemoryAccountStore,
|
|
12
13
|
type ParsedServerConfig,
|
|
13
14
|
StaticMotdProvider,
|
|
14
15
|
} from '@serverless-ircd/irc-core';
|
|
15
16
|
import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
|
|
16
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
17
18
|
import { AwsRuntime } from '../src/aws-runtime.js';
|
|
18
19
|
import type { PostToConnection } from '../src/aws-runtime.js';
|
|
19
20
|
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
@@ -156,6 +157,23 @@ describe.skipIf(!available)('handlers', () => {
|
|
|
156
157
|
expect(res.headers?.['Sec-WebSocket-Protocol']).toBe('text.ircv3.net');
|
|
157
158
|
});
|
|
158
159
|
|
|
160
|
+
it('echoes text.ircv3.net (never binary) when binary is offered first', async () => {
|
|
161
|
+
// Reproduces the production 1003 close: the relay offers binary first,
|
|
162
|
+
// but API Gateway cannot transport binary frames, so the handler must
|
|
163
|
+
// agree to text.ircv3.net. Asserting the echoed subprotocol at the
|
|
164
|
+
// dispatch boundary guards against regressing to the generic selector
|
|
165
|
+
// which would negotiate binary and trigger an immediate 1003.
|
|
166
|
+
const res = await dispatch(
|
|
167
|
+
{
|
|
168
|
+
requestContext: { routeKey: '$connect', connectionId: 'c-sub-both' },
|
|
169
|
+
headers: { 'Sec-WebSocket-Protocol': 'binary.ircv3.net, text.ircv3.net' },
|
|
170
|
+
},
|
|
171
|
+
requireFx().deps,
|
|
172
|
+
);
|
|
173
|
+
expect(res.statusCode).toBe(200);
|
|
174
|
+
expect(res.headers?.['Sec-WebSocket-Protocol']).toBe('text.ircv3.net');
|
|
175
|
+
});
|
|
176
|
+
|
|
159
177
|
it('omits the Sec-WebSocket-Protocol header on a legacy connect', async () => {
|
|
160
178
|
const res = await dispatch(
|
|
161
179
|
{ requestContext: { routeKey: '$connect', connectionId: 'c-nosub' } },
|
|
@@ -329,6 +347,54 @@ describe.skipIf(!available)('handlers', () => {
|
|
|
329
347
|
expect(res.statusCode).toBe(410);
|
|
330
348
|
});
|
|
331
349
|
|
|
350
|
+
it('warns when the connection row is missing so the 410 path is visible in logs', async () => {
|
|
351
|
+
// Previously the 410 early-return logged nothing, which made a
|
|
352
|
+
// missing-row $default invisible to operators. The handler must now
|
|
353
|
+
// emit a warning naming the connection id.
|
|
354
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
355
|
+
await dispatch(
|
|
356
|
+
{
|
|
357
|
+
requestContext: { routeKey: '$default', connectionId: 'ghost-row' },
|
|
358
|
+
body: 'PING :tok\r\n',
|
|
359
|
+
},
|
|
360
|
+
requireFx().deps,
|
|
361
|
+
);
|
|
362
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
363
|
+
const msg = String(warnSpy.mock.calls[0]?.[0]);
|
|
364
|
+
expect(msg).toContain('[irc-handler]');
|
|
365
|
+
expect(msg).toContain('ghost-row');
|
|
366
|
+
warnSpy.mockRestore();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('reads the Connections row strongly consistently (avoids read-after-write 410)', async () => {
|
|
370
|
+
// $connect writes the row and $default reads it on the very next
|
|
371
|
+
// frame. An eventually-consistent Get can miss the just-written row
|
|
372
|
+
// (replication lag) and silently drop the client's first frame as a
|
|
373
|
+
// 410. The Get MUST be strongly consistent.
|
|
374
|
+
await dispatch(
|
|
375
|
+
{ requestContext: { routeKey: '$connect', connectionId: 'cr1' } },
|
|
376
|
+
requireFx().deps,
|
|
377
|
+
);
|
|
378
|
+
requireFx().mgmt.register('cr1', () => {});
|
|
379
|
+
const sendSpy = vi.spyOn(requireFx().client, 'send');
|
|
380
|
+
await dispatch(
|
|
381
|
+
{
|
|
382
|
+
requestContext: { routeKey: '$default', connectionId: 'cr1' },
|
|
383
|
+
body: 'PING :tok\r\n',
|
|
384
|
+
},
|
|
385
|
+
requireFx().deps,
|
|
386
|
+
);
|
|
387
|
+
const conGets = sendSpy.mock.calls
|
|
388
|
+
.map((call) => call[0] as unknown)
|
|
389
|
+
.filter(
|
|
390
|
+
(cmd): cmd is GetCommand =>
|
|
391
|
+
cmd instanceof GetCommand && (cmd.input.TableName?.endsWith('Connections') ?? false),
|
|
392
|
+
);
|
|
393
|
+
expect(conGets.length).toBeGreaterThan(0);
|
|
394
|
+
expect((conGets[0]?.input as { ConsistentRead?: boolean }).ConsistentRead).toBe(true);
|
|
395
|
+
sendSpy.mockRestore();
|
|
396
|
+
});
|
|
397
|
+
|
|
332
398
|
it('returns 400 when body is missing', async () => {
|
|
333
399
|
// Cast: APIGW always sends a body for $default; tests verify the guard.
|
|
334
400
|
const event = {
|
|
@@ -420,11 +486,15 @@ describe.skipIf(!available)('handlers', () => {
|
|
|
420
486
|
expect(calls.some((d) => d.includes(' 001 '))).toBe(true);
|
|
421
487
|
});
|
|
422
488
|
|
|
423
|
-
it('delivers one postToConnection per line
|
|
489
|
+
it('delivers one postToConnection per line when binary is offered first (selects text.ircv3.net)', async () => {
|
|
490
|
+
// API Gateway cannot transport binary frames (1003 close), so a
|
|
491
|
+
// `binary.ircv3.net, text.ircv3.net` offer resolves to text.ircv3.net
|
|
492
|
+
// and the connection is spec-text: one postToConnection per outbound
|
|
493
|
+
// IRC line, no trailing CRLF.
|
|
424
494
|
await dispatch(
|
|
425
495
|
{
|
|
426
496
|
requestContext: { routeKey: '$connect', connectionId: 'spec2' },
|
|
427
|
-
headers: { 'Sec-WebSocket-Protocol': 'binary.ircv3.net' },
|
|
497
|
+
headers: { 'Sec-WebSocket-Protocol': 'binary.ircv3.net, text.ircv3.net' },
|
|
428
498
|
},
|
|
429
499
|
requireFx().deps,
|
|
430
500
|
);
|
|
@@ -473,6 +543,38 @@ describe.skipIf(!available)('handlers', () => {
|
|
|
473
543
|
expect(calls[0]).toContain(' 001 ');
|
|
474
544
|
});
|
|
475
545
|
|
|
546
|
+
it('logs and still returns 200 when postToConnection fails (silent reply loss)', async () => {
|
|
547
|
+
// Reproduces the production failure mode where the management API
|
|
548
|
+
// rejects every post (gone connection, stale MANAGEMENT_URL, …).
|
|
549
|
+
// Previously the catch was empty → the operator saw zero logs and
|
|
550
|
+
// the client saw nothing. The handler must now surface the error.
|
|
551
|
+
await dispatch(
|
|
552
|
+
{ requestContext: { routeKey: '$connect', connectionId: 'dead1' } },
|
|
553
|
+
requireFx().deps,
|
|
554
|
+
);
|
|
555
|
+
const failingMgmt: PostToConnection = {
|
|
556
|
+
async postToConnection(): Promise<unknown> {
|
|
557
|
+
throw new Error('GoneException: 410');
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
561
|
+
const res = await dispatch(
|
|
562
|
+
{
|
|
563
|
+
requestContext: { routeKey: '$default', connectionId: 'dead1' },
|
|
564
|
+
body: 'NICK dead\r\nUSER dead 0 * :Dead\r\n',
|
|
565
|
+
},
|
|
566
|
+
{ ...requireFx().deps, managementApi: failingMgmt as never },
|
|
567
|
+
);
|
|
568
|
+
// Graceful degradation: mutations are persisted, status stays 200.
|
|
569
|
+
expect(res.statusCode).toBe(200);
|
|
570
|
+
// The reply failure is surfaced to CloudWatch (was previously silent).
|
|
571
|
+
expect(errorSpy).toHaveBeenCalledOnce();
|
|
572
|
+
const firstArg = String(errorSpy.mock.calls[0]?.[0]);
|
|
573
|
+
expect(firstArg).toContain('[irc-handler]');
|
|
574
|
+
expect(firstArg).toContain('postToConnection');
|
|
575
|
+
errorSpy.mockRestore();
|
|
576
|
+
});
|
|
577
|
+
|
|
476
578
|
it('persists joined channels across $default invocations', async () => {
|
|
477
579
|
await dispatch(
|
|
478
580
|
{ requestContext: { routeKey: '$connect', connectionId: 'c1' } },
|
|
@@ -13,15 +13,20 @@ import {
|
|
|
13
13
|
} from '../src/tables.js';
|
|
14
14
|
|
|
15
15
|
describe('TABLE_NAMES', () => {
|
|
16
|
-
it('contains exactly the
|
|
16
|
+
it('contains exactly the six canonical logical names', () => {
|
|
17
17
|
expect(TABLE_NAMES).toEqual([
|
|
18
18
|
'Connections',
|
|
19
19
|
'ChannelMeta',
|
|
20
20
|
'ChannelMembers',
|
|
21
21
|
'Nicks',
|
|
22
22
|
'Accounts',
|
|
23
|
+
'Services',
|
|
23
24
|
]);
|
|
24
25
|
});
|
|
26
|
+
|
|
27
|
+
it('TABLE_KEYS omits Services (the runtime reads SERVICES_TABLE directly)', () => {
|
|
28
|
+
expect(TABLE_KEYS).not.toHaveProperty('Services');
|
|
29
|
+
});
|
|
25
30
|
});
|
|
26
31
|
|
|
27
32
|
describe('TABLE_KEYS', () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/cf-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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,6 +38,14 @@ export interface CfConfigEnv {
|
|
|
38
38
|
CHANNEL_PREFIXES?: string;
|
|
39
39
|
OPER_USER?: string;
|
|
40
40
|
OPER_PASSWORD?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Server-password gate. Treat as a wrangler secret in production
|
|
43
|
+
* (never a plaintext `[vars]` entry); the loader threads it through
|
|
44
|
+
* to `ServerConfig.serverPassword` so the registration reducer can
|
|
45
|
+
* enforce `PASS <value>` before `001 RPL_WELCOME`. Undefined / empty
|
|
46
|
+
* disables the gate.
|
|
47
|
+
*/
|
|
48
|
+
SERVER_PASSWORD?: string;
|
|
41
49
|
MAX_CHANNELS_PER_USER?: string;
|
|
42
50
|
MAX_TARGETS_PER_COMMAND?: string;
|
|
43
51
|
NICK_LEN?: string;
|
|
@@ -92,6 +100,9 @@ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
|
|
|
92
100
|
},
|
|
93
101
|
];
|
|
94
102
|
}
|
|
103
|
+
if (env.SERVER_PASSWORD !== undefined) {
|
|
104
|
+
input.serverPassword = env.SERVER_PASSWORD;
|
|
105
|
+
}
|
|
95
106
|
if (env.MAX_CHANNELS_PER_USER !== undefined) {
|
|
96
107
|
input.maxChannelsPerUser = Number.parseInt(env.MAX_CHANNELS_PER_USER, 10);
|
|
97
108
|
}
|
|
@@ -67,6 +67,7 @@ import { makeCfRuntime } from './cf-runtime.js';
|
|
|
67
67
|
import type { CfConnectionHandlers } from './cf-runtime.js';
|
|
68
68
|
import { loadServerConfigFromCfEnv } from './config-loader.js';
|
|
69
69
|
import { resolveAccountStore } from './d1-account-store.js';
|
|
70
|
+
import { type D1ServicesStore, loadD1ServicesStore } from './d1-services-store.js';
|
|
70
71
|
import type { Env } from './env.js';
|
|
71
72
|
import { STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
|
|
72
73
|
import type { PersistedConnectionState } from './serialize.js';
|
|
@@ -114,6 +115,16 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
114
115
|
* should seed `createdAt` with its receive time.
|
|
115
116
|
*/
|
|
116
117
|
private channelAccess: PassthroughChannelAccess | undefined;
|
|
118
|
+
/**
|
|
119
|
+
* Test-only: when set, `webSocketClose`/`webSocketError` skip the
|
|
120
|
+
* `tearDown` cleanup. Simulates the production failure mode where the
|
|
121
|
+
* platform drops a hibernated WebSocket without ever delivering the
|
|
122
|
+
* close event to the DO — the exact condition the idle-sweep alarm
|
|
123
|
+
* exists as a backstop for. Lets the alarm test exercise the no-socket
|
|
124
|
+
* teardown path as the sole cleanup, without the close handler racing
|
|
125
|
+
* in to do the work first.
|
|
126
|
+
*/
|
|
127
|
+
private __suppressCloseTeardown = false;
|
|
117
128
|
/**
|
|
118
129
|
* Per-connection chat-history store backing `draft/chathistory`
|
|
119
130
|
* playback. The minimum-viable in-worker ring buffer is scoped to this
|
|
@@ -143,6 +154,17 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
143
154
|
private accounts: AccountStore | undefined;
|
|
144
155
|
/** Guards {@link loadAccountStore} so the D1 query runs at most once. */
|
|
145
156
|
private accountsResolved = false;
|
|
157
|
+
/**
|
|
158
|
+
* Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
|
|
159
|
+
* OperServ + read-marker), hydrated once via {@link loadServicesStore}
|
|
160
|
+
* before the first frame dispatches. The store mutates the in-memory
|
|
161
|
+
* cache synchronously and enqueues write-behind ops; {@link flushServices}
|
|
162
|
+
* drains them into D1 after each frame / at teardown. `undefined` when
|
|
163
|
+
* D1 is not configured (services unbound → no NickServ/ChanServ routing).
|
|
164
|
+
*/
|
|
165
|
+
private services: D1ServicesStore | undefined;
|
|
166
|
+
/** Guards {@link loadServicesStore} so the D1 scan runs at most once. */
|
|
167
|
+
private servicesResolved = false;
|
|
146
168
|
/**
|
|
147
169
|
* Verified client-cert subject captured at WebSocket upgrade time when
|
|
148
170
|
* CF API Shield mTLS is configured. Surfaced to the SASL EXTERNAL reducer
|
|
@@ -157,6 +179,17 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
157
179
|
* When unset (no mTLS configured), EXTERNAL auth fails with `904`.
|
|
158
180
|
*/
|
|
159
181
|
private mtlsCertSubject: string | undefined;
|
|
182
|
+
/**
|
|
183
|
+
* Client source IP captured at WebSocket upgrade time, surfaced onto the
|
|
184
|
+
* {@link ConnectionState.host} field so WHOIS / hostmasks resolve instead
|
|
185
|
+
* of falling back to `?`. Read from the Cloudflare edge header
|
|
186
|
+
* `CF-Connecting-IP` (set unconditionally by the CF proxy on every
|
|
187
|
+
* request), with `X-Real-IP` as a fallback for non-CF reverse-proxy
|
|
188
|
+
* deployments. When neither is present the host stays `undefined`
|
|
189
|
+
* (hostmasks omit the `@host` segment). Cloaking, if enabled, replaces
|
|
190
|
+
* this raw IP with a deterministic cloak at registration time.
|
|
191
|
+
*/
|
|
192
|
+
private sourceHost: string | undefined;
|
|
160
193
|
private readonly clock: Clock = SystemClock;
|
|
161
194
|
private readonly ids: IdFactory = new UuidIdFactory();
|
|
162
195
|
private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
|
|
@@ -193,6 +226,13 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
193
226
|
if (cf !== undefined && typeof cf.tlsClientAuthCertSubject === 'string') {
|
|
194
227
|
this.mtlsCertSubject = cf.tlsClientAuthCertSubject;
|
|
195
228
|
}
|
|
229
|
+
// Capture the client source IP for ConnectionState.host. CF-Connecting-IP
|
|
230
|
+
// is set by the Cloudflare edge on every proxied request; X-Real-IP is
|
|
231
|
+
// the conventional fallback for non-CF reverse proxies. Stashed on the
|
|
232
|
+
// instance because loadState runs lazily on the first event (and again
|
|
233
|
+
// after hibernation), not here at upgrade time.
|
|
234
|
+
this.sourceHost =
|
|
235
|
+
request.headers.get('CF-Connecting-IP') ?? request.headers.get('X-Real-IP') ?? undefined;
|
|
196
236
|
// IRCv3 WebSocket subprotocol negotiation: read the offered
|
|
197
237
|
// `Sec-WebSocket-Protocol` list, select the first supported entry, and
|
|
198
238
|
// echo it back when one was agreed. The negotiated frame mode is tagged
|
|
@@ -244,12 +284,14 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
244
284
|
const beforeNick = state.nick;
|
|
245
285
|
|
|
246
286
|
await this.loadAccountStore();
|
|
287
|
+
await this.loadServicesStore();
|
|
247
288
|
const actor = this.buildActor(ws, state, mode);
|
|
248
289
|
try {
|
|
249
290
|
await actor.receiveTextFrame(text);
|
|
250
291
|
} finally {
|
|
251
292
|
// Persist any mutations even if dispatch threw partway through.
|
|
252
293
|
await this.persistState(state);
|
|
294
|
+
await this.flushServices();
|
|
253
295
|
}
|
|
254
296
|
|
|
255
297
|
// If registration completed this frame, schedule a PING alarm.
|
|
@@ -270,11 +312,13 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
270
312
|
_reason: string,
|
|
271
313
|
_wasClean: boolean,
|
|
272
314
|
): Promise<void> {
|
|
315
|
+
if (this.__suppressCloseTeardown) return;
|
|
273
316
|
await this.tearDown(ws);
|
|
274
317
|
}
|
|
275
318
|
|
|
276
319
|
/** Same teardown as close. */
|
|
277
320
|
override async webSocketError(ws: WebSocket, _error: unknown): Promise<void> {
|
|
321
|
+
if (this.__suppressCloseTeardown) return;
|
|
278
322
|
await this.tearDown(ws);
|
|
279
323
|
}
|
|
280
324
|
|
|
@@ -293,12 +337,20 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
293
337
|
const state = await this.loadState();
|
|
294
338
|
const now = this.clock.now();
|
|
295
339
|
if (state.lastSeen + this.pongTimeoutMs < now) {
|
|
296
|
-
// Idle past the PONG threshold: tear down.
|
|
340
|
+
// Idle past the PONG threshold: tear down. Both branches must drive
|
|
341
|
+
// a synthetic QUIT through the actor so the channel rosters prune
|
|
342
|
+
// the member and shared-channel peers receive the QUIT line. When a
|
|
343
|
+
// live socket is present `tearDown` closes it; when the socket is
|
|
344
|
+
// already gone (lost close event, DO woke from hibernation with no
|
|
345
|
+
// socket) `tearDownNoSocket` runs the same effect pipeline against
|
|
346
|
+
// no-op transport handlers so the cross-DO fanout still fires.
|
|
347
|
+
// Using `releaseAndPersist` here would leak the member into every
|
|
348
|
+
// shared channel's roster indefinitely (ghost users in `/NAMES`).
|
|
297
349
|
const sockets = this.ctx.getWebSockets();
|
|
298
350
|
if (sockets.length > 0) {
|
|
299
351
|
await this.tearDown(sockets[0] as WebSocket);
|
|
300
352
|
} else {
|
|
301
|
-
await this.
|
|
353
|
+
await this.tearDownNoSocket(state);
|
|
302
354
|
}
|
|
303
355
|
return;
|
|
304
356
|
}
|
|
@@ -398,6 +450,13 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
398
450
|
id: this.ctx.id.toString(),
|
|
399
451
|
connectedSince: this.clock.now(),
|
|
400
452
|
});
|
|
453
|
+
// Seed the visible host from the upgrade-time source IP capture so
|
|
454
|
+
// WHOIS / hostmasks carry a real `@host` segment instead of the `?`
|
|
455
|
+
// fallback. Mirrors the local-cli / cf-tcp-container pattern. undefined
|
|
456
|
+
// when no source-IP header was present (hostmask omits @host entirely).
|
|
457
|
+
if (this.sourceHost !== undefined) {
|
|
458
|
+
this.cached.host = this.sourceHost;
|
|
459
|
+
}
|
|
401
460
|
} else {
|
|
402
461
|
this.cached = deserialize(record);
|
|
403
462
|
}
|
|
@@ -445,6 +504,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
445
504
|
// per-connection in the Workers dashboard.
|
|
446
505
|
const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
|
|
447
506
|
const accounts = this.accountStore();
|
|
507
|
+
const services = this.servicesStore();
|
|
448
508
|
const mtlsIdentity: MtlsIdentityProvider | undefined =
|
|
449
509
|
this.mtlsCertSubject !== undefined
|
|
450
510
|
? new InMemoryMtlsIdentityProvider([{ connId: state.id, identity: this.mtlsCertSubject }])
|
|
@@ -470,6 +530,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
470
530
|
// the DO, so every WS connection is secure → user mode `S`.
|
|
471
531
|
secure: true,
|
|
472
532
|
...(accounts !== undefined ? { accounts } : {}),
|
|
533
|
+
...(services !== undefined ? { services } : {}),
|
|
473
534
|
...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
|
|
474
535
|
logger,
|
|
475
536
|
});
|
|
@@ -479,11 +540,13 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
479
540
|
private async tearDown(ws: WebSocket): Promise<void> {
|
|
480
541
|
const state = await this.loadState();
|
|
481
542
|
await this.loadAccountStore();
|
|
543
|
+
await this.loadServicesStore();
|
|
482
544
|
const actor = this.buildActor(ws, state, this.wsFrameMode(ws));
|
|
483
545
|
// Drive QUIT through the actor so the same effect pipeline handles
|
|
484
546
|
// fanout as during normal operation.
|
|
485
547
|
await actor.receiveTextFrame('QUIT\r\n');
|
|
486
548
|
await this.releaseAndPersist(state);
|
|
549
|
+
await this.flushServices();
|
|
487
550
|
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
488
551
|
ws.close();
|
|
489
552
|
}
|
|
@@ -577,6 +640,40 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
577
640
|
return this.accounts;
|
|
578
641
|
}
|
|
579
642
|
|
|
643
|
+
/**
|
|
644
|
+
* Resolves the persistent services store once (D1 → write-behind cache),
|
|
645
|
+
* caching the result. Safe to call on every frame; the D1 scan runs at
|
|
646
|
+
* most once per ConnectionDO instance. Must complete before the first
|
|
647
|
+
* reducer dispatches so the synchronous `ServicesStore` port has its
|
|
648
|
+
* snapshot ready. Leaves `services` `undefined` when D1 is absent so the
|
|
649
|
+
* actor's `ctx.services` stays unset (no NickServ/ChanServ routing).
|
|
650
|
+
*/
|
|
651
|
+
private async loadServicesStore(): Promise<void> {
|
|
652
|
+
if (this.servicesResolved) return;
|
|
653
|
+
this.servicesResolved = true;
|
|
654
|
+
this.services = await loadD1ServicesStore(this.env.ACCOUNTS_DB, this.clock);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Returns the resolved services store, or `undefined` when services are
|
|
659
|
+
* not configured. Populated by {@link loadServicesStore}; callers MUST
|
|
660
|
+
* `await loadServicesStore()` before reading this.
|
|
661
|
+
*/
|
|
662
|
+
private servicesStore(): D1ServicesStore | undefined {
|
|
663
|
+
return this.services;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Drains any queued write-behind ops into D1. No-op when services are
|
|
668
|
+
* unbound or when no mutations occurred this turn. Called after every
|
|
669
|
+
* frame (the `webSocketMessage` finally block) and at teardown so a
|
|
670
|
+
* deploy / DO eviction does not lose services state.
|
|
671
|
+
*/
|
|
672
|
+
private async flushServices(): Promise<void> {
|
|
673
|
+
if (this.services === undefined) return;
|
|
674
|
+
await this.services.flush();
|
|
675
|
+
}
|
|
676
|
+
|
|
580
677
|
// -------------------------------------------------------------------------
|
|
581
678
|
// Test hooks (only invoked from `runInDurableObject` in tests)
|
|
582
679
|
// -------------------------------------------------------------------------
|
|
@@ -603,6 +700,8 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
603
700
|
// Emit QUIT effects without dispatching transport calls (the socket
|
|
604
701
|
// is already gone). The cross-DO fanout still runs.
|
|
605
702
|
await this.loadAccountStore();
|
|
703
|
+
await this.loadServicesStore();
|
|
704
|
+
const services = this.servicesStore();
|
|
606
705
|
const noOpHandlers: CfConnectionHandlers = {
|
|
607
706
|
send: (): void => {},
|
|
608
707
|
disconnect: (): void => {},
|
|
@@ -624,9 +723,11 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
624
723
|
motd: this.motdProvider(),
|
|
625
724
|
messages: this.messageStore(),
|
|
626
725
|
history: this.historyStore(),
|
|
726
|
+
...(services !== undefined ? { services } : {}),
|
|
627
727
|
});
|
|
628
728
|
await actor.receiveTextFrame('QUIT\r\n');
|
|
629
729
|
await this.releaseAndPersist(state);
|
|
730
|
+
await this.flushServices();
|
|
630
731
|
try {
|
|
631
732
|
await this.ctx.storage.deleteAlarm();
|
|
632
733
|
} catch {
|
|
@@ -651,6 +752,15 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
651
752
|
await this.persistState(state);
|
|
652
753
|
}
|
|
653
754
|
|
|
755
|
+
/**
|
|
756
|
+
* Test-only: suppresses the `webSocketClose`/`webSocketError` teardown
|
|
757
|
+
* so the idle-sweep alarm is the sole cleanup path — mirroring a lost
|
|
758
|
+
* close event in production. See {@link __suppressCloseTeardown}.
|
|
759
|
+
*/
|
|
760
|
+
public __suppressCloseTeardownHook(): void {
|
|
761
|
+
this.__suppressCloseTeardown = true;
|
|
762
|
+
}
|
|
763
|
+
|
|
654
764
|
/** Returns this DO's hex id — the canonical ConnId used in RPC routing. */
|
|
655
765
|
public __peekHexId(): string {
|
|
656
766
|
return this.ctx.id.toString();
|