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,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare {@link IrcRuntime} adapter.
|
|
3
|
+
*
|
|
4
|
+
* Wires the three Durable Object namespaces (ConnectionDO, RegistryDO,
|
|
5
|
+
* ChannelDO) together behind a single {@link IrcRuntime} surface. Each
|
|
6
|
+
* method issues the correct DO `stub()` call:
|
|
7
|
+
*
|
|
8
|
+
* - Nick registry ops route to `REGISTRY_DO` sharded by
|
|
9
|
+
* {@link registryKeyForNick} so two clients racing on the same nick
|
|
10
|
+
* always land on the same shard.
|
|
11
|
+
* - Channel ops route to `CHANNEL_DO` keyed by lowercased channel name.
|
|
12
|
+
* - Cross-connection `send` / `getConnectionInfo` route to the target
|
|
13
|
+
* connection's `ConnectionDO` via RPC (`deliver`, `getConnSnapshot`).
|
|
14
|
+
* - Calls whose target *is* the bound connection shortcut through the
|
|
15
|
+
* injected {@link CfConnectionHandlers} — no RPC-to-self.
|
|
16
|
+
*
|
|
17
|
+
* The class is constructed per `ConnectionDO.webSocketMessage` event:
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* const runtime = new CfRuntime(this.env, state.id, handlers);
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* `state.id` is the DO's hex id (`ctx.id.toString()`), which is the same
|
|
24
|
+
* string other connections see when they look up a nick in the registry
|
|
25
|
+
* — so cross-connection routing is symmetric.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
type ChanName,
|
|
30
|
+
type ChanSnapshot,
|
|
31
|
+
type ChannelDelta,
|
|
32
|
+
type ConnId,
|
|
33
|
+
type ConnSnapshot,
|
|
34
|
+
type ConnectionState,
|
|
35
|
+
type Nick,
|
|
36
|
+
type RawLine,
|
|
37
|
+
type RosterEntry,
|
|
38
|
+
toSnapshot,
|
|
39
|
+
} from '@serverless-ircd/irc-core';
|
|
40
|
+
import type { IrcRuntime } from '@serverless-ircd/irc-server';
|
|
41
|
+
import type { Env, RegistryRpc } from './env.js';
|
|
42
|
+
import { DEFAULT_REGISTRY_SHARDS, registryKeyForNick, shardNick } from './sharding.js';
|
|
43
|
+
|
|
44
|
+
/** Per-connection transport callbacks the ConnectionDO wires up. */
|
|
45
|
+
export interface CfConnectionHandlers {
|
|
46
|
+
/** Outbound IRC lines (already framed by the caller) written to the WS. */
|
|
47
|
+
send: (lines: RawLine[]) => void;
|
|
48
|
+
/** Close the underlying transport. */
|
|
49
|
+
disconnect: (reason?: string) => void;
|
|
50
|
+
/** Snapshot of the live ConnectionState, used by getConnectionInfo. */
|
|
51
|
+
snapshot(): ConnectionState | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* RPC contract the {@link CfRuntime} expects on every `ConnectionDO`
|
|
56
|
+
* instance. ConnectionDO implements this in addition to its WebSocket
|
|
57
|
+
* handlers so that *other* connections' runtimes can route `Send` and
|
|
58
|
+
* `GetConnectionInfo` effects to it. The shape matches the
|
|
59
|
+
* {@link ConnectionDeliveryRpc} the ChannelDO uses, so a single RPC
|
|
60
|
+
* method (`deliver`) covers both fanout and point-to-point send paths.
|
|
61
|
+
*/
|
|
62
|
+
export interface ConnectionRpc {
|
|
63
|
+
/** Delivers `lines` to the live (or hibernated) WebSocket. */
|
|
64
|
+
deliver(lines: RawLine[]): Promise<{ delivered: number }>;
|
|
65
|
+
/** Returns a serializable snapshot of the connection state. */
|
|
66
|
+
getConnSnapshot(): Promise<ConnSnapshot | null>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Cloudflare {@link IrcRuntime} bound to one connection.
|
|
71
|
+
*
|
|
72
|
+
* Construct one per inbound frame: cheap to build, no caching across
|
|
73
|
+
* events (every call hits the authoritative DO so state can never drift
|
|
74
|
+
* between the runtime and the DOs).
|
|
75
|
+
*/
|
|
76
|
+
export class CfRuntime implements IrcRuntime {
|
|
77
|
+
private readonly env: Env;
|
|
78
|
+
private readonly connId: ConnId;
|
|
79
|
+
private readonly handlers: CfConnectionHandlers;
|
|
80
|
+
private readonly registryShards: number;
|
|
81
|
+
|
|
82
|
+
constructor(
|
|
83
|
+
env: Env,
|
|
84
|
+
connId: ConnId,
|
|
85
|
+
handlers: CfConnectionHandlers,
|
|
86
|
+
opts: { registryShards?: number } = {},
|
|
87
|
+
) {
|
|
88
|
+
this.env = env;
|
|
89
|
+
this.connId = connId;
|
|
90
|
+
this.handlers = handlers;
|
|
91
|
+
this.registryShards = opts.registryShards ?? DEFAULT_REGISTRY_SHARDS;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// -------------------------------------------------------------------------
|
|
95
|
+
// Transport
|
|
96
|
+
// -------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
async send(to: ConnId, lines: RawLine[]): Promise<void> {
|
|
99
|
+
if (to === this.connId) {
|
|
100
|
+
this.handlers.send(lines);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Cross-connection: route via the target's ConnectionDO RPC. The DO
|
|
104
|
+
// id (hex string) is exactly what the registry stores as the conn
|
|
105
|
+
// id, so `idFromString` resolves it directly.
|
|
106
|
+
const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(to));
|
|
107
|
+
await (stub as unknown as ConnectionRpc).deliver(lines);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async broadcast(chan: ChanName, lines: RawLine[], except?: ConnId): Promise<void> {
|
|
111
|
+
const payload = lines.map((l) => l.text);
|
|
112
|
+
await this.channelRpc(chan).broadcast(payload, except);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async disconnect(conn: ConnId, reason?: string): Promise<void> {
|
|
116
|
+
if (conn === this.connId) {
|
|
117
|
+
this.handlers.disconnect(reason);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Cross-connection disconnect is a teardown signal; we route it via
|
|
121
|
+
// the target's ConnectionDO RPC as well. (ConnectionDO doesn't expose
|
|
122
|
+
// a dedicated `disconnect` RPC today — the canonical teardown path is
|
|
123
|
+
// the WebSocket close, which is owned by the connection itself. We
|
|
124
|
+
// therefore no-op for cross-connection disconnects; this preserves
|
|
125
|
+
// the IrcRuntime contract without inventing a new RPC.)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async sendToNick(
|
|
129
|
+
nick: Nick,
|
|
130
|
+
_sender: ConnId,
|
|
131
|
+
lines: RawLine[],
|
|
132
|
+
notFoundLines?: RawLine[],
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
const owner = await this.registryRpc(nick).lookupNick(nick);
|
|
135
|
+
if (owner !== null) {
|
|
136
|
+
const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(owner));
|
|
137
|
+
await (stub as unknown as ConnectionRpc).deliver(lines);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (notFoundLines !== undefined) {
|
|
141
|
+
this.handlers.send(notFoundLines);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// -------------------------------------------------------------------------
|
|
146
|
+
// Nick registry
|
|
147
|
+
// -------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
async reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }> {
|
|
150
|
+
return this.registryRpc(nick).reserveNick(nick, conn);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async changeNick(conn: ConnId, oldNick: Nick, newNick: Nick): Promise<boolean> {
|
|
154
|
+
// Two-phase when the shards differ: reserve-new first; on success,
|
|
155
|
+
// release-old on the old shard. The new-nick shard wins the race;
|
|
156
|
+
// any concurrent contender that beats us to `newNick` makes the
|
|
157
|
+
// first phase fail and we leave `oldNick` untouched.
|
|
158
|
+
if (this.shardOf(oldNick) === this.shardOf(newNick)) {
|
|
159
|
+
return this.registryRpc(newNick).changeNick(conn, oldNick, newNick);
|
|
160
|
+
}
|
|
161
|
+
const reserved = await this.registryRpc(newNick).reserveNick(newNick, conn);
|
|
162
|
+
if (!reserved.ok) return false;
|
|
163
|
+
await this.registryRpc(oldNick).releaseNick(oldNick);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async releaseNick(nick: Nick): Promise<void> {
|
|
168
|
+
await this.registryRpc(nick).releaseNick(nick);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async lookupNick(nick: Nick): Promise<ConnId | null> {
|
|
172
|
+
return this.registryRpc(nick).lookupNick(nick);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// -------------------------------------------------------------------------
|
|
176
|
+
// Channel ownership
|
|
177
|
+
// -------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
async applyChannelDelta(chan: ChanName, delta: ChannelDelta): Promise<void> {
|
|
180
|
+
await this.channelRpc(chan).applyChannelDelta(delta);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async getChannelSnapshot(chan: ChanName): Promise<ChanSnapshot | null> {
|
|
184
|
+
// ChannelDO returns a JSON-friendly DTO (members/banMasks/pendingInvites
|
|
185
|
+
// as arrays). Rehydrate into a proper ChanSnapshot so callers can treat
|
|
186
|
+
// `members` as a ReadonlyMap keyed by ConnId and `banMasks`/pendingInvites
|
|
187
|
+
// as Sets.
|
|
188
|
+
const dto = (await this.channelRpc(chan).getChannelSnapshot()) as {
|
|
189
|
+
name: ChanName;
|
|
190
|
+
nameLower: string;
|
|
191
|
+
modes: ChanSnapshot['modes'];
|
|
192
|
+
banMasks: string[];
|
|
193
|
+
topic?: ChanSnapshot['topic'];
|
|
194
|
+
members: Array<RosterEntry & { conn: ConnId }>;
|
|
195
|
+
pendingInvites: string[];
|
|
196
|
+
createdAt: number;
|
|
197
|
+
} | null;
|
|
198
|
+
if (dto === null) return null;
|
|
199
|
+
const members = new Map<ConnId, RosterEntry>();
|
|
200
|
+
for (const entry of dto.members) {
|
|
201
|
+
members.set(entry.conn, {
|
|
202
|
+
conn: entry.conn,
|
|
203
|
+
nick: entry.nick,
|
|
204
|
+
op: entry.op,
|
|
205
|
+
voice: entry.voice,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const snap: ChanSnapshot = {
|
|
209
|
+
name: dto.name,
|
|
210
|
+
nameLower: dto.nameLower,
|
|
211
|
+
modes: { ...dto.modes },
|
|
212
|
+
banMasks: new Set(dto.banMasks),
|
|
213
|
+
...(dto.topic !== undefined ? { topic: dto.topic } : {}),
|
|
214
|
+
members,
|
|
215
|
+
pendingInvites: new Set(dto.pendingInvites),
|
|
216
|
+
createdAt: dto.createdAt,
|
|
217
|
+
};
|
|
218
|
+
return snap;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// -------------------------------------------------------------------------
|
|
222
|
+
// Lookups
|
|
223
|
+
// -------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
async getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null> {
|
|
226
|
+
if (conn === this.connId) {
|
|
227
|
+
const live = this.handlers.snapshot();
|
|
228
|
+
return live === undefined ? null : toSnapshot(live);
|
|
229
|
+
}
|
|
230
|
+
const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(conn));
|
|
231
|
+
return (stub as unknown as ConnectionRpc).getConnSnapshot();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async getConnection(conn: ConnId): Promise<ConnectionState | null> {
|
|
235
|
+
// CF stores ConnectionState inside each ConnectionDO; the only
|
|
236
|
+
// authoritative view this runtime can hand out without a new RPC is
|
|
237
|
+
// the local connection's live state. Remote connections return null
|
|
238
|
+
// until a dedicated `getConnState` RPC lands alongside the broader
|
|
239
|
+
// WHOIS-over-CF work — the actor degrades to "unknown nick" for them.
|
|
240
|
+
if (conn === this.connId) {
|
|
241
|
+
return this.handlers.snapshot() ?? null;
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async getChannelConnections(_chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
|
|
247
|
+
// CF has no single source of truth for full ConnectionStates across
|
|
248
|
+
// all ConnectionDOs. The WHO command (the only current consumer)
|
|
249
|
+
// ships in a later ticket; return an empty map until then.
|
|
250
|
+
return new Map();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async listChannels(): Promise<ChanSnapshot[]> {
|
|
254
|
+
// CF has no central channel registry; each ChannelDO is independent.
|
|
255
|
+
// The LIST command (the only current consumer) ships in a later
|
|
256
|
+
// ticket; return an empty array until then.
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// -------------------------------------------------------------------------
|
|
261
|
+
// Internal
|
|
262
|
+
// -------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
/** Shard index for a nick under the configured fleet size. */
|
|
265
|
+
private shardOf(nick: Nick): number {
|
|
266
|
+
return shardNick(nick, this.registryShards);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Returns the registry DO RPC stub for the shard owning `nick`. */
|
|
270
|
+
private registryRpc(nick: Nick): RegistryRpc {
|
|
271
|
+
const key = registryKeyForNick(nick, this.registryShards);
|
|
272
|
+
const stub = this.env.REGISTRY_DO.get(this.env.REGISTRY_DO.idFromName(key));
|
|
273
|
+
return stub as unknown as RegistryRpc;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Returns the channel DO RPC stub for `name` (keyed by lowercased name). */
|
|
277
|
+
private channelRpc(name: ChanName): {
|
|
278
|
+
broadcast(lines: unknown[], except?: string): Promise<void>;
|
|
279
|
+
applyChannelDelta(delta: unknown): Promise<void>;
|
|
280
|
+
getChannelSnapshot(): Promise<unknown>;
|
|
281
|
+
} {
|
|
282
|
+
const lower = name.toLowerCase();
|
|
283
|
+
const stub = this.env.CHANNEL_DO.get(this.env.CHANNEL_DO.idFromName(lower));
|
|
284
|
+
return stub as unknown as {
|
|
285
|
+
broadcast(lines: unknown[], except?: string): Promise<void>;
|
|
286
|
+
applyChannelDelta(delta: unknown): Promise<void>;
|
|
287
|
+
getChannelSnapshot(): Promise<unknown>;
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Legacy factory retained for backward compatibility with code that
|
|
294
|
+
* constructs the per-connection runtime inline (e.g. {@link ConnectionDO}'s
|
|
295
|
+
* own build path). Equivalent to `new CfRuntime(env, connId, handlers)`.
|
|
296
|
+
*/
|
|
297
|
+
export function makeCfRuntime(
|
|
298
|
+
env: Env,
|
|
299
|
+
connId: ConnId,
|
|
300
|
+
_registryKey: string,
|
|
301
|
+
handlers: CfConnectionHandlers,
|
|
302
|
+
): IrcRuntime {
|
|
303
|
+
// `_registryKey` is ignored — the production CfRuntime always shards the
|
|
304
|
+
// registry by nick (the prior per-conn key was a 033 scaffolding
|
|
305
|
+
// convention that the real RegistryDO does not use).
|
|
306
|
+
void _registryKey;
|
|
307
|
+
return new CfRuntime(env, connId, handlers);
|
|
308
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ChannelDO` — Cloudflare Durable Object owning one IRC channel's
|
|
3
|
+
* authoritative state: roster, modes, topic, ban list.
|
|
4
|
+
*
|
|
5
|
+
* PLAN §6.1 — one DO instance per lowercased channel name
|
|
6
|
+
* (`env.CHANNEL_DO.idFromName(chanLower)`). The DO is the single
|
|
7
|
+
* serialized authority for that channel's roster; concurrent JOINs,
|
|
8
|
+
* MODEs, and broadcasts are serialized by the DO's request loop, so
|
|
9
|
+
* invariants (unique membership, consistent ban list, atomic topic
|
|
10
|
+
* swaps) hold without external locking.
|
|
11
|
+
*
|
|
12
|
+
* State is persisted to {@link DurableObjectState["storage"]} so it
|
|
13
|
+
* survives eviction. The persisted shape is a JSON-friendly
|
|
14
|
+
* {@link PersistedChannelState}; `Set`s and `Map`s are rebuilt on
|
|
15
|
+
* load (mirrors the trick used by `ConnectionDO`'s serialize module).
|
|
16
|
+
*
|
|
17
|
+
* Fanout model:
|
|
18
|
+
* - `broadcast(lines, except)` walks the live roster and calls each
|
|
19
|
+
* member's `ConnectionDO.deliver(lines)` via service-binding RPC.
|
|
20
|
+
* - `deliver` returns `{ delivered: number }`; zero means the member
|
|
21
|
+
* has no live WebSocket. The ChannelDO prunes those entries lazily
|
|
22
|
+
* on every broadcast (and via the explicit {@link sweep} method)
|
|
23
|
+
* so the roster cannot accumulate ghost members.
|
|
24
|
+
*
|
|
25
|
+
* Coordinates with ConnectionDO via the `applyChannelDelta` RPC: the
|
|
26
|
+
* ConnectionDO-side CfRuntime forwards every `ApplyChannelDelta` effect
|
|
27
|
+
* here, where the mutation lands on the authoritative state. The same
|
|
28
|
+
* delta shape from `@serverless-ircd/irc-core` flows both ways.
|
|
29
|
+
*
|
|
30
|
+
* RPC return-type note: snapshot reads return the JSON-friendly
|
|
31
|
+
* {@link ChannelSnapshotDto} (members/banMasks/pendingInvites as
|
|
32
|
+
* arrays) so they survive the DO RPC boundary without relying on
|
|
33
|
+
* structured-clone of `Map`/`Set`. The CfRuntime (036)
|
|
34
|
+
* rehydrates these into a full {@link ChanSnapshot} at the call site.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
38
|
+
import {
|
|
39
|
+
type ChanName,
|
|
40
|
+
type ChannelDelta,
|
|
41
|
+
type ChannelState,
|
|
42
|
+
type ChannelTopic,
|
|
43
|
+
type ConnId,
|
|
44
|
+
type RosterEntry,
|
|
45
|
+
applyChannelDelta,
|
|
46
|
+
createChannel,
|
|
47
|
+
toSnapshot,
|
|
48
|
+
} from '@serverless-ircd/irc-core';
|
|
49
|
+
import type { Env } from './env.js';
|
|
50
|
+
|
|
51
|
+
/** Storage key under which the per-channel state is persisted. */
|
|
52
|
+
const STATE_STORAGE_KEY = 'channel-state';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* JSON-friendly persisted shape. `members` (a `Map`) and `banMasks`
|
|
56
|
+
* (a `Set`) are serialized as arrays; everything else survives
|
|
57
|
+
* `JSON.stringify` verbatim. `pendingInvites` is similarly serialized
|
|
58
|
+
* as an array of lowercased nicks.
|
|
59
|
+
*/
|
|
60
|
+
interface PersistedChannelState {
|
|
61
|
+
name: ChanName;
|
|
62
|
+
nameLower: string;
|
|
63
|
+
modes: ChannelState['modes'];
|
|
64
|
+
banMasks: string[];
|
|
65
|
+
topic?: ChannelTopic;
|
|
66
|
+
members: Array<Omit<RosterEntry, 'conn'> & { conn: ConnId }>;
|
|
67
|
+
pendingInvites: string[];
|
|
68
|
+
createdAt: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Result returned by `ConnectionDO.deliver`. The ChannelDO prunes any
|
|
73
|
+
* member whose `deliver` returns `delivered: 0`.
|
|
74
|
+
*/
|
|
75
|
+
export interface DeliverResult {
|
|
76
|
+
delivered: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* RPC contract `ChannelDO` calls on each member's `ConnectionDO`. The
|
|
81
|
+
* real `ConnectionDO` implements this; tests use the same shape against
|
|
82
|
+
* the production `ConnectionDO` instance.
|
|
83
|
+
*/
|
|
84
|
+
export interface ConnectionDeliveryRpc {
|
|
85
|
+
deliver(lines: Array<{ text: string }>): Promise<DeliverResult>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* JSON-friendly snapshot DTO returned by `getChannelSnapshot`. Maps and
|
|
90
|
+
* Sets are flattened to arrays so the value survives the DO RPC
|
|
91
|
+
* boundary in both production and the test pool. The CfRuntime
|
|
92
|
+
* (036) rehydrates this into a {@link ChanSnapshot} at the
|
|
93
|
+
* call site.
|
|
94
|
+
*/
|
|
95
|
+
export interface ChannelSnapshotDto {
|
|
96
|
+
name: ChanName;
|
|
97
|
+
nameLower: string;
|
|
98
|
+
modes: ChannelState['modes'];
|
|
99
|
+
banMasks: string[];
|
|
100
|
+
topic?: ChannelTopic;
|
|
101
|
+
members: RosterEntry[];
|
|
102
|
+
pendingInvites: string[];
|
|
103
|
+
createdAt: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* ChannelDO instance.
|
|
108
|
+
*
|
|
109
|
+
* The class is exported as the binding target in `wrangler.toml`; the
|
|
110
|
+
* Workers runtime instantiates it with `(ctx, env)` per DO id.
|
|
111
|
+
*/
|
|
112
|
+
export class ChannelDO extends DurableObject<Env> {
|
|
113
|
+
/** Cached authoritative channel state; reloaded from storage on miss. */
|
|
114
|
+
private cached: ChannelState | undefined;
|
|
115
|
+
|
|
116
|
+
// -------------------------------------------------------------------------
|
|
117
|
+
// RPC — roster / modes / topic mutations
|
|
118
|
+
// -------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Applies a {@link ChannelDelta} atomically: memberships, modeChanges,
|
|
122
|
+
* banMaskChanges, and topic. Idempotent for no-op deltas (empty
|
|
123
|
+
* membership lists, missing members, etc.).
|
|
124
|
+
*
|
|
125
|
+
* This is the single entry point ConnectionDO uses (via CfRuntime)
|
|
126
|
+
* to mutate channel state in response to JOIN / PART / KICK / MODE /
|
|
127
|
+
* TOPIC reducers. The reducer builds the delta; this method applies
|
|
128
|
+
* it against the authoritative state and persists the result.
|
|
129
|
+
*/
|
|
130
|
+
async applyChannelDelta(delta: ChannelDelta): Promise<void> {
|
|
131
|
+
const state = await this.loadState();
|
|
132
|
+
const next = applyChannelDelta(state, delta);
|
|
133
|
+
await this.persistState(next);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// -------------------------------------------------------------------------
|
|
137
|
+
// RPC — fanout
|
|
138
|
+
// -------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Fans `lines` out to every member of the channel (optionally
|
|
142
|
+
* skipping `except`). For each member the DO calls
|
|
143
|
+
* `ConnectionDO.deliver(lines)` via service-binding RPC; members
|
|
144
|
+
* whose `deliver` returns `{ delivered: 0 }` are pruned from the
|
|
145
|
+
* roster (lazy gone-connection sweep).
|
|
146
|
+
*
|
|
147
|
+
* The fan-out runs in parallel via `Promise.all` so a 1k-member
|
|
148
|
+
* channel completes within a single event-loop tick budget. Member
|
|
149
|
+
* order in the resulting prune set is non-deterministic but the
|
|
150
|
+
* prune delta is order-independent (removes are idempotent).
|
|
151
|
+
*
|
|
152
|
+
* @param lines Raw IRC line texts to deliver.
|
|
153
|
+
* @param except Optional ConnId to skip (e.g. the sender of a PRIVMSG).
|
|
154
|
+
*/
|
|
155
|
+
async broadcast(lines: string[], except?: ConnId): Promise<void> {
|
|
156
|
+
if (lines.length === 0) return;
|
|
157
|
+
const state = await this.loadState();
|
|
158
|
+
if (state.members.size === 0) return;
|
|
159
|
+
|
|
160
|
+
const payload = lines.map((text) => ({ text }));
|
|
161
|
+
const pruned = await this.fanoutAndCollect(state, payload, except);
|
|
162
|
+
if (pruned.length > 0) {
|
|
163
|
+
const next = applyChannelDelta(state, {
|
|
164
|
+
memberships: pruned.map((conn) => ({ type: 'remove' as const, conn })),
|
|
165
|
+
});
|
|
166
|
+
await this.persistState(next);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Explicit gone-connection sweep. Walks every roster entry and prunes
|
|
172
|
+
* those whose ConnectionDO reports `delivered: 0` (no live WebSocket).
|
|
173
|
+
* Returns the number of members pruned.
|
|
174
|
+
*
|
|
175
|
+
* Useful for periodic alarm-driven cleanup; the `broadcast` path
|
|
176
|
+
* performs the same pruning lazily on every fan-out.
|
|
177
|
+
*/
|
|
178
|
+
async sweep(): Promise<number> {
|
|
179
|
+
const state = await this.loadState();
|
|
180
|
+
if (state.members.size === 0) return 0;
|
|
181
|
+
// Probe each member with an empty payload (deliver returns the
|
|
182
|
+
// OPEN-socket count without sending bytes).
|
|
183
|
+
const pruned = await this.fanoutAndCollect(state, [], undefined);
|
|
184
|
+
if (pruned.length > 0) {
|
|
185
|
+
const next = applyChannelDelta(state, {
|
|
186
|
+
memberships: pruned.map((conn) => ({ type: 'remove' as const, conn })),
|
|
187
|
+
});
|
|
188
|
+
await this.persistState(next);
|
|
189
|
+
}
|
|
190
|
+
return pruned.length;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// -------------------------------------------------------------------------
|
|
194
|
+
// RPC — reads
|
|
195
|
+
// -------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Returns a read-only snapshot of the channel state as a
|
|
199
|
+
* JSON-friendly DTO ({@link ChannelSnapshotDto}). Suitable for
|
|
200
|
+
* NAMES, WHO, and PRIVMSG fanout routing decisions. The DTO uses
|
|
201
|
+
* arrays instead of `Map`/`Set` so it survives DO RPC in both
|
|
202
|
+
* production and the test pool.
|
|
203
|
+
*/
|
|
204
|
+
async getChannelSnapshot(): Promise<ChannelSnapshotDto> {
|
|
205
|
+
const state = await this.loadState();
|
|
206
|
+
return toDto(state);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// -------------------------------------------------------------------------
|
|
210
|
+
// Internal helpers
|
|
211
|
+
// -------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/** Loads the authoritative state from storage, caching on first read. */
|
|
214
|
+
private async loadState(): Promise<ChannelState> {
|
|
215
|
+
if (this.cached !== undefined) return this.cached;
|
|
216
|
+
const record = await this.ctx.storage.get<PersistedChannelState>(STATE_STORAGE_KEY);
|
|
217
|
+
if (record === undefined) {
|
|
218
|
+
const key = this.ctx.id.name ?? this.ctx.id.toString();
|
|
219
|
+
this.cached = createChannel({
|
|
220
|
+
name: key,
|
|
221
|
+
nameLower: key,
|
|
222
|
+
createdAt: Date.now(),
|
|
223
|
+
});
|
|
224
|
+
} else {
|
|
225
|
+
this.cached = deserializeChannel(record);
|
|
226
|
+
}
|
|
227
|
+
return this.cached;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Persists the authoritative state and refreshes the cache. */
|
|
231
|
+
private async persistState(state: ChannelState): Promise<void> {
|
|
232
|
+
this.cached = state;
|
|
233
|
+
await this.ctx.storage.put(STATE_STORAGE_KEY, serializeChannel(state));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Iterates roster entries, calling `deliver` on each member's
|
|
238
|
+
* ConnectionDO **in parallel** via `Promise.all`. Returns the list
|
|
239
|
+
* of ConnIds whose deliver reported `delivered: 0` (i.e. gone
|
|
240
|
+
* connections). The caller is responsible for applying the prune as
|
|
241
|
+
* a delta so the persisted state stays consistent with the cache.
|
|
242
|
+
*
|
|
243
|
+
* Parallel fan-out keeps a 1k-member broadcast within a single
|
|
244
|
+
* event-loop tick budget. Per-member deliver is independent (no
|
|
245
|
+
* shared mutable state), so parallelization is safe; the resulting
|
|
246
|
+
* prune set order is non-deterministic but the prune delta is
|
|
247
|
+
* order-independent (removes are idempotent).
|
|
248
|
+
*
|
|
249
|
+
* Defensive note: thrown RPCs are treated as "delivered: 0" — we do
|
|
250
|
+
* not want a transient failure to wedge the roster, but the safest
|
|
251
|
+
* interpretation of a thrown cross-DO call is "the member is gone".
|
|
252
|
+
*/
|
|
253
|
+
private async fanoutAndCollect(
|
|
254
|
+
state: ChannelState,
|
|
255
|
+
payload: Array<{ text: string }>,
|
|
256
|
+
except: ConnId | undefined,
|
|
257
|
+
): Promise<ConnId[]> {
|
|
258
|
+
const targets: ConnId[] = [];
|
|
259
|
+
for (const connId of state.members.keys()) {
|
|
260
|
+
if (except !== undefined && connId === except) continue;
|
|
261
|
+
targets.push(connId);
|
|
262
|
+
}
|
|
263
|
+
const results = await Promise.all(
|
|
264
|
+
targets.map(async (connId) => ({
|
|
265
|
+
connId,
|
|
266
|
+
delivered: await this.deliverToMember(connId, payload),
|
|
267
|
+
})),
|
|
268
|
+
);
|
|
269
|
+
return results.filter((r) => r.delivered === 0).map((r) => r.connId);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Calls `deliver` on the member's ConnectionDO. Returns the
|
|
274
|
+
* `delivered` count reported by the ConnectionDO, or `0` if the RPC
|
|
275
|
+
* throws (defensive: a totally missing DO instance still reports 0,
|
|
276
|
+
* which the caller interprets as "prune me").
|
|
277
|
+
*/
|
|
278
|
+
private async deliverToMember(connId: ConnId, lines: Array<{ text: string }>): Promise<number> {
|
|
279
|
+
try {
|
|
280
|
+
// `connId` is the hex form of the existing ConnectionDO id; use
|
|
281
|
+
// `idFromString` so the same DO instance is addressed (hashing it
|
|
282
|
+
// via `idFromName` would point at a different, empty DO).
|
|
283
|
+
const id = this.env.CONNECTION_DO.idFromString(connId);
|
|
284
|
+
const stub = this.env.CONNECTION_DO.get(id);
|
|
285
|
+
const result = await (stub as unknown as ConnectionDeliveryRpc).deliver(lines);
|
|
286
|
+
return result.delivered;
|
|
287
|
+
} catch {
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// -------------------------------------------------------------------------
|
|
293
|
+
// Test hooks (only invoked from `runInDurableObject` in tests)
|
|
294
|
+
// -------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
/** Drops the in-memory state cache, simulating wake-from-eviction. */
|
|
297
|
+
public __resetCache(): void {
|
|
298
|
+
this.cached = undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Returns the live cached state (without forcing a storage read).
|
|
303
|
+
* Exposed for tests that need to inspect Map/Set fields directly
|
|
304
|
+
* without going through the JSON-friendly DTO.
|
|
305
|
+
*/
|
|
306
|
+
public async __peekState(): Promise<ChannelState | undefined> {
|
|
307
|
+
return this.cached;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// (De)serialization — Set/Map ↔ array round-trip for state.storage.
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
function serializeChannel(chan: ChannelState): PersistedChannelState {
|
|
316
|
+
const out: PersistedChannelState = {
|
|
317
|
+
name: chan.name,
|
|
318
|
+
nameLower: chan.nameLower,
|
|
319
|
+
modes: { ...chan.modes },
|
|
320
|
+
banMasks: [...chan.banMasks],
|
|
321
|
+
members: [...chan.members.values()].map((e) => ({
|
|
322
|
+
conn: e.conn,
|
|
323
|
+
nick: e.nick,
|
|
324
|
+
op: e.op,
|
|
325
|
+
voice: e.voice,
|
|
326
|
+
})),
|
|
327
|
+
pendingInvites: [...chan.pendingInvites],
|
|
328
|
+
createdAt: chan.createdAt,
|
|
329
|
+
};
|
|
330
|
+
if (chan.topic !== undefined) out.topic = chan.topic;
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function deserializeChannel(record: PersistedChannelState): ChannelState {
|
|
335
|
+
const chan: ChannelState = {
|
|
336
|
+
name: record.name,
|
|
337
|
+
nameLower: record.nameLower,
|
|
338
|
+
modes: { ...record.modes },
|
|
339
|
+
banMasks: new Set<string>(record.banMasks),
|
|
340
|
+
members: new Map<ConnId, RosterEntry>(
|
|
341
|
+
record.members.map((e) => [
|
|
342
|
+
e.conn,
|
|
343
|
+
{ conn: e.conn, nick: e.nick, op: e.op ?? false, voice: e.voice ?? false },
|
|
344
|
+
]),
|
|
345
|
+
),
|
|
346
|
+
pendingInvites: new Set<string>(record.pendingInvites),
|
|
347
|
+
createdAt: record.createdAt,
|
|
348
|
+
};
|
|
349
|
+
if (record.topic !== undefined) chan.topic = record.topic;
|
|
350
|
+
return chan;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Builds a JSON-friendly {@link ChannelSnapshotDto} from a live
|
|
355
|
+
* {@link ChannelState}. Uses {@link toSnapshot} for the immutable
|
|
356
|
+
* view, then flattens the `Map`/`Set` fields to arrays.
|
|
357
|
+
*/
|
|
358
|
+
function toDto(chan: ChannelState): ChannelSnapshotDto {
|
|
359
|
+
// Defensively rebuild arrays; in some test-pool code paths the cached
|
|
360
|
+
// state's Set/Map fields may be missing if the channel was created
|
|
361
|
+
// via an unusual path (e.g. direct storage put without going through
|
|
362
|
+
// createChannel).
|
|
363
|
+
const dto: ChannelSnapshotDto = {
|
|
364
|
+
name: chan.name,
|
|
365
|
+
nameLower: chan.nameLower,
|
|
366
|
+
modes: { ...chan.modes },
|
|
367
|
+
banMasks: chan.banMasks ? [...chan.banMasks] : [],
|
|
368
|
+
members: chan.members ? [...chan.members.values()].map((e) => ({ ...e })) : [],
|
|
369
|
+
pendingInvites: chan.pendingInvites ? [...chan.pendingInvites] : [],
|
|
370
|
+
createdAt: chan.createdAt,
|
|
371
|
+
};
|
|
372
|
+
if (chan.topic !== undefined) dto.topic = chan.topic;
|
|
373
|
+
return dto;
|
|
374
|
+
}
|