serverless-ircd 0.3.0 → 0.5.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 +349 -18
- package/README.md +132 -30
- package/apps/aws-stack/README.md +6 -5
- 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 +150 -10
- package/apps/aws-stack/tests/stack.test.ts +145 -4
- 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 +149 -24
- package/apps/local-cli/tests/e2e.test.ts +1 -1
- package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
- 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 +75 -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 +96 -9
- package/packages/aws-adapter/src/handlers/default.ts +98 -7
- package/packages/aws-adapter/src/handlers/index.ts +109 -4
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +8 -0
- package/packages/aws-adapter/src/serialize.ts +11 -1
- package/packages/aws-adapter/src/stats.ts +80 -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/aws-integration.test.ts +1 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +174 -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 +302 -53
- package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- 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 +5 -1
- package/packages/cf-adapter/src/cf-runtime.ts +68 -5
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +278 -85
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +54 -11
- package/packages/cf-adapter/src/index.ts +11 -8
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- 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 +12 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
- package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/connection-do-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 +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
- 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 +15 -8
- 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/wrangler.test.toml +15 -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 +6 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +24 -3
- 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 +17 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/isupport.ts +6 -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 +4 -8
- 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/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- 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/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- 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/userhost.ts +84 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +65 -0
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +33 -30
- package/packages/irc-core/src/index.ts +3 -0
- package/packages/irc-core/src/ports.ts +360 -12
- package/packages/irc-core/src/protocol/numerics.ts +48 -11
- package/packages/irc-core/src/protocol/outbound.ts +20 -3
- package/packages/irc-core/src/types.ts +46 -2
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/tests/account-store.test.ts +45 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- 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/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 +57 -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/rehash.test.ts +171 -0
- 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/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 +9 -35
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -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/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +102 -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/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- 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-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 +393 -16
- package/packages/irc-server/src/dispatch.ts +1 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +15 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +104 -0
- package/packages/irc-server/tests/actor.test.ts +1489 -4
- package/packages/irc-server/tests/dispatch.test.ts +37 -17
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +5 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +141 -3
- 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 +10 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +9 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +107 -0
- 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
- 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
|
@@ -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,15 @@
|
|
|
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.
|
|
20
|
+
* - `CHANNEL_DO` per lowercased channel — authoritative roster/modes.
|
|
21
21
|
*
|
|
22
22
|
* The actor's `ActorChannelAccess` is a passthrough that returns
|
|
23
23
|
* throwaway {@link ChannelState} objects — reducers mutate the local
|
|
24
24
|
* view and emit `ApplyChannelDelta` effects that the CfRuntime forwards
|
|
25
|
-
* to the authoritative ChannelDO.
|
|
26
|
-
*
|
|
27
|
-
* state)
|
|
25
|
+
* to the authoritative ChannelDO. Connection-authority commands and
|
|
26
|
+
* JOIN/QUIT fan-out are fully wired; richer channel reads (NAMES of
|
|
27
|
+
* remote state) are served via the ChannelDO snapshot RPC.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { DurableObject } from 'cloudflare:workers';
|
|
@@ -37,46 +37,54 @@ import {
|
|
|
37
37
|
type ConnectionState,
|
|
38
38
|
ConsoleLogger,
|
|
39
39
|
type IdFactory,
|
|
40
|
-
InMemoryAccountStore,
|
|
41
40
|
InMemoryMessageStore,
|
|
41
|
+
InMemoryMtlsIdentityProvider,
|
|
42
|
+
InMemoryNickHistoryStore,
|
|
42
43
|
LogLevel,
|
|
43
44
|
type Logger,
|
|
44
45
|
type MessageStore,
|
|
45
46
|
type MotdProvider,
|
|
47
|
+
type MtlsIdentityProvider,
|
|
48
|
+
type NickHistoryStore,
|
|
46
49
|
type RawLine,
|
|
47
50
|
type SaslAccountCredential,
|
|
48
51
|
type ServerConfig,
|
|
49
52
|
SystemClock,
|
|
50
53
|
UuidIdFactory,
|
|
51
|
-
|
|
54
|
+
type WsFrameMode,
|
|
52
55
|
createChannel,
|
|
53
56
|
createConnection,
|
|
57
|
+
frameToLines,
|
|
58
|
+
isWithinWsByteBudget,
|
|
59
|
+
parseSecWsProtocolOffers,
|
|
60
|
+
sanitizeForTextMode,
|
|
61
|
+
selectSubprotocol,
|
|
54
62
|
toSnapshot,
|
|
63
|
+
wsFrameModeFor,
|
|
55
64
|
} from '@serverless-ircd/irc-core';
|
|
56
|
-
import { ConnectionActor } from '@serverless-ircd/irc-server';
|
|
65
|
+
import { ConnectionActor, type Transport } from '@serverless-ircd/irc-server';
|
|
57
66
|
import { makeCfRuntime } from './cf-runtime.js';
|
|
58
67
|
import type { CfConnectionHandlers } from './cf-runtime.js';
|
|
68
|
+
import { loadServerConfigFromCfEnv } from './config-loader.js';
|
|
69
|
+
import { resolveAccountStore } from './d1-account-store.js';
|
|
59
70
|
import type { Env } from './env.js';
|
|
60
|
-
import {
|
|
71
|
+
import { STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
|
|
61
72
|
import type { PersistedConnectionState } from './serialize.js';
|
|
73
|
+
import { CfStats } from './stats.js';
|
|
62
74
|
|
|
63
75
|
/** Default PING cadence (ms) — tuned for typical IRC client behavior. */
|
|
64
76
|
export const DEFAULT_PING_INTERVAL_MS = 60_000;
|
|
65
77
|
/** Default no-PONG disconnect threshold (ms). */
|
|
66
78
|
export const DEFAULT_PONG_TIMEOUT_MS = 90_000;
|
|
67
79
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
channelLen: 50,
|
|
77
|
-
topicLen: 390,
|
|
78
|
-
quitMessage: 'Client Quit',
|
|
79
|
-
};
|
|
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();
|
|
80
88
|
|
|
81
89
|
/**
|
|
82
90
|
* ConnectionDO instance.
|
|
@@ -87,6 +95,13 @@ const DEFAULT_SERVER_CONFIG: ServerConfig = {
|
|
|
87
95
|
export class ConnectionDO extends DurableObject<Env> {
|
|
88
96
|
/** Cached connection state; reloaded from storage after hibernation. */
|
|
89
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;
|
|
90
105
|
/**
|
|
91
106
|
* Per-instance channel-state cache. Reused across every WebSocket frame
|
|
92
107
|
* the connection handles (within a single hibernation cycle) so that
|
|
@@ -109,18 +124,54 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
109
124
|
*/
|
|
110
125
|
private messages: MessageStore | undefined;
|
|
111
126
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
127
|
+
* Per-connection nick-history store backing `WHOWAS`. The minimum-viable
|
|
128
|
+
* in-worker ring buffer is scoped to this connection: sign-offs this
|
|
129
|
+
* connection records are queryable by the same connection. A
|
|
130
|
+
* Durable-Object-backed persistent variant (shared across connections,
|
|
131
|
+
* keyed by nick) is a documented follow-up. Lazily constructed.
|
|
132
|
+
*/
|
|
133
|
+
private history: NickHistoryStore | undefined;
|
|
134
|
+
/**
|
|
135
|
+
* SASL account store, resolved once via {@link loadAccountStore} before the
|
|
136
|
+
* first frame dispatches. The two-phase load mirrors AWS's
|
|
137
|
+
* `loadDynamoAccountStore`: D1 `accounts` table (authoritative when it has
|
|
138
|
+
* rows) is queried at boot; when empty/unreachable the `SASL_ACCOUNTS`
|
|
139
|
+
* env-var seed provides the fallback `InMemoryAccountStore`. The
|
|
140
|
+
* `AccountStore.verify` port is synchronous, so pre-loading MUST complete
|
|
141
|
+
* before the first reducer runs.
|
|
116
142
|
*/
|
|
117
143
|
private accounts: AccountStore | undefined;
|
|
144
|
+
/** Guards {@link loadAccountStore} so the D1 query runs at most once. */
|
|
145
|
+
private accountsResolved = false;
|
|
146
|
+
/**
|
|
147
|
+
* Verified client-cert subject captured at WebSocket upgrade time when
|
|
148
|
+
* CF API Shield mTLS is configured. Surfaced to the SASL EXTERNAL reducer
|
|
149
|
+
* via an {@link MtlsIdentityProvider} so `AUTHENTICATE EXTERNAL` can map
|
|
150
|
+
* the cert subject to an account.
|
|
151
|
+
*
|
|
152
|
+
* CF API Shield mTLS setup:
|
|
153
|
+
* 1. Upload the client CA to API Shield (dashboard → Security → API Shield).
|
|
154
|
+
* 2. Create an mTLS policy on the custom hostname that requires client certs.
|
|
155
|
+
* 3. The verified subject is injected into `request.cf.tlsClientAuthCertSubject`
|
|
156
|
+
* on every request, which the Worker reads at upgrade time.
|
|
157
|
+
* When unset (no mTLS configured), EXTERNAL auth fails with `904`.
|
|
158
|
+
*/
|
|
159
|
+
private mtlsCertSubject: string | undefined;
|
|
118
160
|
private readonly clock: Clock = SystemClock;
|
|
119
161
|
private readonly ids: IdFactory = new UuidIdFactory();
|
|
120
162
|
private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
|
|
121
163
|
private readonly pongTimeoutMs: number = DEFAULT_PONG_TIMEOUT_MS;
|
|
122
|
-
|
|
123
|
-
//
|
|
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.
|
|
124
175
|
|
|
125
176
|
// -------------------------------------------------------------------------
|
|
126
177
|
// WebSocket upgrade
|
|
@@ -134,12 +185,31 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
134
185
|
const pair = new WebSocketPair();
|
|
135
186
|
const server = pair[0];
|
|
136
187
|
const client = pair[1];
|
|
188
|
+
// Capture the verified client-cert subject when CF API Shield mTLS is
|
|
189
|
+
// active. `request.cf` is a Cloudflare-specific extension; the property
|
|
190
|
+
// is present only when a client certificate was verified against the
|
|
191
|
+
// uploaded CA pool.
|
|
192
|
+
const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
|
|
193
|
+
if (cf !== undefined && typeof cf.tlsClientAuthCertSubject === 'string') {
|
|
194
|
+
this.mtlsCertSubject = cf.tlsClientAuthCertSubject;
|
|
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);
|
|
137
204
|
// Hibernation: `acceptWebSocket` accepts the server side implicitly.
|
|
138
|
-
// Calling `server.accept()` first throws "already accepted".
|
|
139
|
-
|
|
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}`]);
|
|
140
208
|
// Schedule the first PING sweep.
|
|
141
209
|
await this.ctx.storage.setAlarm(Date.now() + this.pingIntervalMs);
|
|
142
|
-
|
|
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 });
|
|
143
213
|
}
|
|
144
214
|
|
|
145
215
|
// -------------------------------------------------------------------------
|
|
@@ -148,12 +218,33 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
148
218
|
|
|
149
219
|
/** Frame → actor → dispatch → persist. */
|
|
150
220
|
override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
151
|
-
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;
|
|
152
243
|
const state = await this.loadState();
|
|
153
|
-
const before = new Set(state.joinedChannels);
|
|
154
244
|
const beforeNick = state.nick;
|
|
155
245
|
|
|
156
|
-
|
|
246
|
+
await this.loadAccountStore();
|
|
247
|
+
const actor = this.buildActor(ws, state, mode);
|
|
157
248
|
try {
|
|
158
249
|
await actor.receiveTextFrame(text);
|
|
159
250
|
} finally {
|
|
@@ -165,17 +256,11 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
165
256
|
if (beforeNick === undefined && state.nick !== undefined) {
|
|
166
257
|
await this.ctx.storage.setAlarm(Date.now() + this.pingIntervalMs);
|
|
167
258
|
}
|
|
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
|
-
}
|
|
259
|
+
// No per-ConnectionDO channel-registration step is needed here: channel
|
|
260
|
+
// membership is driven solely by the `ApplyChannelDelta` effect the
|
|
261
|
+
// joinReducer emits, which the CfRuntime forwards to the authoritative
|
|
262
|
+
// ChannelDO. The ChannelDO roster IS the fan-out registration target, so
|
|
263
|
+
// a second loop over newly joined channels here would be redundant.
|
|
179
264
|
}
|
|
180
265
|
|
|
181
266
|
/** QUIT fanout + nick release. RFC 1459: a missing close is a QUIT. */
|
|
@@ -202,7 +287,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
202
287
|
* been idle past the PONG timeout, closes it.
|
|
203
288
|
*
|
|
204
289
|
* PLAN §6.1 — DO alarms are the canonical PING/idle mechanism on CF;
|
|
205
|
-
* equivalent to EventBridge Scheduler on AWS
|
|
290
|
+
* equivalent to EventBridge Scheduler on AWS.
|
|
206
291
|
*/
|
|
207
292
|
override async alarm(): Promise<void> {
|
|
208
293
|
const state = await this.loadState();
|
|
@@ -220,7 +305,8 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
220
305
|
// Send PING. PONG replies update lastSeen via the actor.
|
|
221
306
|
const token = this.ids.nonce();
|
|
222
307
|
for (const ws of this.ctx.getWebSockets()) {
|
|
223
|
-
|
|
308
|
+
const socket = ws as WebSocket;
|
|
309
|
+
this.sendOutbound(socket, [{ text: `PING :${token}` }], this.wsFrameMode(socket));
|
|
224
310
|
}
|
|
225
311
|
await this.ctx.storage.setAlarm(now + this.pingIntervalMs);
|
|
226
312
|
}
|
|
@@ -251,14 +337,11 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
251
337
|
*/
|
|
252
338
|
async deliver(lines: RawLine[]): Promise<{ delivered: number }> {
|
|
253
339
|
let delivered = 0;
|
|
254
|
-
let text: string | undefined;
|
|
255
|
-
if (lines.length > 0) {
|
|
256
|
-
text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
|
|
257
|
-
}
|
|
258
340
|
for (const ws of this.ctx.getWebSockets()) {
|
|
259
341
|
const socket = ws as WebSocket;
|
|
260
342
|
if (socket.readyState !== WebSocket.OPEN) continue;
|
|
261
|
-
|
|
343
|
+
const mode = this.wsFrameMode(socket);
|
|
344
|
+
this.sendOutbound(socket, lines, mode);
|
|
262
345
|
delivered++;
|
|
263
346
|
}
|
|
264
347
|
return { delivered };
|
|
@@ -268,6 +351,44 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
268
351
|
// Internal helpers
|
|
269
352
|
// -------------------------------------------------------------------------
|
|
270
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
|
+
|
|
271
392
|
/** Loads the connection state from storage, caching for the event. */
|
|
272
393
|
private async loadState(): Promise<ConnectionState> {
|
|
273
394
|
if (this.cached !== undefined) return this.cached;
|
|
@@ -290,29 +411,30 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
290
411
|
}
|
|
291
412
|
|
|
292
413
|
/**
|
|
293
|
-
* 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).
|
|
294
419
|
*/
|
|
295
|
-
private buildActor(ws: WebSocket, state: ConnectionState): ConnectionActor {
|
|
420
|
+
private buildActor(ws: WebSocket, state: ConnectionState, mode: WsFrameMode): ConnectionActor {
|
|
296
421
|
const serverConfig = this.serverConfig();
|
|
297
422
|
const motd = this.motdProvider();
|
|
298
423
|
const handlers = {
|
|
299
424
|
send: (lines: RawLine[]): void => {
|
|
300
|
-
|
|
301
|
-
const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
|
|
302
|
-
ws.send(text);
|
|
303
|
-
}
|
|
425
|
+
this.sendOutbound(ws, lines, mode);
|
|
304
426
|
},
|
|
305
427
|
disconnect: (reason?: string): void => {
|
|
306
|
-
if (reason !== undefined
|
|
307
|
-
|
|
428
|
+
if (reason !== undefined) {
|
|
429
|
+
this.sendOutbound(ws, [{ text: `ERROR :Closing link: (${reason})` }], mode);
|
|
308
430
|
}
|
|
309
431
|
ws.close();
|
|
310
432
|
},
|
|
311
433
|
snapshot: (): ConnectionState | undefined => this.cached,
|
|
312
434
|
};
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
435
|
+
// The CfRuntime shards the registry by nick; the per-connection
|
|
436
|
+
// key passed here is ignored by `makeCfRuntime` (kept in the signature
|
|
437
|
+
// for call-site stability).
|
|
316
438
|
const registryKey = this.ctx.id.toString();
|
|
317
439
|
const runtime = makeCfRuntime(this.env, state.id, registryKey, handlers);
|
|
318
440
|
if (this.channelAccess === undefined) {
|
|
@@ -323,16 +445,29 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
323
445
|
// per-connection in the Workers dashboard.
|
|
324
446
|
const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
|
|
325
447
|
const accounts = this.accountStore();
|
|
448
|
+
const mtlsIdentity: MtlsIdentityProvider | undefined =
|
|
449
|
+
this.mtlsCertSubject !== undefined
|
|
450
|
+
? new InMemoryMtlsIdentityProvider([{ connId: state.id, identity: this.mtlsCertSubject }])
|
|
451
|
+
: undefined;
|
|
326
452
|
return new ConnectionActor({
|
|
327
453
|
state,
|
|
328
454
|
runtime,
|
|
329
455
|
channels: this.channelAccess,
|
|
330
456
|
serverConfig,
|
|
457
|
+
configSource: 'KV',
|
|
331
458
|
clock: this.clock,
|
|
332
459
|
ids: this.ids,
|
|
333
460
|
motd,
|
|
334
461
|
messages: this.messageStore(),
|
|
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),
|
|
335
469
|
...(accounts !== undefined ? { accounts } : {}),
|
|
470
|
+
...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
|
|
336
471
|
logger,
|
|
337
472
|
});
|
|
338
473
|
}
|
|
@@ -340,7 +475,8 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
340
475
|
/** Tears down a connection: emit QUIT effects, release nick, close WS. */
|
|
341
476
|
private async tearDown(ws: WebSocket): Promise<void> {
|
|
342
477
|
const state = await this.loadState();
|
|
343
|
-
|
|
478
|
+
await this.loadAccountStore();
|
|
479
|
+
const actor = this.buildActor(ws, state, this.wsFrameMode(ws));
|
|
344
480
|
// Drive QUIT through the actor so the same effect pipeline handles
|
|
345
481
|
// fanout as during normal operation.
|
|
346
482
|
await actor.receiveTextFrame('QUIT\r\n');
|
|
@@ -368,11 +504,17 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
368
504
|
}
|
|
369
505
|
|
|
370
506
|
private serverConfig(): ServerConfig {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
507
|
+
// Single source of truth: delegate to the schema-validated loader so
|
|
508
|
+
// every reducer-facing field (operCreds, serverPassword, motdLines,
|
|
509
|
+
// limit knobs, …) is populated from `this.env` exactly as the shared
|
|
510
|
+
// `ServerConfigSchema` prescribes. The previous hand-rolled literal
|
|
511
|
+
// silently dropped fields like operCreds, which left OPER unable to
|
|
512
|
+
// authenticate (491 ERR_NOOPERHOST) even when OPER_USER/OPER_PASSWORD
|
|
513
|
+
// were bound. See config-loader.ts and config.ts.
|
|
514
|
+
if (this.cachedServerConfig === undefined) {
|
|
515
|
+
this.cachedServerConfig = loadServerConfigFromCfEnv(this.env);
|
|
516
|
+
}
|
|
517
|
+
return this.cachedServerConfig;
|
|
376
518
|
}
|
|
377
519
|
|
|
378
520
|
private motdProvider(): MotdProvider {
|
|
@@ -398,17 +540,37 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
398
540
|
}
|
|
399
541
|
|
|
400
542
|
/**
|
|
401
|
-
* Lazily constructs the
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
|
|
405
|
-
|
|
543
|
+
* Lazily constructs the per-connection nick-history store. A future
|
|
544
|
+
* persistent variant (DO/KV-backed, keyed by nick) will replace this
|
|
545
|
+
* without touching the actor wiring.
|
|
546
|
+
*/
|
|
547
|
+
private historyStore(): NickHistoryStore {
|
|
548
|
+
if (this.history === undefined) {
|
|
549
|
+
this.history = new InMemoryNickHistoryStore(this.clock);
|
|
550
|
+
}
|
|
551
|
+
return this.history;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Resolves the SASL account store once (D1 table → `SASL_ACCOUNTS` seed),
|
|
556
|
+
* caching the result. Safe to call on every frame; the D1 query runs at
|
|
557
|
+
* most once per ConnectionDO instance. Must complete before the first
|
|
558
|
+
* reducer dispatches so the synchronous `AccountStore.verify` port has its
|
|
559
|
+
* data ready.
|
|
560
|
+
*/
|
|
561
|
+
private async loadAccountStore(): Promise<void> {
|
|
562
|
+
if (this.accountsResolved) return;
|
|
563
|
+
this.accountsResolved = true;
|
|
564
|
+
this.accounts = await resolveAccountStore(this.env.ACCOUNTS_DB, this.env.SASL_ACCOUNTS);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Returns the resolved account store, or `undefined` when no accounts are
|
|
569
|
+
* configured (so the actor's `ctx.accounts` stays unset: `AUTHENTICATE
|
|
570
|
+
* PLAIN` → `904`). Populated by {@link loadAccountStore}; callers MUST
|
|
571
|
+
* `await loadAccountStore()` before reading this.
|
|
406
572
|
*/
|
|
407
573
|
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
574
|
return this.accounts;
|
|
413
575
|
}
|
|
414
576
|
|
|
@@ -437,6 +599,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
437
599
|
private async tearDownNoSocket(state: ConnectionState): Promise<void> {
|
|
438
600
|
// Emit QUIT effects without dispatching transport calls (the socket
|
|
439
601
|
// is already gone). The cross-DO fanout still runs.
|
|
602
|
+
await this.loadAccountStore();
|
|
440
603
|
const noOpHandlers: CfConnectionHandlers = {
|
|
441
604
|
send: (): void => {},
|
|
442
605
|
disconnect: (): void => {},
|
|
@@ -452,10 +615,12 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
452
615
|
runtime,
|
|
453
616
|
channels: this.channelAccess,
|
|
454
617
|
serverConfig: this.serverConfig(),
|
|
618
|
+
configSource: 'KV',
|
|
455
619
|
clock: this.clock,
|
|
456
620
|
ids: this.ids,
|
|
457
621
|
motd: this.motdProvider(),
|
|
458
622
|
messages: this.messageStore(),
|
|
623
|
+
history: this.historyStore(),
|
|
459
624
|
});
|
|
460
625
|
await actor.receiveTextFrame('QUIT\r\n');
|
|
461
626
|
await this.releaseAndPersist(state);
|
|
@@ -629,12 +794,6 @@ class PassthroughChannelAccess {
|
|
|
629
794
|
}
|
|
630
795
|
}
|
|
631
796
|
|
|
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
797
|
/**
|
|
639
798
|
* Parses the `SASL_ACCOUNTS` env var into credential entries.
|
|
640
799
|
*
|
|
@@ -659,3 +818,37 @@ export function parseSaslAccountsEnv(raw: string | undefined): SaslAccountCreden
|
|
|
659
818
|
}
|
|
660
819
|
return out;
|
|
661
820
|
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Hibernation-tag prefixes written by {@link ConnectionDO.fetch}. Each tag
|
|
824
|
+
* encodes the negotiated {@link WsFrameMode} so it survives DO eviction and
|
|
825
|
+
* can be recovered in every WebSocket event handler via `ctx.getTags(ws)`.
|
|
826
|
+
*/
|
|
827
|
+
const WS_TAG_LEGACY = 'ws:legacy';
|
|
828
|
+
const WS_TAG_SPEC_TEXT = 'ws:spec-text';
|
|
829
|
+
const WS_TAG_SPEC_BINARY = 'ws:spec-binary';
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Recovers the {@link WsFrameMode} from a hibernated socket's tags.
|
|
833
|
+
* Defaults to the legacy-tolerant mode when no recognized tag is present.
|
|
834
|
+
*/
|
|
835
|
+
export function wsFrameModeFromTags(tags: readonly string[]): WsFrameMode {
|
|
836
|
+
for (const tag of tags) {
|
|
837
|
+
if (tag === WS_TAG_SPEC_TEXT) return 'spec-text';
|
|
838
|
+
if (tag === WS_TAG_SPEC_BINARY) return 'spec-binary';
|
|
839
|
+
if (tag === WS_TAG_LEGACY) return 'legacy';
|
|
840
|
+
}
|
|
841
|
+
return 'legacy';
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Builds the inbound line-framing {@link Transport} for `mode`. Spec modes
|
|
846
|
+
* treat one WebSocket message as exactly one IRC line (a single trailing
|
|
847
|
+
* CR-LF is stripped and the frame is never split); legacy mode splits the
|
|
848
|
+
* frame on `\r\n` so older clients that concatenate messages keep working.
|
|
849
|
+
*/
|
|
850
|
+
export function makeWsFrameTransport(mode: WsFrameMode): Transport {
|
|
851
|
+
return {
|
|
852
|
+
feed: (chunk: string): string[] => frameToLines(chunk, mode),
|
|
853
|
+
};
|
|
854
|
+
}
|