serverless-ircd 0.1.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 +47 -0
- package/.github/workflows/deploy-cf.yml +97 -0
- package/LICENSE +29 -0
- package/README.md +323 -0
- package/apps/cf-worker/package.json +37 -0
- package/apps/cf-worker/scripts/smoke.mjs +175 -0
- package/apps/cf-worker/src/worker.ts +59 -0
- package/apps/cf-worker/tests/smoke.test.ts +150 -0
- package/apps/cf-worker/tsconfig.build.json +17 -0
- package/apps/cf-worker/tsconfig.test.json +18 -0
- package/apps/cf-worker/vitest.config.ts +31 -0
- package/apps/cf-worker/wrangler.test.toml +33 -0
- package/apps/cf-worker/wrangler.toml +98 -0
- package/apps/local-cli/package.json +35 -0
- package/apps/local-cli/src/line-scanner.ts +60 -0
- package/apps/local-cli/src/main.ts +145 -0
- package/apps/local-cli/src/motd-file.ts +45 -0
- package/apps/local-cli/src/server.ts +409 -0
- package/apps/local-cli/tests/e2e.test.ts +346 -0
- package/apps/local-cli/tests/id-factory.test.ts +25 -0
- package/apps/local-cli/tests/line-scanner.test.ts +81 -0
- package/apps/local-cli/tests/motd-file.test.ts +71 -0
- package/apps/local-cli/tests/tcp.test.ts +358 -0
- package/apps/local-cli/tsconfig.build.json +17 -0
- package/apps/local-cli/tsconfig.test.json +15 -0
- package/apps/local-cli/vitest.config.ts +24 -0
- package/biome.json +52 -0
- package/package.json +35 -0
- package/packages/cf-adapter/package.json +38 -0
- package/packages/cf-adapter/src/cf-runtime.ts +308 -0
- package/packages/cf-adapter/src/channel-do.ts +374 -0
- package/packages/cf-adapter/src/connection-do.ts +542 -0
- package/packages/cf-adapter/src/env.ts +67 -0
- package/packages/cf-adapter/src/index.ts +38 -0
- package/packages/cf-adapter/src/registry-do.ts +130 -0
- package/packages/cf-adapter/src/serialize.ts +142 -0
- package/packages/cf-adapter/src/sharding.ts +101 -0
- package/packages/cf-adapter/tests/cf-harness.ts +264 -0
- package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
- package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
- package/packages/cf-adapter/tests/integration/harness.ts +102 -0
- package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
- package/packages/cf-adapter/tests/integration/index.ts +17 -0
- package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
- package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
- package/packages/cf-adapter/tests/sharding.test.ts +100 -0
- package/packages/cf-adapter/tests/worker/main.ts +33 -0
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
- package/packages/cf-adapter/tsconfig.build.json +16 -0
- package/packages/cf-adapter/tsconfig.test.json +18 -0
- package/packages/cf-adapter/vitest.config.ts +32 -0
- package/packages/cf-adapter/wrangler.test.toml +50 -0
- package/packages/in-memory-runtime/package.json +29 -0
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
- package/packages/in-memory-runtime/src/index.ts +9 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
- package/packages/in-memory-runtime/tsconfig.build.json +16 -0
- package/packages/in-memory-runtime/tsconfig.test.json +14 -0
- package/packages/in-memory-runtime/vitest.config.ts +21 -0
- package/packages/irc-core/package.json +29 -0
- package/packages/irc-core/src/caps/capabilities.ts +96 -0
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/commands/away.ts +82 -0
- package/packages/irc-core/src/commands/cap.ts +257 -0
- package/packages/irc-core/src/commands/chghost.ts +30 -0
- package/packages/irc-core/src/commands/index.ts +40 -0
- package/packages/irc-core/src/commands/invite.ts +156 -0
- package/packages/irc-core/src/commands/isupport.ts +133 -0
- package/packages/irc-core/src/commands/join.ts +309 -0
- package/packages/irc-core/src/commands/kick.ts +162 -0
- package/packages/irc-core/src/commands/list.ts +126 -0
- package/packages/irc-core/src/commands/mode.ts +655 -0
- package/packages/irc-core/src/commands/motd.ts +146 -0
- package/packages/irc-core/src/commands/names.ts +164 -0
- package/packages/irc-core/src/commands/part.ts +118 -0
- package/packages/irc-core/src/commands/ping.ts +47 -0
- package/packages/irc-core/src/commands/privmsg.ts +256 -0
- package/packages/irc-core/src/commands/quit.ts +70 -0
- package/packages/irc-core/src/commands/registration.ts +251 -0
- package/packages/irc-core/src/commands/topic.ts +171 -0
- package/packages/irc-core/src/commands/who.ts +169 -0
- package/packages/irc-core/src/commands/whois.ts +165 -0
- package/packages/irc-core/src/effects.ts +184 -0
- package/packages/irc-core/src/index.ts +20 -0
- package/packages/irc-core/src/ports.ts +153 -0
- package/packages/irc-core/src/protocol/batch.ts +85 -0
- package/packages/irc-core/src/protocol/index.ts +18 -0
- package/packages/irc-core/src/protocol/messages.ts +36 -0
- package/packages/irc-core/src/protocol/numerics.ts +155 -0
- package/packages/irc-core/src/protocol/outbound.ts +145 -0
- package/packages/irc-core/src/protocol/parser.ts +175 -0
- package/packages/irc-core/src/protocol/serializer.ts +71 -0
- package/packages/irc-core/src/state/channel.ts +293 -0
- package/packages/irc-core/src/state/connection.ts +153 -0
- package/packages/irc-core/src/state/index.ts +25 -0
- package/packages/irc-core/src/state/registry.ts +22 -0
- package/packages/irc-core/src/types.ts +83 -0
- package/packages/irc-core/tests/batch.test.ts +79 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
- package/packages/irc-core/tests/commands/away.test.ts +233 -0
- package/packages/irc-core/tests/commands/batch.test.ts +178 -0
- package/packages/irc-core/tests/commands/cap.test.ts +499 -0
- package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
- package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
- package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
- package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
- package/packages/irc-core/tests/commands/invite.test.ts +321 -0
- package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
- package/packages/irc-core/tests/commands/join.test.ts +687 -0
- package/packages/irc-core/tests/commands/kick.test.ts +316 -0
- package/packages/irc-core/tests/commands/list.test.ts +452 -0
- package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
- package/packages/irc-core/tests/commands/motd.test.ts +322 -0
- package/packages/irc-core/tests/commands/names.test.ts +342 -0
- package/packages/irc-core/tests/commands/part.test.ts +265 -0
- package/packages/irc-core/tests/commands/ping.test.ts +144 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
- package/packages/irc-core/tests/commands/quit.test.ts +220 -0
- package/packages/irc-core/tests/commands/registration.test.ts +599 -0
- package/packages/irc-core/tests/commands/topic.test.ts +337 -0
- package/packages/irc-core/tests/commands/who.test.ts +441 -0
- package/packages/irc-core/tests/commands/whois.test.ts +422 -0
- package/packages/irc-core/tests/effects.test.ts +147 -0
- package/packages/irc-core/tests/message-tags.test.ts +182 -0
- package/packages/irc-core/tests/numerics.test.ts +98 -0
- package/packages/irc-core/tests/outbound.test.ts +232 -0
- package/packages/irc-core/tests/parser.test.ts +162 -0
- package/packages/irc-core/tests/ports.test.ts +101 -0
- package/packages/irc-core/tests/serializer.test.ts +164 -0
- package/packages/irc-core/tests/state/channel.test.ts +351 -0
- package/packages/irc-core/tests/state/connection.test.ts +122 -0
- package/packages/irc-core/tests/state/registry.test.ts +20 -0
- package/packages/irc-core/tests/types.test.ts +127 -0
- package/packages/irc-core/tsconfig.build.json +12 -0
- package/packages/irc-core/tsconfig.test.json +10 -0
- package/packages/irc-core/vitest.config.ts +21 -0
- package/packages/irc-server/package.json +28 -0
- package/packages/irc-server/src/actor.ts +449 -0
- package/packages/irc-server/src/dispatch.ts +120 -0
- package/packages/irc-server/src/index.ts +11 -0
- package/packages/irc-server/src/runtime.ts +61 -0
- package/packages/irc-server/tests/actor.test.ts +816 -0
- package/packages/irc-server/tests/dispatch.test.ts +512 -0
- package/packages/irc-server/tests/runtime.test.ts +52 -0
- package/packages/irc-server/tsconfig.build.json +13 -0
- package/packages/irc-server/tsconfig.test.json +11 -0
- package/packages/irc-server/vitest.config.ts +21 -0
- package/pnpm-workspace.yaml +4 -0
- package/tools/tcp-ws-forwarder/package.json +32 -0
- package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
- package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
- package/tools/tcp-ws-forwarder/src/main.ts +114 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
- package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
- package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
- package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
- package/tsconfig.base.json +25 -0
- package/turbo.json +29 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ConnectionDO` — Cloudflare Durable Object that owns one IRC
|
|
3
|
+
* connection's socket + authoritative state.
|
|
4
|
+
*
|
|
5
|
+
* PLAN §6.1 — uses {@link DurableObjectState}'s
|
|
6
|
+
* `HibernatingWebSocketHandler` so idle WebSocket connections cost
|
|
7
|
+
* nothing between messages. When a message arrives the DO wakes, replays
|
|
8
|
+
* the persisted {@link ConnectionState} from `state.storage`, runs the
|
|
9
|
+
* frame through {@link ConnectionActor} with a stub {@link CfRuntime},
|
|
10
|
+
* and persists the updated state back.
|
|
11
|
+
*
|
|
12
|
+
* Lifecycle:
|
|
13
|
+
* - `fetch()` WebSocket upgrade; `acceptWebSocket`.
|
|
14
|
+
* - `webSocketMessage()` Frame → actor → dispatch → persist.
|
|
15
|
+
* - `webSocketClose()`/`Error()` QUIT fanout + nick release.
|
|
16
|
+
* - `alarm()` PING sweep; missing PONG disconnects.
|
|
17
|
+
*
|
|
18
|
+
* Cross-DO coordination:
|
|
19
|
+
* - `REGISTRY_DO` (RPC stub today, real in 034).
|
|
20
|
+
* - `CHANNEL_DO` per lowercased channel (stub today, real in 035).
|
|
21
|
+
*
|
|
22
|
+
* The actor's `ActorChannelAccess` is a passthrough that returns
|
|
23
|
+
* throwaway {@link ChannelState} objects — reducers mutate the local
|
|
24
|
+
* view and emit `ApplyChannelDelta` effects that the CfRuntime forwards
|
|
25
|
+
* to the authoritative ChannelDO. Sufficient for connection-authority
|
|
26
|
+
* commands and JOIN/QUIT fan-out; richer channel reads (NAMES of remote
|
|
27
|
+
* state) ship in 036.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
31
|
+
import {
|
|
32
|
+
type ChanName,
|
|
33
|
+
type ChannelState,
|
|
34
|
+
type Clock,
|
|
35
|
+
type ConnId,
|
|
36
|
+
type ConnectionState,
|
|
37
|
+
type IdFactory,
|
|
38
|
+
type MotdProvider,
|
|
39
|
+
type RawLine,
|
|
40
|
+
type ServerConfig,
|
|
41
|
+
SystemClock,
|
|
42
|
+
UuidIdFactory,
|
|
43
|
+
applyChannelDelta,
|
|
44
|
+
createChannel,
|
|
45
|
+
createConnection,
|
|
46
|
+
toSnapshot,
|
|
47
|
+
} from '@serverless-ircd/irc-core';
|
|
48
|
+
import { ConnectionActor } from '@serverless-ircd/irc-server';
|
|
49
|
+
import { makeCfRuntime } from './cf-runtime.js';
|
|
50
|
+
import type { CfConnectionHandlers } from './cf-runtime.js';
|
|
51
|
+
import type { Env } from './env.js';
|
|
52
|
+
import { PERSISTED_STATE_VERSION, STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
|
|
53
|
+
|
|
54
|
+
/** Default PING cadence (ms) — tuned for typical IRC client behavior. */
|
|
55
|
+
export const DEFAULT_PING_INTERVAL_MS = 60_000;
|
|
56
|
+
/** Default no-PONG disconnect threshold (ms). */
|
|
57
|
+
export const DEFAULT_PONG_TIMEOUT_MS = 90_000;
|
|
58
|
+
|
|
59
|
+
/** Server config defaults; production deployments override via env. */
|
|
60
|
+
const DEFAULT_SERVER_CONFIG: ServerConfig = {
|
|
61
|
+
serverName: 'irc.example.com',
|
|
62
|
+
networkName: 'ServerlessIRCd',
|
|
63
|
+
maxChannelsPerUser: 30,
|
|
64
|
+
maxTargetsPerCommand: 10,
|
|
65
|
+
maxListEntries: 50,
|
|
66
|
+
nickLen: 30,
|
|
67
|
+
channelLen: 50,
|
|
68
|
+
topicLen: 390,
|
|
69
|
+
quitMessage: 'Client Quit',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* ConnectionDO instance.
|
|
74
|
+
*
|
|
75
|
+
* The class is exported as the binding target in `wrangler.toml`; the
|
|
76
|
+
* Workers runtime instantiates it with `(ctx, env)` per DO id.
|
|
77
|
+
*/
|
|
78
|
+
export class ConnectionDO extends DurableObject<Env> {
|
|
79
|
+
/** Cached connection state; reloaded from storage after hibernation. */
|
|
80
|
+
private cached: ConnectionState | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* Per-instance channel-state cache. Reused across every WebSocket frame
|
|
83
|
+
* the connection handles (within a single hibernation cycle) so that
|
|
84
|
+
* reducers see the roster mutations they performed on prior frames —
|
|
85
|
+
* e.g. JOIN in frame 1, then PART/TOPIC/MODE in later frames all
|
|
86
|
+
* share the same ChannelState. Authoritative state still lives in the
|
|
87
|
+
* ChannelDO; this cache only mirrors the local connection's view.
|
|
88
|
+
*
|
|
89
|
+
* Created lazily because `clock.now()` advances and the first frame
|
|
90
|
+
* should seed `createdAt` with its receive time.
|
|
91
|
+
*/
|
|
92
|
+
private channelAccess: PassthroughChannelAccess | undefined;
|
|
93
|
+
private readonly clock: Clock = SystemClock;
|
|
94
|
+
private readonly ids: IdFactory = new UuidIdFactory();
|
|
95
|
+
private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
|
|
96
|
+
private readonly pongTimeoutMs: number = DEFAULT_PONG_TIMEOUT_MS;
|
|
97
|
+
/** Outstanding outbound lines, drained when the WS sends complete. */
|
|
98
|
+
// (Reserved for a future batching optimization; kept off until 036.)
|
|
99
|
+
|
|
100
|
+
// -------------------------------------------------------------------------
|
|
101
|
+
// WebSocket upgrade
|
|
102
|
+
// -------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/** Standard WebSocket upgrade handler. */
|
|
105
|
+
override async fetch(request: Request): Promise<Response> {
|
|
106
|
+
if (request.headers.get('Upgrade') !== 'websocket') {
|
|
107
|
+
return new Response('Expected WebSocket', { status: 426 });
|
|
108
|
+
}
|
|
109
|
+
const pair = new WebSocketPair();
|
|
110
|
+
const server = pair[0];
|
|
111
|
+
const client = pair[1];
|
|
112
|
+
// Hibernation: `acceptWebSocket` accepts the server side implicitly.
|
|
113
|
+
// Calling `server.accept()` first throws "already accepted".
|
|
114
|
+
this.ctx.acceptWebSocket(server);
|
|
115
|
+
// Schedule the first PING sweep.
|
|
116
|
+
await this.ctx.storage.setAlarm(Date.now() + this.pingIntervalMs);
|
|
117
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// -------------------------------------------------------------------------
|
|
121
|
+
// Hibernating WebSocket event handlers
|
|
122
|
+
// -------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/** Frame → actor → dispatch → persist. */
|
|
125
|
+
override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
126
|
+
const text = typeof message === 'string' ? message : new TextDecoder().decode(message);
|
|
127
|
+
const state = await this.loadState();
|
|
128
|
+
const before = new Set(state.joinedChannels);
|
|
129
|
+
const beforeNick = state.nick;
|
|
130
|
+
|
|
131
|
+
const actor = this.buildActor(ws, state);
|
|
132
|
+
try {
|
|
133
|
+
await actor.receiveTextFrame(text);
|
|
134
|
+
} finally {
|
|
135
|
+
// Persist any mutations even if dispatch threw partway through.
|
|
136
|
+
await this.persistState(state);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// If registration completed this frame, schedule a PING alarm.
|
|
140
|
+
if (beforeNick === undefined && state.nick !== undefined) {
|
|
141
|
+
await this.ctx.storage.setAlarm(Date.now() + this.pingIntervalMs);
|
|
142
|
+
}
|
|
143
|
+
// Detect newly joined channels so we can register them with the
|
|
144
|
+
// channel DO — necessary for the close handler to fan out QUIT to
|
|
145
|
+
// them. The stub channel DO records the call; the real ChannelDO
|
|
146
|
+
// (035) maintains authoritative roster state.
|
|
147
|
+
for (const chan of state.joinedChannels) {
|
|
148
|
+
if (!before.has(chan)) {
|
|
149
|
+
// joinReducer already emitted an ApplyChannelDelta; the stub
|
|
150
|
+
// ChannelDO records it. No extra work needed here for 033.
|
|
151
|
+
void chan;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** QUIT fanout + nick release. RFC 1459: a missing close is a QUIT. */
|
|
157
|
+
override async webSocketClose(
|
|
158
|
+
ws: WebSocket,
|
|
159
|
+
_code: number,
|
|
160
|
+
_reason: string,
|
|
161
|
+
_wasClean: boolean,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
await this.tearDown(ws);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Same teardown as close. */
|
|
167
|
+
override async webSocketError(ws: WebSocket, _error: unknown): Promise<void> {
|
|
168
|
+
await this.tearDown(ws);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// -------------------------------------------------------------------------
|
|
172
|
+
// Alarms (PING / idle sweeps)
|
|
173
|
+
// -------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Fires on the scheduled alarm. Sends a PING; if the connection has
|
|
177
|
+
* been idle past the PONG timeout, closes it.
|
|
178
|
+
*
|
|
179
|
+
* PLAN §6.1 — DO alarms are the canonical PING/idle mechanism on CF;
|
|
180
|
+
* equivalent to EventBridge Scheduler on AWS (042).
|
|
181
|
+
*/
|
|
182
|
+
override async alarm(): Promise<void> {
|
|
183
|
+
const state = await this.loadState();
|
|
184
|
+
const now = this.clock.now();
|
|
185
|
+
if (state.lastSeen + this.pongTimeoutMs < now) {
|
|
186
|
+
// Idle past the PONG threshold: tear down.
|
|
187
|
+
const sockets = this.ctx.getWebSockets();
|
|
188
|
+
if (sockets.length > 0) {
|
|
189
|
+
await this.tearDown(sockets[0] as WebSocket);
|
|
190
|
+
} else {
|
|
191
|
+
await this.releaseAndPersist(state);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// Send PING. PONG replies update lastSeen via the actor.
|
|
196
|
+
const token = this.ids.nonce();
|
|
197
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
198
|
+
(ws as WebSocket).send(`PING :${token}\r\n`);
|
|
199
|
+
}
|
|
200
|
+
await this.ctx.storage.setAlarm(now + this.pingIntervalMs);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// -------------------------------------------------------------------------
|
|
204
|
+
// RPC — cross-DO fan-out (called by ChannelDO)
|
|
205
|
+
// -------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Pushes `lines` to every WebSocket attached to this connection. This
|
|
209
|
+
* is the fan-out target used by {@link ChannelDO}: one broadcast to
|
|
210
|
+
* a channel becomes N `deliver` calls, one per member's
|
|
211
|
+
* ConnectionDO.
|
|
212
|
+
*
|
|
213
|
+
* Returns `{ delivered: number }` — the number of WebSockets the
|
|
214
|
+
* lines were written to. Zero signals "no live connection"; callers
|
|
215
|
+
* (ChannelDO.broadcast / sweep) use that to prune dead roster
|
|
216
|
+
* entries lazily.
|
|
217
|
+
*
|
|
218
|
+
* Hibernation note: `state.getWebSockets()` returns the live set
|
|
219
|
+
* even after the DO has been evicted and woken back up, because the
|
|
220
|
+
* runtime tracks them out-of-process. A connection that closed
|
|
221
|
+
* unilaterally reports an empty list and is pruned on the next
|
|
222
|
+
* broadcast.
|
|
223
|
+
*
|
|
224
|
+
* Empty payload (`lines === []`) is a probe — returns the live
|
|
225
|
+
* socket count without sending anything. Used by ChannelDO.sweep.
|
|
226
|
+
*/
|
|
227
|
+
async deliver(lines: RawLine[]): Promise<{ delivered: number }> {
|
|
228
|
+
let delivered = 0;
|
|
229
|
+
let text: string | undefined;
|
|
230
|
+
if (lines.length > 0) {
|
|
231
|
+
text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
|
|
232
|
+
}
|
|
233
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
234
|
+
const socket = ws as WebSocket;
|
|
235
|
+
if (socket.readyState !== WebSocket.OPEN) continue;
|
|
236
|
+
if (text !== undefined) socket.send(text);
|
|
237
|
+
delivered++;
|
|
238
|
+
}
|
|
239
|
+
return { delivered };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// -------------------------------------------------------------------------
|
|
243
|
+
// Internal helpers
|
|
244
|
+
// -------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/** Loads the connection state from storage, caching for the event. */
|
|
247
|
+
private async loadState(): Promise<ConnectionState> {
|
|
248
|
+
if (this.cached !== undefined) return this.cached;
|
|
249
|
+
const record = await this.ctx.storage.get(STATE_STORAGE_KEY);
|
|
250
|
+
if (record === undefined) {
|
|
251
|
+
this.cached = createConnection({
|
|
252
|
+
id: this.ctx.id.toString(),
|
|
253
|
+
connectedSince: this.clock.now(),
|
|
254
|
+
});
|
|
255
|
+
} else {
|
|
256
|
+
this.cached = deserialize(record);
|
|
257
|
+
}
|
|
258
|
+
return this.cached;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Persists the connection state to storage. */
|
|
262
|
+
private async persistState(state: ConnectionState): Promise<void> {
|
|
263
|
+
this.cached = state;
|
|
264
|
+
await this.ctx.storage.put(STATE_STORAGE_KEY, serialize(state));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Constructs an actor wired to this DO's CfRuntime.
|
|
269
|
+
*/
|
|
270
|
+
private buildActor(ws: WebSocket, state: ConnectionState): ConnectionActor {
|
|
271
|
+
const serverConfig = this.serverConfig();
|
|
272
|
+
const motd = this.motdProvider();
|
|
273
|
+
const handlers = {
|
|
274
|
+
send: (lines: RawLine[]): void => {
|
|
275
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
276
|
+
const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
|
|
277
|
+
ws.send(text);
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
disconnect: (reason?: string): void => {
|
|
281
|
+
if (reason !== undefined && ws.readyState === WebSocket.OPEN) {
|
|
282
|
+
ws.send(`ERROR :Closing link: (${reason})\r\n`);
|
|
283
|
+
}
|
|
284
|
+
ws.close();
|
|
285
|
+
},
|
|
286
|
+
snapshot: (): ConnectionState | undefined => this.cached,
|
|
287
|
+
};
|
|
288
|
+
// For 033 stub routing we look up the per-connection registry
|
|
289
|
+
// stub by the DO instance's hex id. Production (034) shards the
|
|
290
|
+
// registry by nick instead, at which point `registryKey` is unused.
|
|
291
|
+
const registryKey = this.ctx.id.toString();
|
|
292
|
+
const runtime = makeCfRuntime(this.env, state.id, registryKey, handlers);
|
|
293
|
+
if (this.channelAccess === undefined) {
|
|
294
|
+
this.channelAccess = new PassthroughChannelAccess(this.clock.now(), this.env);
|
|
295
|
+
}
|
|
296
|
+
return new ConnectionActor({
|
|
297
|
+
state,
|
|
298
|
+
runtime,
|
|
299
|
+
channels: this.channelAccess,
|
|
300
|
+
serverConfig,
|
|
301
|
+
clock: this.clock,
|
|
302
|
+
ids: this.ids,
|
|
303
|
+
motd,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Tears down a connection: emit QUIT effects, release nick, close WS. */
|
|
308
|
+
private async tearDown(ws: WebSocket): Promise<void> {
|
|
309
|
+
const state = await this.loadState();
|
|
310
|
+
const actor = this.buildActor(ws, state);
|
|
311
|
+
// Drive QUIT through the actor so the same effect pipeline handles
|
|
312
|
+
// fanout as during normal operation.
|
|
313
|
+
await actor.receiveTextFrame('QUIT\r\n');
|
|
314
|
+
await this.releaseAndPersist(state);
|
|
315
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
316
|
+
ws.close();
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
await this.ctx.storage.deleteAlarm();
|
|
320
|
+
} catch {
|
|
321
|
+
// Already cleared; ignore.
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Releases the nick via the registry and persists the (possibly mutated) state. */
|
|
326
|
+
private async releaseAndPersist(state: ConnectionState): Promise<void> {
|
|
327
|
+
if (state.nick !== undefined) {
|
|
328
|
+
const registryKey = this.ctx.id.toString();
|
|
329
|
+
const stub = this.env.REGISTRY_DO.get(this.env.REGISTRY_DO.idFromName(registryKey));
|
|
330
|
+
await (stub as unknown as { releaseNick: (n: string) => Promise<void> }).releaseNick(
|
|
331
|
+
state.nick,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
await this.persistState(state);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private serverConfig(): ServerConfig {
|
|
338
|
+
return {
|
|
339
|
+
...DEFAULT_SERVER_CONFIG,
|
|
340
|
+
...(this.env.SERVER_NAME !== undefined ? { serverName: this.env.SERVER_NAME } : {}),
|
|
341
|
+
...(this.env.NETWORK_NAME !== undefined ? { networkName: this.env.NETWORK_NAME } : {}),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private motdProvider(): MotdProvider {
|
|
346
|
+
const raw = this.env.MOTD_LINES;
|
|
347
|
+
if (raw === undefined || raw.length === 0) {
|
|
348
|
+
return { lines: () => DEFAULT_MOTD };
|
|
349
|
+
}
|
|
350
|
+
// MOTD_LINES is a newline-delimited env var.
|
|
351
|
+
return { lines: () => raw.split('\n') };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// -------------------------------------------------------------------------
|
|
355
|
+
// Test hooks (only invoked from `runInDurableObject` in tests)
|
|
356
|
+
// -------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Test hook: explicitly invokes the close-teardown path. Useful when
|
|
360
|
+
* the test framework does not propagate the WebSocket close event to
|
|
361
|
+
* hibernated sockets synchronously enough for assertions.
|
|
362
|
+
*/
|
|
363
|
+
public async __triggerClose(): Promise<void> {
|
|
364
|
+
const sockets = this.ctx.getWebSockets();
|
|
365
|
+
const ws = sockets[0];
|
|
366
|
+
if (ws === undefined) {
|
|
367
|
+
// No socket attached; run cleanup without it.
|
|
368
|
+
const state = await this.loadState();
|
|
369
|
+
await this.tearDownNoSocket(state);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
await this.tearDown(ws as WebSocket);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Like {@link tearDown} but without a live WebSocket reference. */
|
|
376
|
+
private async tearDownNoSocket(state: ConnectionState): Promise<void> {
|
|
377
|
+
// Emit QUIT effects without dispatching transport calls (the socket
|
|
378
|
+
// is already gone). The cross-DO fanout still runs.
|
|
379
|
+
const noOpHandlers: CfConnectionHandlers = {
|
|
380
|
+
send: (): void => {},
|
|
381
|
+
disconnect: (): void => {},
|
|
382
|
+
snapshot: (): ConnectionState | undefined => this.cached,
|
|
383
|
+
};
|
|
384
|
+
const registryKey = this.ctx.id.toString();
|
|
385
|
+
const runtime = makeCfRuntime(this.env, state.id, registryKey, noOpHandlers);
|
|
386
|
+
if (this.channelAccess === undefined) {
|
|
387
|
+
this.channelAccess = new PassthroughChannelAccess(this.clock.now(), this.env);
|
|
388
|
+
}
|
|
389
|
+
const actor = new ConnectionActor({
|
|
390
|
+
state,
|
|
391
|
+
runtime,
|
|
392
|
+
channels: this.channelAccess,
|
|
393
|
+
serverConfig: this.serverConfig(),
|
|
394
|
+
clock: this.clock,
|
|
395
|
+
ids: this.ids,
|
|
396
|
+
motd: this.motdProvider(),
|
|
397
|
+
});
|
|
398
|
+
await actor.receiveTextFrame('QUIT\r\n');
|
|
399
|
+
await this.releaseAndPersist(state);
|
|
400
|
+
try {
|
|
401
|
+
await this.ctx.storage.deleteAlarm();
|
|
402
|
+
} catch {
|
|
403
|
+
// Already cleared.
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Drops the in-memory state cache, simulating wake-from-hibernation. */
|
|
408
|
+
public __resetCache(): void {
|
|
409
|
+
this.cached = undefined;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Returns the live cached state (without forcing a storage read). */
|
|
413
|
+
public async __peekState(): Promise<ConnectionState | undefined> {
|
|
414
|
+
return this.cached;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Backdates `lastSeen` to test the alarm-driven disconnect path. */
|
|
418
|
+
public async __backdateLastSeen(deltaMs: number): Promise<void> {
|
|
419
|
+
const state = await this.loadState();
|
|
420
|
+
state.lastSeen = this.clock.now() - deltaMs;
|
|
421
|
+
await this.persistState(state);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Returns this DO's hex id — the canonical ConnId used in RPC routing. */
|
|
425
|
+
public __peekHexId(): string {
|
|
426
|
+
return this.ctx.id.toString();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Cross-connection RPC: returns a snapshot of this connection's state.
|
|
431
|
+
* Called by another connection's {@link CfRuntime} when a
|
|
432
|
+
* `GetConnectionInfo` effect targets this connection. Loads from storage
|
|
433
|
+
* on demand (safe to call after hibernation).
|
|
434
|
+
*/
|
|
435
|
+
public async getConnSnapshot(): Promise<import('@serverless-ircd/irc-core').ConnSnapshot | null> {
|
|
436
|
+
const state = await this.loadState();
|
|
437
|
+
return toSnapshot(state);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Default MOTD when no `MOTD_LINES` env is supplied. */
|
|
442
|
+
const DEFAULT_MOTD: string[] = ['Welcome to the ServerlessIRCd Cloudflare adapter.'];
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* `ActorChannelAccess` backed by a per-instance cache that is refreshed
|
|
446
|
+
* from the authoritative ChannelDO before each reducer that needs to
|
|
447
|
+
* observe channel state. The cache is seeded lazily by `getOrCreateChannel`
|
|
448
|
+
* (for commands like JOIN that create a brand-new channel) and overwritten
|
|
449
|
+
* by `refreshChannel` (for commands that read existing state: NAMES, MODE,
|
|
450
|
+
* KICK, PART, TOPIC, PRIVMSG fan-out checks, …).
|
|
451
|
+
*
|
|
452
|
+
* The `env` reference lets `refreshChannel` issue a `getChannelSnapshot`
|
|
453
|
+
* RPC to the right ChannelDO instance. The DTO returned across the RPC
|
|
454
|
+
* boundary is rehydrated into a proper {@link ChannelState} so the
|
|
455
|
+
* synchronous reducers can mutate `members` (a Map) and `banMasks` /
|
|
456
|
+
* `pendingInvites` (Sets) in place.
|
|
457
|
+
*/
|
|
458
|
+
class PassthroughChannelAccess {
|
|
459
|
+
private readonly cache = new Map<string, ChannelState>();
|
|
460
|
+
constructor(
|
|
461
|
+
private readonly now: number,
|
|
462
|
+
private readonly env: Env,
|
|
463
|
+
) {}
|
|
464
|
+
|
|
465
|
+
getOrCreateChannel(name: ChanName): ChannelState {
|
|
466
|
+
const key = name.toLowerCase();
|
|
467
|
+
const existing = this.cache.get(key);
|
|
468
|
+
if (existing !== undefined) return existing;
|
|
469
|
+
const created = createChannel({
|
|
470
|
+
name,
|
|
471
|
+
nameLower: key,
|
|
472
|
+
createdAt: this.now,
|
|
473
|
+
});
|
|
474
|
+
this.cache.set(key, created);
|
|
475
|
+
return created;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Pulls the authoritative snapshot for `name` from the ChannelDO and
|
|
480
|
+
* replaces the local cache entry. No-op if the channel does not exist
|
|
481
|
+
* remotely (the caller will fall through to `getOrCreateChannel` which
|
|
482
|
+
* lazily seeds an empty entry).
|
|
483
|
+
*
|
|
484
|
+
* Safe to call on a channel the connection has not joined: the snapshot
|
|
485
|
+
* is read-only and the local cache entry is only consulted by the
|
|
486
|
+
* reducer running this frame.
|
|
487
|
+
*
|
|
488
|
+
* The caller-supplied `name` is preserved verbatim in the rebuilt state
|
|
489
|
+
* (rather than trusting `dto.name`, which can default to a hex DO id on
|
|
490
|
+
* channels that were lazily initialised by ChannelDO but never received
|
|
491
|
+
* an `applyChannelDelta`).
|
|
492
|
+
*/
|
|
493
|
+
async refreshChannel(name: ChanName): Promise<void> {
|
|
494
|
+
const key = name.toLowerCase();
|
|
495
|
+
const id = this.env.CHANNEL_DO.idFromName(key);
|
|
496
|
+
const stub = this.env.CHANNEL_DO.get(id);
|
|
497
|
+
const dto = (await (
|
|
498
|
+
stub as unknown as {
|
|
499
|
+
getChannelSnapshot: () => Promise<unknown>;
|
|
500
|
+
}
|
|
501
|
+
).getChannelSnapshot()) as {
|
|
502
|
+
modes: ChannelState['modes'];
|
|
503
|
+
banMasks: string[];
|
|
504
|
+
topic?: ChannelState['topic'];
|
|
505
|
+
members: Array<{ conn: ConnId; nick: string; op: boolean; voice: boolean }>;
|
|
506
|
+
pendingInvites: string[];
|
|
507
|
+
createdAt: number;
|
|
508
|
+
} | null;
|
|
509
|
+
if (dto === null) return;
|
|
510
|
+
// Don't replace the cache with an empty snapshot — the channel
|
|
511
|
+
// effectively doesn't exist yet, and the local reducer should lazily
|
|
512
|
+
// seed it via getOrCreateChannel. This also avoids clobbering the
|
|
513
|
+
// local cache with ChannelDO's "name unknown" default state.
|
|
514
|
+
if (dto.members.length === 0) return;
|
|
515
|
+
const members = new Map<ConnId, { conn: ConnId; nick: string; op: boolean; voice: boolean }>();
|
|
516
|
+
for (const entry of dto.members) {
|
|
517
|
+
members.set(entry.conn, {
|
|
518
|
+
conn: entry.conn,
|
|
519
|
+
nick: entry.nick,
|
|
520
|
+
op: entry.op,
|
|
521
|
+
voice: entry.voice,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
const rebuilt: ChannelState = {
|
|
525
|
+
name,
|
|
526
|
+
nameLower: key,
|
|
527
|
+
modes: { ...dto.modes },
|
|
528
|
+
banMasks: new Set(dto.banMasks),
|
|
529
|
+
...(dto.topic !== undefined ? { topic: dto.topic } : {}),
|
|
530
|
+
members,
|
|
531
|
+
pendingInvites: new Set(dto.pendingInvites),
|
|
532
|
+
createdAt: dto.createdAt,
|
|
533
|
+
};
|
|
534
|
+
this.cache.set(key, rebuilt);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Reference `applyChannelDelta` to ensure the import is used downstream by
|
|
539
|
+
// tests that exercise state migrations; harmless if the symbol is unused
|
|
540
|
+
// in production today. (Removed from the ConnectionDO path because the
|
|
541
|
+
// authoritative delta application lives in the ChannelDO stub today.)
|
|
542
|
+
export { applyChannelDelta, PERSISTED_STATE_VERSION };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Workers environment bindings for the cf-adapter.
|
|
3
|
+
*
|
|
4
|
+
* Each binding corresponds to a Durable Object namespace declared in
|
|
5
|
+
* `wrangler.toml`. The same shape is used in production and tests;
|
|
6
|
+
* tests register stub implementations in `wrangler.test.toml`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The production {@link Env} for `apps/cf-worker`. ConnectionDO uses the
|
|
11
|
+
* three DO namespaces below to coordinate with the registry and channel
|
|
12
|
+
* authorities. 036 (CfRuntime) is where the full wiring lives;
|
|
13
|
+
* 033 (ConnectionDO) only needs `CONNECTION_DO` itself plus the
|
|
14
|
+
* two collaborator namespaces.
|
|
15
|
+
*
|
|
16
|
+
* RPC method shapes on {@link RegistryRpc} and {@link ChannelRpc} are
|
|
17
|
+
* implemented by both the production DOs and the test stubs in
|
|
18
|
+
* `tests/worker/stubs/`.
|
|
19
|
+
*/
|
|
20
|
+
export interface Env {
|
|
21
|
+
/** Per-connection authority: socket + ConnectionState. */
|
|
22
|
+
CONNECTION_DO: DurableObjectNamespace;
|
|
23
|
+
/**
|
|
24
|
+
* Nick registry authority. RPC: reserveNick / changeNick / releaseNick /
|
|
25
|
+
* lookupNick. Stubbed now (033); real impl in 034.
|
|
26
|
+
*
|
|
27
|
+
* Note: the production DO class is what's brand-typed; here we keep the
|
|
28
|
+
* namespace unparameterized and cast to the RPC surface at the call
|
|
29
|
+
* site. The full type plumbing lands with 034.
|
|
30
|
+
*/
|
|
31
|
+
REGISTRY_DO: DurableObjectNamespace;
|
|
32
|
+
/**
|
|
33
|
+
* Channel authority, keyed by lowercased channel name. RPC: broadcast /
|
|
34
|
+
* applyChannelDelta / getChannelSnapshot. Stubbed now; real in 035.
|
|
35
|
+
*/
|
|
36
|
+
CHANNEL_DO: DurableObjectNamespace;
|
|
37
|
+
/** Server-level config knobs (name, MOTD lines, limits). */
|
|
38
|
+
SERVER_NAME: string;
|
|
39
|
+
NETWORK_NAME: string;
|
|
40
|
+
MOTD_LINES: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* RPC contract ConnectionDO expects from the registry. The real
|
|
45
|
+
* RegistryDO (034) and the test stub both implement this.
|
|
46
|
+
*/
|
|
47
|
+
export interface RegistryRpc {
|
|
48
|
+
reserveNick(nick: string, conn: string): Promise<{ ok: true } | { ok: false }>;
|
|
49
|
+
changeNick(conn: string, oldNick: string, newNick: string): Promise<boolean>;
|
|
50
|
+
releaseNick(nick: string): Promise<void>;
|
|
51
|
+
lookupNick(nick: string): Promise<string | null>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* RPC contract ConnectionDO expects from each channel instance. The real
|
|
56
|
+
* ChannelDO (035) and the test stub both implement this.
|
|
57
|
+
*
|
|
58
|
+
* Note: `broadcast` accepts already-formatted raw line texts (no
|
|
59
|
+
* `RawLine` wrapper) because that's what `cf-runtime.broadcast`
|
|
60
|
+
* forwards after unwrapping the pure-core `RawLine[]`. The ChannelDO
|
|
61
|
+
* re-wraps them when calling `ConnectionDO.deliver`.
|
|
62
|
+
*/
|
|
63
|
+
export interface ChannelRpc {
|
|
64
|
+
broadcast(lines: unknown[], except?: string): Promise<void>;
|
|
65
|
+
applyChannelDelta(delta: unknown): Promise<void>;
|
|
66
|
+
getChannelSnapshot(): Promise<unknown>;
|
|
67
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @serverless-ircd/cf-adapter
|
|
3
|
+
*
|
|
4
|
+
* Cloudflare Workers adapter for ServerlessIRCd. Implements the three
|
|
5
|
+
* Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and a
|
|
6
|
+
* CfRuntime that bridges them behind the platform-agnostic
|
|
7
|
+
* {@link IrcRuntime} port.
|
|
8
|
+
*
|
|
9
|
+
* 033 shipped the {@link ConnectionDO} + a stub CfRuntime.
|
|
10
|
+
* 034 ships the {@link RegistryDO} (real nick registry with
|
|
11
|
+
* sharding). The {@link ChannelDO} (roster + modes + fanout) ships
|
|
12
|
+
* here. The full CfRuntime lands in 036.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
ConnectionDO,
|
|
17
|
+
DEFAULT_PING_INTERVAL_MS,
|
|
18
|
+
DEFAULT_PONG_TIMEOUT_MS,
|
|
19
|
+
} from './connection-do.js';
|
|
20
|
+
export { RegistryDO } from './registry-do.js';
|
|
21
|
+
export { ChannelDO } from './channel-do.js';
|
|
22
|
+
export type { DeliverResult, ConnectionDeliveryRpc } from './channel-do.js';
|
|
23
|
+
export {
|
|
24
|
+
DEFAULT_REGISTRY_SHARDS,
|
|
25
|
+
registryKeyForNick,
|
|
26
|
+
shardNick,
|
|
27
|
+
} from './sharding.js';
|
|
28
|
+
export { makeCfRuntime } from './cf-runtime.js';
|
|
29
|
+
export type { CfConnectionHandlers } from './cf-runtime.js';
|
|
30
|
+
export type { ChannelRpc, Env, RegistryRpc } from './env.js';
|
|
31
|
+
export {
|
|
32
|
+
PERSISTED_STATE_VERSION,
|
|
33
|
+
STATE_STORAGE_KEY,
|
|
34
|
+
deserialize,
|
|
35
|
+
migrate,
|
|
36
|
+
serialize,
|
|
37
|
+
} from './serialize.js';
|
|
38
|
+
export type { PersistedConnectionState } from './serialize.js';
|