serverless-ircd 0.4.0 → 0.6.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 +34 -2
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/CHANGELOG.md +435 -0
- package/README.md +206 -27
- package/apps/aws-stack/README.md +37 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +106 -13
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +49 -3
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.toml +5 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +138 -28
- package/apps/local-cli/tests/e2e.test.ts +113 -29
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
- package/biome.json +1 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +110 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +15 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +66 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +40 -2
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +100 -4
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +238 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +6 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +185 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +38 -4
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +42 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +59 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +12 -9
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +72 -10
- package/packages/irc-core/src/effects.ts +41 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +568 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +57 -11
- package/packages/irc-core/src/protocol/outbound.ts +36 -4
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +120 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +129 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -0
- package/packages/irc-core/tests/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +699 -19
- package/packages/irc-server/src/dispatch.ts +109 -16
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +2523 -42
- package/packages/irc-server/tests/dispatch.test.ts +300 -2
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/index.ts +6 -0
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- package/tools/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -16,9 +16,8 @@
|
|
|
16
16
|
* - `alarm()` PING sweep; missing PONG disconnects.
|
|
17
17
|
*
|
|
18
18
|
* Cross-DO coordination:
|
|
19
|
-
* - `REGISTRY_DO` — sharded nick registry
|
|
20
|
-
* - `CHANNEL_DO` per lowercased channel — authoritative roster/modes
|
|
21
|
-
* (035).
|
|
19
|
+
* - `REGISTRY_DO` — sharded nick registry.
|
|
20
|
+
* - `CHANNEL_DO` per lowercased channel — authoritative roster/modes.
|
|
22
21
|
*
|
|
23
22
|
* The actor's `ActorChannelAccess` is a passthrough that returns
|
|
24
23
|
* throwaway {@link ChannelState} objects — reducers mutate the local
|
|
@@ -52,35 +51,40 @@ import {
|
|
|
52
51
|
type ServerConfig,
|
|
53
52
|
SystemClock,
|
|
54
53
|
UuidIdFactory,
|
|
54
|
+
type WsFrameMode,
|
|
55
55
|
createChannel,
|
|
56
56
|
createConnection,
|
|
57
|
+
frameToLines,
|
|
58
|
+
isWithinWsByteBudget,
|
|
59
|
+
parseSecWsProtocolOffers,
|
|
60
|
+
sanitizeForTextMode,
|
|
61
|
+
selectSubprotocol,
|
|
57
62
|
toSnapshot,
|
|
63
|
+
wsFrameModeFor,
|
|
58
64
|
} from '@serverless-ircd/irc-core';
|
|
59
|
-
import { ConnectionActor } from '@serverless-ircd/irc-server';
|
|
65
|
+
import { ConnectionActor, type Transport } from '@serverless-ircd/irc-server';
|
|
60
66
|
import { makeCfRuntime } from './cf-runtime.js';
|
|
61
67
|
import type { CfConnectionHandlers } from './cf-runtime.js';
|
|
68
|
+
import { loadServerConfigFromCfEnv } from './config-loader.js';
|
|
62
69
|
import { resolveAccountStore } from './d1-account-store.js';
|
|
63
70
|
import type { Env } from './env.js';
|
|
64
71
|
import { STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
|
|
65
72
|
import type { PersistedConnectionState } from './serialize.js';
|
|
73
|
+
import { CfStats } from './stats.js';
|
|
66
74
|
|
|
67
75
|
/** Default PING cadence (ms) — tuned for typical IRC client behavior. */
|
|
68
76
|
export const DEFAULT_PING_INTERVAL_MS = 60_000;
|
|
69
77
|
/** Default no-PONG disconnect threshold (ms). */
|
|
70
78
|
export const DEFAULT_PONG_TIMEOUT_MS = 90_000;
|
|
71
79
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
channelLen: 50,
|
|
81
|
-
topicLen: 390,
|
|
82
|
-
quitMessage: 'Client Quit',
|
|
83
|
-
};
|
|
80
|
+
/**
|
|
81
|
+
* Wall-clock timestamp captured at this worker isolate's first module load
|
|
82
|
+
* (cold start). Used as the `uptimeStartedAt` anchor for {@link CfStats} so
|
|
83
|
+
* `STATS u` reports uptime for the current isolate. A dedicated stats DO
|
|
84
|
+
* would supply a deployment-wide uptime (future work); for now each
|
|
85
|
+
* isolate reports its own.
|
|
86
|
+
*/
|
|
87
|
+
const WORKER_STARTUP_AT = Date.now();
|
|
84
88
|
|
|
85
89
|
/**
|
|
86
90
|
* ConnectionDO instance.
|
|
@@ -91,6 +95,13 @@ const DEFAULT_SERVER_CONFIG: ServerConfig = {
|
|
|
91
95
|
export class ConnectionDO extends DurableObject<Env> {
|
|
92
96
|
/** Cached connection state; reloaded from storage after hibernation. */
|
|
93
97
|
private cached: ConnectionState | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* Cached reducer-facing server config. Parsed once from `this.env`
|
|
100
|
+
* (bindings are stable for the DO's lifetime) so the schema-validated
|
|
101
|
+
* `loadServerConfigFromCfEnv` runs a single time rather than on every
|
|
102
|
+
* WebSocket frame.
|
|
103
|
+
*/
|
|
104
|
+
private cachedServerConfig: ServerConfig | undefined;
|
|
94
105
|
/**
|
|
95
106
|
* Per-instance channel-state cache. Reused across every WebSocket frame
|
|
96
107
|
* the connection handles (within a single hibernation cycle) so that
|
|
@@ -150,15 +161,17 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
150
161
|
private readonly ids: IdFactory = new UuidIdFactory();
|
|
151
162
|
private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
|
|
152
163
|
private readonly pongTimeoutMs: number = DEFAULT_PONG_TIMEOUT_MS;
|
|
153
|
-
// Outbound lines are batched per call, not via a reserved
|
|
154
|
-
// field: `deliver()` joins every line of a ChannelDO
|
|
155
|
-
// single `WebSocket.send()`, and the actor `send`
|
|
156
|
-
// `buildActor` does the same for one frame's response.
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
164
|
+
// Outbound lines are batched per call (legacy mode), not via a reserved
|
|
165
|
+
// instance field: a legacy `deliver()` joins every line of a ChannelDO
|
|
166
|
+
// broadcast into a single `WebSocket.send()`, and the legacy actor `send`
|
|
167
|
+
// handler in `buildActor` does the same for one frame's response. An
|
|
168
|
+
// IRCv3 spec-mode connection instead sends one line per WS message with
|
|
169
|
+
// no trailing CR-LF (see `sendOutbound`). Cross-call per-microtask
|
|
170
|
+
// coalescing (the optimization previously reserved here) was dropped — it
|
|
171
|
+
// would add a drain queue and ordering hazards between independent
|
|
172
|
+
// fan-out calls for no measured benefit (see ADR-003 and the CF adapter
|
|
173
|
+
// load-test report). Revisit only if WS round-trips become a demonstrated
|
|
174
|
+
// bottleneck.
|
|
162
175
|
|
|
163
176
|
// -------------------------------------------------------------------------
|
|
164
177
|
// WebSocket upgrade
|
|
@@ -180,12 +193,23 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
180
193
|
if (cf !== undefined && typeof cf.tlsClientAuthCertSubject === 'string') {
|
|
181
194
|
this.mtlsCertSubject = cf.tlsClientAuthCertSubject;
|
|
182
195
|
}
|
|
196
|
+
// IRCv3 WebSocket subprotocol negotiation: read the offered
|
|
197
|
+
// `Sec-WebSocket-Protocol` list, select the first supported entry, and
|
|
198
|
+
// echo it back when one was agreed. The negotiated frame mode is tagged
|
|
199
|
+
// onto the hibernated socket so it survives eviction and can be
|
|
200
|
+
// recovered in every event handler (webSocketMessage / deliver / alarm).
|
|
201
|
+
const offerHeader = request.headers.get('Sec-WebSocket-Protocol');
|
|
202
|
+
const chosen = selectSubprotocol(parseSecWsProtocolOffers(offerHeader));
|
|
203
|
+
const mode = wsFrameModeFor(chosen ?? undefined);
|
|
183
204
|
// Hibernation: `acceptWebSocket` accepts the server side implicitly.
|
|
184
|
-
// Calling `server.accept()` first throws "already accepted".
|
|
185
|
-
|
|
205
|
+
// Calling `server.accept()` first throws "already accepted". The tag
|
|
206
|
+
// records the negotiated frame mode for the outbound + inbound paths.
|
|
207
|
+
this.ctx.acceptWebSocket(server, [`ws:${mode}`]);
|
|
186
208
|
// Schedule the first PING sweep.
|
|
187
209
|
await this.ctx.storage.setAlarm(Date.now() + this.pingIntervalMs);
|
|
188
|
-
|
|
210
|
+
const headers = new Headers();
|
|
211
|
+
if (chosen !== null) headers.set('Sec-WebSocket-Protocol', chosen);
|
|
212
|
+
return new Response(null, { status: 101, webSocket: client, headers });
|
|
189
213
|
}
|
|
190
214
|
|
|
191
215
|
// -------------------------------------------------------------------------
|
|
@@ -194,12 +218,33 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
194
218
|
|
|
195
219
|
/** Frame → actor → dispatch → persist. */
|
|
196
220
|
override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
197
|
-
const
|
|
221
|
+
const mode = this.wsFrameMode(ws);
|
|
222
|
+
// text.ircv3.net is a text-only subprotocol: a binary frame on such a
|
|
223
|
+
// connection is a protocol violation. RFC 6455 close code 1003 (unsupported
|
|
224
|
+
// data) is the spec-mandated response.
|
|
225
|
+
if (mode === 'spec-text' && typeof message !== 'string') {
|
|
226
|
+
ws.close(1003, 'binary frame not permitted on text.ircv3.net');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const raw = typeof message === 'string' ? message : new TextDecoder().decode(message);
|
|
230
|
+
// IRCv3 WebSocket spec: a single message MUST fit the 510-byte budget
|
|
231
|
+
// (the 512-byte IRC line limit minus the omitted trailing CR-LF). An
|
|
232
|
+
// oversize message is a protocol violation; RFC 6455 close code 1009
|
|
233
|
+
// (Message Too Big) is the spec-mandated response. Legacy connections
|
|
234
|
+
// keep the parser's existing 512-byte line cap, so the budget is
|
|
235
|
+
// enforced for negotiated spec modes only.
|
|
236
|
+
if ((mode === 'spec-text' || mode === 'spec-binary') && !isWithinWsByteBudget(raw)) {
|
|
237
|
+
ws.close(1009, 'Message Too Big');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// text.ircv3.net frames are UTF-8 text; lone surrogates have no valid
|
|
241
|
+
// encoding and are substituted with U+FFFD before framing.
|
|
242
|
+
const text = mode === 'spec-text' ? sanitizeForTextMode(raw) : raw;
|
|
198
243
|
const state = await this.loadState();
|
|
199
244
|
const beforeNick = state.nick;
|
|
200
245
|
|
|
201
246
|
await this.loadAccountStore();
|
|
202
|
-
const actor = this.buildActor(ws, state);
|
|
247
|
+
const actor = this.buildActor(ws, state, mode);
|
|
203
248
|
try {
|
|
204
249
|
await actor.receiveTextFrame(text);
|
|
205
250
|
} finally {
|
|
@@ -242,7 +287,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
242
287
|
* been idle past the PONG timeout, closes it.
|
|
243
288
|
*
|
|
244
289
|
* PLAN §6.1 — DO alarms are the canonical PING/idle mechanism on CF;
|
|
245
|
-
* equivalent to EventBridge Scheduler on AWS
|
|
290
|
+
* equivalent to EventBridge Scheduler on AWS.
|
|
246
291
|
*/
|
|
247
292
|
override async alarm(): Promise<void> {
|
|
248
293
|
const state = await this.loadState();
|
|
@@ -260,7 +305,8 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
260
305
|
// Send PING. PONG replies update lastSeen via the actor.
|
|
261
306
|
const token = this.ids.nonce();
|
|
262
307
|
for (const ws of this.ctx.getWebSockets()) {
|
|
263
|
-
|
|
308
|
+
const socket = ws as WebSocket;
|
|
309
|
+
this.sendOutbound(socket, [{ text: `PING :${token}` }], this.wsFrameMode(socket));
|
|
264
310
|
}
|
|
265
311
|
await this.ctx.storage.setAlarm(now + this.pingIntervalMs);
|
|
266
312
|
}
|
|
@@ -291,14 +337,11 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
291
337
|
*/
|
|
292
338
|
async deliver(lines: RawLine[]): Promise<{ delivered: number }> {
|
|
293
339
|
let delivered = 0;
|
|
294
|
-
let text: string | undefined;
|
|
295
|
-
if (lines.length > 0) {
|
|
296
|
-
text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
|
|
297
|
-
}
|
|
298
340
|
for (const ws of this.ctx.getWebSockets()) {
|
|
299
341
|
const socket = ws as WebSocket;
|
|
300
342
|
if (socket.readyState !== WebSocket.OPEN) continue;
|
|
301
|
-
|
|
343
|
+
const mode = this.wsFrameMode(socket);
|
|
344
|
+
this.sendOutbound(socket, lines, mode);
|
|
302
345
|
delivered++;
|
|
303
346
|
}
|
|
304
347
|
return { delivered };
|
|
@@ -308,6 +351,44 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
308
351
|
// Internal helpers
|
|
309
352
|
// -------------------------------------------------------------------------
|
|
310
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Recovers the negotiated WebSocket frame mode for `ws` from its
|
|
356
|
+
* hibernation tag. The tag is written once in `fetch` and survives DO
|
|
357
|
+
* eviction, so this returns the correct mode on every wake. Defaults to
|
|
358
|
+
* the legacy-tolerant mode when no tag is present (e.g. a socket accepted
|
|
359
|
+
* before subprotocol negotiation existed).
|
|
360
|
+
*/
|
|
361
|
+
private wsFrameMode(ws: WebSocket): WsFrameMode {
|
|
362
|
+
return wsFrameModeFromTags(this.ctx.getTags(ws));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Writes `lines` to `ws` according to `mode`:
|
|
367
|
+
* - `spec-text` — one `ws.send` per line, no trailing CR-LF, lone
|
|
368
|
+
* surrogates substituted with U+FFFD (UTF-8 safety).
|
|
369
|
+
* - `spec-binary` — one `ws.send` per line as a UTF-8 binary frame,
|
|
370
|
+
* no trailing CR-LF.
|
|
371
|
+
* - `legacy` — all lines joined with `\r\n` plus a trailing
|
|
372
|
+
* `\r\n`, sent as a single WebSocket message.
|
|
373
|
+
*
|
|
374
|
+
* No-ops when the socket is closed or `lines` is empty (preserving the
|
|
375
|
+
* empty-payload probe contract of {@link deliver}).
|
|
376
|
+
*/
|
|
377
|
+
private sendOutbound(ws: WebSocket, lines: RawLine[], mode: WsFrameMode): void {
|
|
378
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
379
|
+
if (lines.length === 0) return;
|
|
380
|
+
if (mode === 'spec-text') {
|
|
381
|
+
for (const line of lines) ws.send(sanitizeForTextMode(line.text));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (mode === 'spec-binary') {
|
|
385
|
+
const enc = new TextEncoder();
|
|
386
|
+
for (const line of lines) ws.send(enc.encode(line.text));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
ws.send(`${lines.map((l) => l.text).join('\r\n')}\r\n`);
|
|
390
|
+
}
|
|
391
|
+
|
|
311
392
|
/** Loads the connection state from storage, caching for the event. */
|
|
312
393
|
private async loadState(): Promise<ConnectionState> {
|
|
313
394
|
if (this.cached !== undefined) return this.cached;
|
|
@@ -330,27 +411,28 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
330
411
|
}
|
|
331
412
|
|
|
332
413
|
/**
|
|
333
|
-
* Constructs an actor wired to this DO's CfRuntime.
|
|
414
|
+
* Constructs an actor wired to this DO's CfRuntime. `mode` drives both the
|
|
415
|
+
* inbound line-framing transport (spec mode: one IRC message per frame,
|
|
416
|
+
* never split; legacy: split on `\r\n`) and the outbound delivery shape
|
|
417
|
+
* (spec mode: one `WebSocket.send` per line, no trailing CR-LF; legacy:
|
|
418
|
+
* batched into a single `\r\n`-joined frame).
|
|
334
419
|
*/
|
|
335
|
-
private buildActor(ws: WebSocket, state: ConnectionState): ConnectionActor {
|
|
420
|
+
private buildActor(ws: WebSocket, state: ConnectionState, mode: WsFrameMode): ConnectionActor {
|
|
336
421
|
const serverConfig = this.serverConfig();
|
|
337
422
|
const motd = this.motdProvider();
|
|
338
423
|
const handlers = {
|
|
339
424
|
send: (lines: RawLine[]): void => {
|
|
340
|
-
|
|
341
|
-
const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
|
|
342
|
-
ws.send(text);
|
|
343
|
-
}
|
|
425
|
+
this.sendOutbound(ws, lines, mode);
|
|
344
426
|
},
|
|
345
427
|
disconnect: (reason?: string): void => {
|
|
346
|
-
if (reason !== undefined
|
|
347
|
-
|
|
428
|
+
if (reason !== undefined) {
|
|
429
|
+
this.sendOutbound(ws, [{ text: `ERROR :Closing link: (${reason})` }], mode);
|
|
348
430
|
}
|
|
349
431
|
ws.close();
|
|
350
432
|
},
|
|
351
433
|
snapshot: (): ConnectionState | undefined => this.cached,
|
|
352
434
|
};
|
|
353
|
-
// The CfRuntime shards the registry by nick
|
|
435
|
+
// The CfRuntime shards the registry by nick; the per-connection
|
|
354
436
|
// key passed here is ignored by `makeCfRuntime` (kept in the signature
|
|
355
437
|
// for call-site stability).
|
|
356
438
|
const registryKey = this.ctx.id.toString();
|
|
@@ -372,11 +454,21 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
372
454
|
runtime,
|
|
373
455
|
channels: this.channelAccess,
|
|
374
456
|
serverConfig,
|
|
457
|
+
configSource: 'KV',
|
|
375
458
|
clock: this.clock,
|
|
376
459
|
ids: this.ids,
|
|
377
460
|
motd,
|
|
378
461
|
messages: this.messageStore(),
|
|
379
462
|
history: this.historyStore(),
|
|
463
|
+
// CfStats fans out across the channel + connection RPCs the runtime
|
|
464
|
+
// already exposes. `uptimeStartedAt` is captured at the worker's
|
|
465
|
+
// module load (cold start); a dedicated stats DO would give a
|
|
466
|
+
// deployment-wide uptime, tracked as future work.
|
|
467
|
+
stats: new CfStats(runtime, WORKER_STARTUP_AT),
|
|
468
|
+
transport: makeWsFrameTransport(mode),
|
|
469
|
+
// Cloudflare terminates TLS at the edge before the WebSocket reaches
|
|
470
|
+
// the DO, so every WS connection is secure → user mode `S`.
|
|
471
|
+
secure: true,
|
|
380
472
|
...(accounts !== undefined ? { accounts } : {}),
|
|
381
473
|
...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
|
|
382
474
|
logger,
|
|
@@ -387,7 +479,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
387
479
|
private async tearDown(ws: WebSocket): Promise<void> {
|
|
388
480
|
const state = await this.loadState();
|
|
389
481
|
await this.loadAccountStore();
|
|
390
|
-
const actor = this.buildActor(ws, state);
|
|
482
|
+
const actor = this.buildActor(ws, state, this.wsFrameMode(ws));
|
|
391
483
|
// Drive QUIT through the actor so the same effect pipeline handles
|
|
392
484
|
// fanout as during normal operation.
|
|
393
485
|
await actor.receiveTextFrame('QUIT\r\n');
|
|
@@ -415,13 +507,17 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
415
507
|
}
|
|
416
508
|
|
|
417
509
|
private serverConfig(): ServerConfig {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
510
|
+
// Single source of truth: delegate to the schema-validated loader so
|
|
511
|
+
// every reducer-facing field (operCreds, serverPassword, motdLines,
|
|
512
|
+
// limit knobs, …) is populated from `this.env` exactly as the shared
|
|
513
|
+
// `ServerConfigSchema` prescribes. The previous hand-rolled literal
|
|
514
|
+
// silently dropped fields like operCreds, which left OPER unable to
|
|
515
|
+
// authenticate (491 ERR_NOOPERHOST) even when OPER_USER/OPER_PASSWORD
|
|
516
|
+
// were bound. See config-loader.ts and config.ts.
|
|
517
|
+
if (this.cachedServerConfig === undefined) {
|
|
518
|
+
this.cachedServerConfig = loadServerConfigFromCfEnv(this.env);
|
|
519
|
+
}
|
|
520
|
+
return this.cachedServerConfig;
|
|
425
521
|
}
|
|
426
522
|
|
|
427
523
|
private motdProvider(): MotdProvider {
|
|
@@ -522,6 +618,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
522
618
|
runtime,
|
|
523
619
|
channels: this.channelAccess,
|
|
524
620
|
serverConfig: this.serverConfig(),
|
|
621
|
+
configSource: 'KV',
|
|
525
622
|
clock: this.clock,
|
|
526
623
|
ids: this.ids,
|
|
527
624
|
motd: this.motdProvider(),
|
|
@@ -724,3 +821,37 @@ export function parseSaslAccountsEnv(raw: string | undefined): SaslAccountCreden
|
|
|
724
821
|
}
|
|
725
822
|
return out;
|
|
726
823
|
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Hibernation-tag prefixes written by {@link ConnectionDO.fetch}. Each tag
|
|
827
|
+
* encodes the negotiated {@link WsFrameMode} so it survives DO eviction and
|
|
828
|
+
* can be recovered in every WebSocket event handler via `ctx.getTags(ws)`.
|
|
829
|
+
*/
|
|
830
|
+
const WS_TAG_LEGACY = 'ws:legacy';
|
|
831
|
+
const WS_TAG_SPEC_TEXT = 'ws:spec-text';
|
|
832
|
+
const WS_TAG_SPEC_BINARY = 'ws:spec-binary';
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Recovers the {@link WsFrameMode} from a hibernated socket's tags.
|
|
836
|
+
* Defaults to the legacy-tolerant mode when no recognized tag is present.
|
|
837
|
+
*/
|
|
838
|
+
export function wsFrameModeFromTags(tags: readonly string[]): WsFrameMode {
|
|
839
|
+
for (const tag of tags) {
|
|
840
|
+
if (tag === WS_TAG_SPEC_TEXT) return 'spec-text';
|
|
841
|
+
if (tag === WS_TAG_SPEC_BINARY) return 'spec-binary';
|
|
842
|
+
if (tag === WS_TAG_LEGACY) return 'legacy';
|
|
843
|
+
}
|
|
844
|
+
return 'legacy';
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Builds the inbound line-framing {@link Transport} for `mode`. Spec modes
|
|
849
|
+
* treat one WebSocket message as exactly one IRC line (a single trailing
|
|
850
|
+
* CR-LF is stripped and the frame is never split); legacy mode splits the
|
|
851
|
+
* frame on `\r\n` so older clients that concatenate messages keep working.
|
|
852
|
+
*/
|
|
853
|
+
export function makeWsFrameTransport(mode: WsFrameMode): Transport {
|
|
854
|
+
return {
|
|
855
|
+
feed: (chunk: string): string[] => frameToLines(chunk, mode),
|
|
856
|
+
};
|
|
857
|
+
}
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
/**
|
|
10
10
|
* The production {@link Env} for `apps/cf-worker`. ConnectionDO uses the
|
|
11
11
|
* four DO namespaces below to coordinate with the registry, channel, and
|
|
12
|
-
* channel-registry authorities. The CfRuntime
|
|
12
|
+
* channel-registry authorities. The CfRuntime wires them together
|
|
13
13
|
* behind the platform-agnostic {@link IrcRuntime} port; ConnectionDO
|
|
14
|
-
*
|
|
14
|
+
* only needs `CONNECTION_DO` itself plus the three collaborator
|
|
15
15
|
* namespaces.
|
|
16
16
|
*
|
|
17
17
|
* RPC method shapes on {@link RegistryRpc} and {@link ChannelRpc} are
|
|
@@ -23,7 +23,7 @@ export interface Env {
|
|
|
23
23
|
CONNECTION_DO: DurableObjectNamespace;
|
|
24
24
|
/**
|
|
25
25
|
* Nick registry authority. RPC: reserveNick / changeNick / releaseNick /
|
|
26
|
-
* lookupNick. Backed by the sharded {@link RegistryDO}
|
|
26
|
+
* lookupNick. Backed by the sharded {@link RegistryDO}, which keys
|
|
27
27
|
* instances by `hash(nick) % N` so the single-threaded DO gives the nick
|
|
28
28
|
* uniqueness invariant for free.
|
|
29
29
|
*
|
|
@@ -34,7 +34,7 @@ export interface Env {
|
|
|
34
34
|
/**
|
|
35
35
|
* Channel authority, keyed by lowercased channel name. RPC: broadcast /
|
|
36
36
|
* applyChannelDelta / getChannelSnapshot / listMembers. Backed by the
|
|
37
|
-
* {@link ChannelDO}
|
|
37
|
+
* {@link ChannelDO}, which owns the authoritative roster, modes,
|
|
38
38
|
* topic, and ban list and fans out broadcasts to each member's
|
|
39
39
|
* ConnectionDO.
|
|
40
40
|
*/
|
|
@@ -77,22 +77,41 @@ export interface Env {
|
|
|
77
77
|
* end-to-end. Empty/undefined disables SASL account verification.
|
|
78
78
|
*/
|
|
79
79
|
SASL_ACCOUNTS?: string;
|
|
80
|
+
/**
|
|
81
|
+
* IRC operator name consulted by the `OPER` reducer. Pair with
|
|
82
|
+
* {@link OPER_PASSWORD}; when either is set the connection-do threads
|
|
83
|
+
* both into the reducer-facing `ServerConfig.operCreds` via
|
|
84
|
+
* `loadServerConfigFromCfEnv`.
|
|
85
|
+
*/
|
|
86
|
+
OPER_USER?: string;
|
|
87
|
+
/**
|
|
88
|
+
* IRC operator password paired with {@link OPER_USER}. Treat as a
|
|
89
|
+
* Cloudflare secret (never a plaintext `[vars]` entry) in production.
|
|
90
|
+
*/
|
|
91
|
+
OPER_PASSWORD?: string;
|
|
80
92
|
}
|
|
81
93
|
|
|
82
94
|
/**
|
|
83
95
|
* RPC contract ConnectionDO expects from the registry. The real
|
|
84
|
-
* RegistryDO
|
|
96
|
+
* RegistryDO and the test stub both implement this.
|
|
85
97
|
*/
|
|
86
98
|
export interface RegistryRpc {
|
|
87
99
|
reserveNick(nick: string, conn: string): Promise<{ ok: true } | { ok: false }>;
|
|
88
100
|
changeNick(conn: string, oldNick: string, newNick: string): Promise<boolean>;
|
|
89
101
|
releaseNick(nick: string): Promise<void>;
|
|
90
102
|
lookupNick(nick: string): Promise<string | null>;
|
|
103
|
+
/**
|
|
104
|
+
* Enumerates every `(nickLower, connectionId)` entry in this shard. Used
|
|
105
|
+
* by global fan-out paths (e.g. `WALLOPS`) that need to reach connections
|
|
106
|
+
* regardless of shared channel membership. Returns a plain array so the
|
|
107
|
+
* RPC marshals cleanly across the DO boundary.
|
|
108
|
+
*/
|
|
109
|
+
listEntries(): Promise<Array<[nickLower: string, connId: string]>>;
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
/**
|
|
94
113
|
* RPC contract ConnectionDO expects from each channel instance. The real
|
|
95
|
-
* ChannelDO
|
|
114
|
+
* ChannelDO and the test stub both implement this.
|
|
96
115
|
*
|
|
97
116
|
* Note: `broadcast` accepts already-formatted raw line texts (no
|
|
98
117
|
* `RawLine` wrapper) because that's what `cf-runtime.broadcast`
|
|
@@ -23,6 +23,8 @@ export {
|
|
|
23
23
|
} from './sharding.js';
|
|
24
24
|
export { makeCfRuntime } from './cf-runtime.js';
|
|
25
25
|
export type { CfConnectionHandlers } from './cf-runtime.js';
|
|
26
|
+
export { CfStats } from './stats.js';
|
|
27
|
+
export type { CfStatsRuntime } from './stats.js';
|
|
26
28
|
export type { ChannelRpc, ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
|
|
27
29
|
export {
|
|
28
30
|
CREATE_ACCOUNTS_TABLE_SQL,
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
* and the second sees the new owner and fails.
|
|
24
24
|
*
|
|
25
25
|
* RPC surface — implements {@link RegistryRpc}, the same shape the
|
|
26
|
-
* stub `RecordingRegistryDO` exposed for
|
|
27
|
-
* (`CfRuntime
|
|
26
|
+
* stub `RecordingRegistryDO` exposed for the connection tests. Consumers
|
|
27
|
+
* (`CfRuntime`) call these methods via `env.REGISTRY_DO`
|
|
28
28
|
* after computing the shard key with {@link registryKeyForNick}.
|
|
29
29
|
*/
|
|
30
30
|
|
|
@@ -85,7 +85,7 @@ export class RegistryDO extends DurableObject implements RegistryRpc {
|
|
|
85
85
|
* `oldNick` after this conn abandoned it (defence in depth).
|
|
86
86
|
*
|
|
87
87
|
* Cross-shard changes (old and new hash to different shards) are
|
|
88
|
-
* orchestrated by `CfRuntime`
|
|
88
|
+
* orchestrated by `CfRuntime` as a two-phase
|
|
89
89
|
* reserve-new-then-release-old. This DO only sees single-shard
|
|
90
90
|
* changes and crash-recovery paths (where `oldNick` may not be
|
|
91
91
|
* present); the latter is why the conditional delete is a soft check.
|
|
@@ -127,4 +127,23 @@ export class RegistryDO extends DurableObject implements RegistryRpc {
|
|
|
127
127
|
const owner = await this.ctx.storage.get<string>(nickKey(nick.toLowerCase()));
|
|
128
128
|
return owner ?? null;
|
|
129
129
|
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Enumerates every `(nickLower, connectionId)` pair in this shard.
|
|
133
|
+
* Backs the global WALLOPS fan-out: the caller scans every shard, collects
|
|
134
|
+
* all connection ids, and delivers to those carrying the `+w` mode. The
|
|
135
|
+
* walk is a single `list` over the `nick:` prefix; cost is proportional to
|
|
136
|
+
* the slice's live nick count (sharded, so bounded per DO).
|
|
137
|
+
*/
|
|
138
|
+
async listEntries(): Promise<Array<[nickLower: string, connId: string]>> {
|
|
139
|
+
const out: Array<[nickLower: string, connId: string]> = [];
|
|
140
|
+
// `storage.list` resolves to a Map of `key → value` for the prefix.
|
|
141
|
+
// Each key is `nick:<lower>`; we strip the prefix to recover the nick.
|
|
142
|
+
const entries = await this.ctx.storage.list<string>({ prefix: NICK_KEY_PREFIX });
|
|
143
|
+
for (const [key, connId] of entries) {
|
|
144
|
+
const nickLower = key.slice(NICK_KEY_PREFIX.length);
|
|
145
|
+
out.push([nickLower, connId]);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
130
149
|
}
|
|
@@ -34,6 +34,13 @@ export interface PersistedConnectionState {
|
|
|
34
34
|
id: string;
|
|
35
35
|
registration: RegistrationState;
|
|
36
36
|
capNegotiating: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Transport TLS fact (user mode `S`). Optional on the persisted shape so
|
|
39
|
+
* records written before the field shipped (schema is otherwise
|
|
40
|
+
* unchanged) deserialize with a `false` default via the coalescing in
|
|
41
|
+
* {@link deserialize}. Always written by {@link serialize}.
|
|
42
|
+
*/
|
|
43
|
+
secure?: boolean;
|
|
37
44
|
caps: string[];
|
|
38
45
|
joinedChannels: ChanName[];
|
|
39
46
|
userModes: UserModes;
|
|
@@ -61,6 +68,7 @@ export function serialize(state: ConnectionState): PersistedConnectionState {
|
|
|
61
68
|
id: state.id,
|
|
62
69
|
registration: state.registration,
|
|
63
70
|
capNegotiating: state.capNegotiating,
|
|
71
|
+
secure: state.secure,
|
|
64
72
|
caps: [...state.caps],
|
|
65
73
|
joinedChannels: [...state.joinedChannels],
|
|
66
74
|
userModes: { ...state.userModes },
|
|
@@ -88,6 +96,9 @@ export function deserialize(record: unknown): ConnectionState {
|
|
|
88
96
|
id: migrated.id,
|
|
89
97
|
registration: migrated.registration,
|
|
90
98
|
capNegotiating: migrated.capNegotiating,
|
|
99
|
+
// `secure` is absent on records persisted before user mode `S` shipped;
|
|
100
|
+
// `migrate` backfills it to false so the cast below is truthful.
|
|
101
|
+
secure: migrated.secure ?? false,
|
|
91
102
|
caps: new Set<string>(migrated.caps),
|
|
92
103
|
joinedChannels: new Set<ChanName>(migrated.joinedChannels),
|
|
93
104
|
userModes: { ...migrated.userModes },
|
|
@@ -119,8 +130,19 @@ export function migrate(record: unknown): PersistedConnectionState {
|
|
|
119
130
|
if (!isPlainObject(record)) {
|
|
120
131
|
throw new Error('persisted connection state is not an object');
|
|
121
132
|
}
|
|
133
|
+
// Backfill additive fields introduced by user mode `S` (TLS connected):
|
|
134
|
+
// `secure` and `userModes.tls`. Both default to false (plain transport),
|
|
135
|
+
// preserving the pre-feature behaviour for records persisted before the
|
|
136
|
+
// mode shipped. The schema version is unchanged because the additions are
|
|
137
|
+
// purely additive with safe defaults — no row transformation is needed
|
|
138
|
+
// beyond coalescing the missing keys.
|
|
139
|
+
if (record.secure === undefined) record.secure = false;
|
|
140
|
+
if (isPlainObject(record.userModes)) {
|
|
141
|
+
const um = record.userModes as Record<string, unknown>;
|
|
142
|
+
if (um.tls === undefined) um.tls = false;
|
|
143
|
+
}
|
|
122
144
|
const version = typeof record.version === 'number' ? record.version : 0;
|
|
123
|
-
// No migrations yet — v1 is the initial schema. Future work
|
|
145
|
+
// No versioned migrations yet — v1 is the initial schema. Future work:
|
|
124
146
|
// if (version < 2) record = migrate_v1_v2(record);
|
|
125
147
|
// if (version < 3) record = migrate_v2_v3(record);
|
|
126
148
|
if (version > PERSISTED_STATE_VERSION) {
|
|
@@ -131,9 +153,8 @@ export function migrate(record: unknown): PersistedConnectionState {
|
|
|
131
153
|
}
|
|
132
154
|
// The cast below is safe: we accept any JSON shape the storage layer
|
|
133
155
|
// returns; production code wrote it via `serialize`, tests wrote it
|
|
134
|
-
// through the same path.
|
|
135
|
-
// `
|
|
136
|
-
// undefined keys out of the persisted record entirely.
|
|
156
|
+
// through the same path. Backfilling above keeps the additive
|
|
157
|
+
// `secure` / `userModes.tls` keys present even on pre-feature records.
|
|
137
158
|
return record as unknown as PersistedConnectionState;
|
|
138
159
|
}
|
|
139
160
|
|
|
@@ -22,8 +22,7 @@
|
|
|
22
22
|
* `N` defaults to {@link DEFAULT_REGISTRY_SHARDS} (32) and is
|
|
23
23
|
* configurable per deployment via the worker env. Changing N after
|
|
24
24
|
* production traffic requires a migration (re-sharding every existing
|
|
25
|
-
* nick→conn mapping); document this in `docs/deployment-cf.md
|
|
26
|
-
* (038).
|
|
25
|
+
* nick→conn mapping); document this in `docs/deployment-cf.md`.
|
|
27
26
|
*/
|
|
28
27
|
|
|
29
28
|
/**
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Workers-flavoured {@link ServerStats} backend.
|
|
3
|
+
*
|
|
4
|
+
* Aggregates the network-wide counts the `LUSERS` / `STATS` reducers need
|
|
5
|
+
* by fanning out across the existing CF RPC surface: `listChannels` (via
|
|
6
|
+
* the {@link ChannelRegistryDO}) plus per-channel `listMembers` and
|
|
7
|
+
* per-member `getConnState`. Classification (oper / invisible / unknown)
|
|
8
|
+
* is delegated to the shared {@link computeStatsSnapshot} helper in
|
|
9
|
+
* irc-core so the classification rules live in exactly one place.
|
|
10
|
+
*
|
|
11
|
+
* **Limitation:** the CF runtime does not currently expose an
|
|
12
|
+
* "enumerate all connections" RPC, so a connection that has not joined
|
|
13
|
+
* any channel is not counted. This matches the ticket's "aggregate via
|
|
14
|
+
* listChannels / connection-enumeration RPCs" guidance; a future
|
|
15
|
+
* dedicated stats DO would close the gap (every connection would be
|
|
16
|
+
* counted regardless of channel membership).
|
|
17
|
+
*
|
|
18
|
+
* Constructed per `LUSERS` / `STATS` invocation alongside the
|
|
19
|
+
* {@link CfRuntime}; cheap to build, no caching. `uptimeStartedAt` is
|
|
20
|
+
* supplied by the caller (typically the worker's startup timestamp).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
ChanName,
|
|
25
|
+
ConnId,
|
|
26
|
+
ConnectionState,
|
|
27
|
+
ServerStatsSnapshot,
|
|
28
|
+
} from '@serverless-ircd/irc-core';
|
|
29
|
+
import { computeStatsSnapshot } from '@serverless-ircd/irc-core';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The minimal slice of {@link CfRuntime} (or any adapter runtime) that
|
|
33
|
+
* {@link CfStats} needs. Defined structurally so unit tests can pass a
|
|
34
|
+
* stub without spinning up workerd / miniflare.
|
|
35
|
+
*/
|
|
36
|
+
export interface CfStatsRuntime {
|
|
37
|
+
listChannels(): Promise<ReadonlyArray<{ nameLower: string }>>;
|
|
38
|
+
getChannelConnections(name: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class CfStats {
|
|
42
|
+
private readonly runtime: CfStatsRuntime;
|
|
43
|
+
private readonly uptimeStartedAt: number;
|
|
44
|
+
|
|
45
|
+
constructor(runtime: CfStatsRuntime, uptimeStartedAt: number) {
|
|
46
|
+
this.runtime = runtime;
|
|
47
|
+
this.uptimeStartedAt = uptimeStartedAt;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async getStats(): Promise<ServerStatsSnapshot> {
|
|
51
|
+
const channels = await this.runtime.listChannels();
|
|
52
|
+
// Fan out: gather every channel's members into one deduplicated map.
|
|
53
|
+
// A user in N channels is counted once.
|
|
54
|
+
const unique = new Map<ConnId, ConnectionState>();
|
|
55
|
+
await Promise.all(
|
|
56
|
+
channels.map(async (chan) => {
|
|
57
|
+
const members = await this.runtime.getChannelConnections(chan.nameLower as ChanName);
|
|
58
|
+
for (const [id, state] of members) {
|
|
59
|
+
unique.set(id, state);
|
|
60
|
+
}
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
return computeStatsSnapshot(unique.values(), channels.length, this.uptimeStartedAt);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CF-side {@link IrcHarnessFactory} — registers the parametrized IRC
|
|
3
|
-
* scenario suite
|
|
3
|
+
* scenario suite from `@serverless-ircd/irc-test-support` against the Cloudflare adapter.
|
|
4
4
|
*
|
|
5
5
|
* Each {@link CfHarness} instance is hermetic at the vitest-pool-workers
|
|
6
6
|
* level: every test gets an isolated storage namespace
|