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,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-connection actor: bytes → framed messages → reducer → dispatch.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 + §9 — the actor owns one connection's lifecycle. It receives
|
|
5
|
+
* WebSocket text frames (one IRC message per frame, or N `\r\n`-joined
|
|
6
|
+
* messages per frame), splits them into lines, parses each line, routes by
|
|
7
|
+
* command to the right reducer per the location-of-authority table, and
|
|
8
|
+
* `dispatch`es the resulting `Effect[]` against the bound {@link IrcRuntime}.
|
|
9
|
+
*
|
|
10
|
+
* Invariants (acceptance criteria):
|
|
11
|
+
* - One message per frame → one reducer invocation.
|
|
12
|
+
* - Joined frame with N `\r\n` → N invocations, in order.
|
|
13
|
+
* - Parser failures produce `421 ERR_UNKNOWNCOMMAND` and processing
|
|
14
|
+
* continues (the actor never crashes on a single bad line).
|
|
15
|
+
*
|
|
16
|
+
* Location-of-authority:
|
|
17
|
+
* - Connection commands (PING/NICK/USER/…/QUIT) run against the actor's
|
|
18
|
+
* own {@link ConnectionState}.
|
|
19
|
+
* - Channel commands (JOIN/PART/…) run against the authoritative
|
|
20
|
+
* {@link ChannelState} the runtime exposes via {@link ActorChannelAccess}.
|
|
21
|
+
* - Target-dependent commands (PRIVMSG/NOTICE/MODE) are routed by the
|
|
22
|
+
* first param's shape (`#`/`&` prefix → channel).
|
|
23
|
+
*
|
|
24
|
+
* The actor is transport-agnostic by design: it processes text frames. The
|
|
25
|
+
* CF / AWS adapters wrap it with platform-specific socket glue.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
type ChanName,
|
|
30
|
+
type ChannelState,
|
|
31
|
+
type Clock,
|
|
32
|
+
type ConnectionState,
|
|
33
|
+
Effect,
|
|
34
|
+
type Effect as EffectType,
|
|
35
|
+
EmptyMotdProvider,
|
|
36
|
+
type IdFactory,
|
|
37
|
+
type IrcMessage,
|
|
38
|
+
type MotdProvider,
|
|
39
|
+
Numerics,
|
|
40
|
+
type RawLine,
|
|
41
|
+
type ServerConfig,
|
|
42
|
+
buildCtx,
|
|
43
|
+
capReducer,
|
|
44
|
+
channelModeReducer,
|
|
45
|
+
handleJoinZero,
|
|
46
|
+
inviteReducer,
|
|
47
|
+
isChannelTarget,
|
|
48
|
+
joinReducer,
|
|
49
|
+
kickReducer,
|
|
50
|
+
listReducer,
|
|
51
|
+
motdReducer,
|
|
52
|
+
namesReducer,
|
|
53
|
+
nickReducer,
|
|
54
|
+
noticeChannelReducer,
|
|
55
|
+
noticeUserReducer,
|
|
56
|
+
parse,
|
|
57
|
+
partReducer,
|
|
58
|
+
passReducer,
|
|
59
|
+
pingReducer,
|
|
60
|
+
pongReducer,
|
|
61
|
+
privmsgChannelReducer,
|
|
62
|
+
privmsgUserReducer,
|
|
63
|
+
quitReducer,
|
|
64
|
+
topicReducer,
|
|
65
|
+
userModeReducer,
|
|
66
|
+
userReducer,
|
|
67
|
+
whoReducer,
|
|
68
|
+
whoisReducer,
|
|
69
|
+
} from '@serverless-ircd/irc-core';
|
|
70
|
+
import { dispatch } from './dispatch.js';
|
|
71
|
+
import type { IrcRuntime } from './runtime.js';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Channel-state access for the actor.
|
|
75
|
+
*
|
|
76
|
+
* For the in-memory runtime this is `runtime.getOrCreateChannel`. CF / AWS
|
|
77
|
+
* adapters will not use this actor — they forward channel-authority
|
|
78
|
+
* commands to a ChannelDO / Lambda — so this interface only needs to cover
|
|
79
|
+
* the local-execution case.
|
|
80
|
+
*
|
|
81
|
+
* `refreshChannel` is optional: in-memory runtimes omit it (their state is
|
|
82
|
+
* already authoritative). Distributed runtimes implement it so the actor
|
|
83
|
+
* can pull the current roster / modes / topic from the channel authority
|
|
84
|
+
* before running a reducer that needs to observe them (NAMES, MODE, KICK,
|
|
85
|
+
* cross-connection JOIN checks, …). The actor awaits `refreshChannel`
|
|
86
|
+
* once per parsed message, before routing.
|
|
87
|
+
*/
|
|
88
|
+
export interface ActorChannelAccess {
|
|
89
|
+
getOrCreateChannel(name: ChanName): ChannelState;
|
|
90
|
+
refreshChannel?(name: ChanName): Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ConnectionActorOptions {
|
|
94
|
+
state: ConnectionState;
|
|
95
|
+
runtime: IrcRuntime;
|
|
96
|
+
channels: ActorChannelAccess;
|
|
97
|
+
serverConfig: ServerConfig;
|
|
98
|
+
clock: Clock;
|
|
99
|
+
ids: IdFactory;
|
|
100
|
+
motd?: MotdProvider;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class ConnectionActor {
|
|
104
|
+
private readonly state: ConnectionState;
|
|
105
|
+
private readonly runtime: IrcRuntime;
|
|
106
|
+
private readonly channels: ActorChannelAccess;
|
|
107
|
+
private readonly serverConfig: ServerConfig;
|
|
108
|
+
private readonly clock: Clock;
|
|
109
|
+
private readonly ids: IdFactory;
|
|
110
|
+
private readonly motd: MotdProvider;
|
|
111
|
+
|
|
112
|
+
constructor(opts: ConnectionActorOptions) {
|
|
113
|
+
this.state = opts.state;
|
|
114
|
+
this.runtime = opts.runtime;
|
|
115
|
+
this.channels = opts.channels;
|
|
116
|
+
this.serverConfig = opts.serverConfig;
|
|
117
|
+
this.clock = opts.clock;
|
|
118
|
+
this.ids = opts.ids;
|
|
119
|
+
this.motd = opts.motd ?? EmptyMotdProvider;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Processes one WebSocket text frame. Splits on `\r\n` (tolerating bare
|
|
124
|
+
* `\n`) and dispatches each non-empty line in order. A parse failure on
|
|
125
|
+
* one line emits a `421` and processing continues with the next line.
|
|
126
|
+
*/
|
|
127
|
+
async receiveTextFrame(text: string): Promise<void> {
|
|
128
|
+
for (const line of splitFrameLines(text)) {
|
|
129
|
+
if (line.length === 0) continue;
|
|
130
|
+
await this.processLine(line);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async processLine(line: string): Promise<void> {
|
|
135
|
+
let msg: IrcMessage;
|
|
136
|
+
let parseFailed = false;
|
|
137
|
+
try {
|
|
138
|
+
msg = parse(line);
|
|
139
|
+
} catch {
|
|
140
|
+
// `parse` only throws `IrcParseError` (every throw inside the parser
|
|
141
|
+
// constructs one); the untyped catch is the strict-mode narrowing
|
|
142
|
+
// artifact for `unknown`. Every parse failure becomes a `421` so the
|
|
143
|
+
// actor never crashes on a single bad line (acceptance criteria).
|
|
144
|
+
parseFailed = true;
|
|
145
|
+
msg = { tags: {}, command: '', params: [] };
|
|
146
|
+
}
|
|
147
|
+
if (parseFailed) {
|
|
148
|
+
const token = firstToken(line);
|
|
149
|
+
await dispatch([this.unknownCommandEffect(token)], this.runtime);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Pull authoritative channel state for any channel the upcoming reducer
|
|
153
|
+
// will read. In-memory runtimes no-op; distributed runtimes fetch from
|
|
154
|
+
// the channel authority (e.g. CF ChannelDO). Done before `route` so the
|
|
155
|
+
// synchronous reducers observe up-to-date rosters / modes / topics.
|
|
156
|
+
await this.prefetchChannelState(msg);
|
|
157
|
+
const effects = await this.route(msg);
|
|
158
|
+
await dispatch(effects, this.runtime);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Awaits {@link ActorChannelAccess.refreshChannel} for every channel
|
|
163
|
+
* referenced by `msg`, if the bound channel-access implementation opted
|
|
164
|
+
* in. No-op for in-memory runtimes.
|
|
165
|
+
*/
|
|
166
|
+
private async prefetchChannelState(msg: IrcMessage): Promise<void> {
|
|
167
|
+
const refresh = this.channels.refreshChannel;
|
|
168
|
+
if (refresh === undefined) return;
|
|
169
|
+
const targets = channelTargetsForMessage(msg);
|
|
170
|
+
if (targets.length === 0) return;
|
|
171
|
+
await Promise.all(targets.map((name) => refresh.call(this.channels, name)));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async route(msg: IrcMessage): Promise<EffectType[]> {
|
|
175
|
+
const ctx = buildCtx({
|
|
176
|
+
serverConfig: this.serverConfig,
|
|
177
|
+
clock: this.clock,
|
|
178
|
+
ids: this.ids,
|
|
179
|
+
motd: this.motd,
|
|
180
|
+
connection: this.state,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
switch (msg.command) {
|
|
184
|
+
// -------- Connection-authority commands --------
|
|
185
|
+
case 'PING':
|
|
186
|
+
return pingReducer(this.state, msg, ctx).effects;
|
|
187
|
+
case 'PONG':
|
|
188
|
+
return pongReducer(this.state, msg, ctx).effects;
|
|
189
|
+
case 'NICK':
|
|
190
|
+
return nickReducer(this.state, msg, ctx).effects;
|
|
191
|
+
case 'USER':
|
|
192
|
+
return userReducer(this.state, msg, ctx).effects;
|
|
193
|
+
case 'PASS':
|
|
194
|
+
return passReducer(this.state, msg, ctx).effects;
|
|
195
|
+
case 'QUIT':
|
|
196
|
+
return quitReducer(this.state, msg, ctx).effects;
|
|
197
|
+
case 'MOTD':
|
|
198
|
+
return motdReducer(this.state, msg, ctx).effects;
|
|
199
|
+
case 'CAP':
|
|
200
|
+
return capReducer(this.state, msg, ctx).effects;
|
|
201
|
+
|
|
202
|
+
// -------- Channel-authority commands --------
|
|
203
|
+
case 'JOIN':
|
|
204
|
+
return this.routeJoin(msg, ctx);
|
|
205
|
+
case 'PART':
|
|
206
|
+
return this.routeMultiChannel(
|
|
207
|
+
msg,
|
|
208
|
+
(chan, singleParamMsg) => partReducer(chan, singleParamMsg, ctx).effects,
|
|
209
|
+
);
|
|
210
|
+
case 'TOPIC':
|
|
211
|
+
return this.routeSingleChannel(msg, (chan) => topicReducer(chan, msg, ctx).effects);
|
|
212
|
+
case 'NAMES':
|
|
213
|
+
return this.routeSingleChannel(msg, (chan) => namesReducer(chan, msg, ctx).effects);
|
|
214
|
+
case 'KICK':
|
|
215
|
+
return this.routeSingleChannel(msg, (chan) => kickReducer(chan, msg, ctx).effects);
|
|
216
|
+
case 'INVITE':
|
|
217
|
+
return this.routeSingleChannel(msg, (chan) => inviteReducer(chan, msg, ctx).effects);
|
|
218
|
+
case 'LIST':
|
|
219
|
+
return this.routeList(msg, ctx);
|
|
220
|
+
case 'WHO':
|
|
221
|
+
return this.routeWho(msg, ctx);
|
|
222
|
+
case 'WHOIS':
|
|
223
|
+
return this.routeWhois(msg, ctx);
|
|
224
|
+
|
|
225
|
+
// -------- Target-dependent commands --------
|
|
226
|
+
case 'PRIVMSG':
|
|
227
|
+
return this.routeMessageTarget(msg, privmsgChannelReducer, privmsgUserReducer, ctx);
|
|
228
|
+
case 'NOTICE':
|
|
229
|
+
return this.routeMessageTarget(msg, noticeChannelReducer, noticeUserReducer, ctx);
|
|
230
|
+
case 'MODE':
|
|
231
|
+
return isChannelTarget(msg.params[0] ?? '')
|
|
232
|
+
? this.routeSingleChannel(msg, (chan) => channelModeReducer(chan, msg, ctx).effects)
|
|
233
|
+
: userModeReducer(this.state, msg, ctx).effects;
|
|
234
|
+
|
|
235
|
+
default:
|
|
236
|
+
return [this.unknownCommandEffect(msg.command)];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async routeList(
|
|
241
|
+
msg: IrcMessage,
|
|
242
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
243
|
+
): Promise<EffectType[]> {
|
|
244
|
+
const snapshots = await this.runtime.listChannels();
|
|
245
|
+
return listReducer(snapshots, msg, ctx).effects;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private async routeWho(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): Promise<EffectType[]> {
|
|
249
|
+
const target = msg.params[0] ?? '';
|
|
250
|
+
if (target.length === 0) {
|
|
251
|
+
return whoReducer(this.channels.getOrCreateChannel(''), new Map(), msg, ctx).effects;
|
|
252
|
+
}
|
|
253
|
+
const chan = this.channels.getOrCreateChannel(target);
|
|
254
|
+
const connections = await this.runtime.getChannelConnections(target);
|
|
255
|
+
return whoReducer(chan, connections, msg, ctx).effects;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Resolves the WHOIS target via the nick registry, fetches its live
|
|
260
|
+
* {@link ConnectionState} from the runtime, and gathers the
|
|
261
|
+
* {@link ChannelState}s the target belongs to so the reducer can build
|
|
262
|
+
* the `319` channel list. Unknown / no-arg cases pass `undefined` to
|
|
263
|
+
* the reducer, which emits `401`+`318` (with a nick) or just `318`.
|
|
264
|
+
*/
|
|
265
|
+
private async routeWhois(
|
|
266
|
+
msg: IrcMessage,
|
|
267
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
268
|
+
): Promise<EffectType[]> {
|
|
269
|
+
const targetNick = msg.params[0] ?? '';
|
|
270
|
+
let target: ConnectionState | undefined;
|
|
271
|
+
if (targetNick.length > 0) {
|
|
272
|
+
const connId = await this.runtime.lookupNick(targetNick);
|
|
273
|
+
if (connId !== null) {
|
|
274
|
+
target = (await this.runtime.getConnection(connId)) ?? undefined;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const channels =
|
|
278
|
+
target === undefined
|
|
279
|
+
? []
|
|
280
|
+
: Array.from(target.joinedChannels, (name) => this.channels.getOrCreateChannel(name));
|
|
281
|
+
return whoisReducer(target, channels, msg, ctx).effects;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private routeJoin(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): EffectType[] {
|
|
285
|
+
if (msg.params[0] === '0') {
|
|
286
|
+
return handleJoinZero(ctx);
|
|
287
|
+
}
|
|
288
|
+
const effects: EffectType[] = [];
|
|
289
|
+
const names = splitCsv(msg.params[0] ?? '');
|
|
290
|
+
const keys = splitCsv(msg.params[1] ?? '');
|
|
291
|
+
if (names.length === 0) {
|
|
292
|
+
// No channel argument: defer to the reducer so it emits the proper
|
|
293
|
+
// 461 ERR_NEEDMOREPARAMS rather than the actor silently doing nothing.
|
|
294
|
+
const chan = this.channels.getOrCreateChannel('');
|
|
295
|
+
return joinReducer(chan, msg, ctx).effects;
|
|
296
|
+
}
|
|
297
|
+
for (const [i, name] of names.entries()) {
|
|
298
|
+
const chan = this.channels.getOrCreateChannel(name);
|
|
299
|
+
const key = keys[i];
|
|
300
|
+
const singleParamMsg: IrcMessage =
|
|
301
|
+
key === undefined ? { ...msg, params: [name] } : { ...msg, params: [name, key] };
|
|
302
|
+
effects.push(...joinReducer(chan, singleParamMsg, ctx).effects);
|
|
303
|
+
}
|
|
304
|
+
return effects;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private routeSingleChannel(
|
|
308
|
+
msg: IrcMessage,
|
|
309
|
+
run: (chan: ChannelState) => EffectType[],
|
|
310
|
+
): EffectType[] {
|
|
311
|
+
const name = msg.params[0] ?? '';
|
|
312
|
+
if (name.length === 0) {
|
|
313
|
+
return [this.unknownCommandEffect(msg.command)];
|
|
314
|
+
}
|
|
315
|
+
const chan = this.channels.getOrCreateChannel(name);
|
|
316
|
+
return run(chan);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private routeMultiChannel(
|
|
320
|
+
msg: IrcMessage,
|
|
321
|
+
run: (chan: ChannelState, singleParamMsg: IrcMessage) => EffectType[],
|
|
322
|
+
): EffectType[] {
|
|
323
|
+
const effects: EffectType[] = [];
|
|
324
|
+
const rawParam = msg.params[0] ?? '';
|
|
325
|
+
const names = splitCsv(rawParam);
|
|
326
|
+
const tail = msg.params.slice(1);
|
|
327
|
+
// No channel argument: defer to the reducer so it can emit the proper
|
|
328
|
+
// 461 ERR_NEEDMOREPARAMS rather than the actor silently doing nothing.
|
|
329
|
+
if (names.length === 0) {
|
|
330
|
+
// No channel argument: defer to the reducer so it can emit the proper
|
|
331
|
+
// 461 ERR_NEEDMOREPARAMS rather than the actor silently doing nothing.
|
|
332
|
+
return run(this.channels.getOrCreateChannel(''), msg);
|
|
333
|
+
}
|
|
334
|
+
for (const name of names) {
|
|
335
|
+
const chan = this.channels.getOrCreateChannel(name);
|
|
336
|
+
const singleParamMsg: IrcMessage = { ...msg, params: [name, ...tail] };
|
|
337
|
+
effects.push(...run(chan, singleParamMsg));
|
|
338
|
+
}
|
|
339
|
+
return effects;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private routeMessageTarget(
|
|
343
|
+
msg: IrcMessage,
|
|
344
|
+
channelReducer: (
|
|
345
|
+
chan: ChannelState,
|
|
346
|
+
msg: IrcMessage,
|
|
347
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
348
|
+
) => {
|
|
349
|
+
effects: EffectType[];
|
|
350
|
+
},
|
|
351
|
+
userReducer: (
|
|
352
|
+
state: ConnectionState,
|
|
353
|
+
msg: IrcMessage,
|
|
354
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
355
|
+
) => {
|
|
356
|
+
effects: EffectType[];
|
|
357
|
+
},
|
|
358
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
359
|
+
): EffectType[] {
|
|
360
|
+
const effects: EffectType[] = [];
|
|
361
|
+
const targets = splitCsv(msg.params[0] ?? '');
|
|
362
|
+
const tail = msg.params.slice(1);
|
|
363
|
+
if (targets.length === 0) {
|
|
364
|
+
// No target argument: defer to the user-target reducer so it emits the
|
|
365
|
+
// proper 411 ERR_NORECIPIENT rather than silently doing nothing.
|
|
366
|
+
return userReducer(this.state, msg, ctx).effects;
|
|
367
|
+
}
|
|
368
|
+
for (const target of targets) {
|
|
369
|
+
const singleParamMsg: IrcMessage = { ...msg, params: [target, ...tail] };
|
|
370
|
+
if (isChannelTarget(target)) {
|
|
371
|
+
const chan = this.channels.getOrCreateChannel(target);
|
|
372
|
+
effects.push(...channelReducer(chan, singleParamMsg, ctx).effects);
|
|
373
|
+
} else {
|
|
374
|
+
effects.push(...userReducer(this.state, singleParamMsg, ctx).effects);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return effects;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private unknownCommandEffect(command: string): EffectType {
|
|
381
|
+
const nick = this.state.nick ?? '*';
|
|
382
|
+
const code = Numerics.ERR_UNKNOWNCOMMAND.toString().padStart(3, '0');
|
|
383
|
+
const line: RawLine = {
|
|
384
|
+
text:
|
|
385
|
+
command.length === 0
|
|
386
|
+
? `:${this.serverConfig.serverName} ${code} ${nick} :Unknown command`
|
|
387
|
+
: `:${this.serverConfig.serverName} ${code} ${nick} ${command} :Unknown command`,
|
|
388
|
+
};
|
|
389
|
+
return Effect.send(this.state.id, [line]);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Splits a WebSocket text frame into IRC lines. Tolerates `\r\n`, bare `\n`,
|
|
395
|
+
* and missing trailing terminators. Returns each line bare (no CRLF).
|
|
396
|
+
*/
|
|
397
|
+
export function splitFrameLines(text: string): string[] {
|
|
398
|
+
if (text.length === 0) return [];
|
|
399
|
+
return text.split(/\r?\n/u);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Returns the first whitespace-delimited token of `line`, or `''`. */
|
|
403
|
+
function firstToken(line: string): string {
|
|
404
|
+
const m = line.match(/^\S+/u);
|
|
405
|
+
if (m === null) return '';
|
|
406
|
+
// `m[0]` is `string | undefined` per `noUncheckedIndexedAccess`, but a
|
|
407
|
+
// successful match always has `m[0]` defined (regex contract).
|
|
408
|
+
return m[0] as string;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Splits a comma-separated IRC parameter list. */
|
|
412
|
+
function splitCsv(param: string): string[] {
|
|
413
|
+
if (param.length === 0) return [];
|
|
414
|
+
return param.split(',').filter((p) => p.length > 0);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Extracts the channel names a reducer will read for the supplied command.
|
|
419
|
+
* Used by `prefetchChannelState` to refresh only the channels the upcoming
|
|
420
|
+
* route will touch. Returns the bare names as they appear in the message
|
|
421
|
+
* (no lowercasing — `refreshChannel` will lowercase on its own).
|
|
422
|
+
*
|
|
423
|
+
* Commands not listed here either have no channel target (PING, NICK, …)
|
|
424
|
+
* or read channel membership from `ctx.connection.joinedChannels` directly
|
|
425
|
+
* (QUIT), so they don't need a prefetch.
|
|
426
|
+
*/
|
|
427
|
+
function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
|
|
428
|
+
const cmd = msg.command;
|
|
429
|
+
switch (cmd) {
|
|
430
|
+
case 'JOIN':
|
|
431
|
+
// `JOIN 0` parts every joined channel; the reducer reads
|
|
432
|
+
// joinedChannels off the connection directly, no prefetch needed.
|
|
433
|
+
if (msg.params[0] === '0') return [];
|
|
434
|
+
return splitCsv(msg.params[0] ?? '').filter(isChannelTarget) as ChanName[];
|
|
435
|
+
case 'PART':
|
|
436
|
+
case 'TOPIC':
|
|
437
|
+
case 'NAMES':
|
|
438
|
+
case 'KICK':
|
|
439
|
+
case 'MODE':
|
|
440
|
+
case 'PRIVMSG':
|
|
441
|
+
case 'NOTICE':
|
|
442
|
+
return splitCsv(msg.params[0] ?? '').filter(isChannelTarget) as ChanName[];
|
|
443
|
+
case 'INVITE':
|
|
444
|
+
// INVITE <nick> <channel> — the channel is the second param.
|
|
445
|
+
return splitCsv(msg.params[1] ?? '').filter(isChannelTarget) as ChanName[];
|
|
446
|
+
default:
|
|
447
|
+
return [];
|
|
448
|
+
}
|
|
449
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* `dispatch(effects, runtime)` interprets each {@link Effect} against the
|
|
5
|
+
* bound {@link IrcRuntime} port. The pure core emits the effect list; only
|
|
6
|
+
* this function awaits. Invariants:
|
|
7
|
+
*
|
|
8
|
+
* - Effects are processed in array order (the test named "ordering").
|
|
9
|
+
* - Consecutive identical Send effects coalesce into one runtime call
|
|
10
|
+
* (the test named "dedup").
|
|
11
|
+
* - The first runtime rejection propagates and stops processing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { BroadcastEffect, Effect, RawLine } from '@serverless-ircd/irc-core';
|
|
15
|
+
import type { IrcRuntime } from './runtime.js';
|
|
16
|
+
|
|
17
|
+
/** Discriminated-union handler: each Effect tag maps to one runtime call. */
|
|
18
|
+
type Handler<E extends Effect> = (effect: E, runtime: IrcRuntime) => Promise<void>;
|
|
19
|
+
|
|
20
|
+
const handlers: { [K in Effect['tag']]: Handler<Extract<Effect, { tag: K }>> } = {
|
|
21
|
+
Send: (e, r) => r.send(e.to, e.lines),
|
|
22
|
+
Broadcast: (e, r) => broadcast(e, r),
|
|
23
|
+
Disconnect: (e, r) => r.disconnect(e.conn, e.reason),
|
|
24
|
+
SendToNick: (e, r) => r.sendToNick(e.nick, e.sender, e.lines, e.notFoundLines),
|
|
25
|
+
ReserveNick: (e, r) => r.reserveNick(e.nick, e.conn).then(noop),
|
|
26
|
+
ChangeNick: (e, r) => r.changeNick(e.conn, e.oldNick, e.newNick).then(noop),
|
|
27
|
+
ReleaseNick: (e, r) => r.releaseNick(e.nick),
|
|
28
|
+
ApplyChannelDelta: (e, r) => r.applyChannelDelta(e.chan, e.delta),
|
|
29
|
+
LookupNick: (e, r) => r.lookupNick(e.nick).then(noop),
|
|
30
|
+
GetConnectionInfo: (e, r) => r.getConnectionInfo(e.conn).then(noop),
|
|
31
|
+
GetChannelSnapshot: (e, r) => r.getChannelSnapshot(e.chan).then(noop),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function noop(): void {}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns true iff two effects are interchangeable — used to coalesce
|
|
38
|
+
* consecutive duplicates so a reducer's accidental double-send is a no-op.
|
|
39
|
+
*
|
|
40
|
+
* Only `Send` is deduped today: it's the only effect where exact-equality
|
|
41
|
+
* coalescing is always safe (same bytes to the same target). Broadcasts
|
|
42
|
+
* carry `except?` and channel-delta semantics that make blind coalescing
|
|
43
|
+
* risky; we leave them alone.
|
|
44
|
+
*/
|
|
45
|
+
function isSameSend(a: Effect, b: Effect): boolean {
|
|
46
|
+
if (a.tag !== 'Send' || b.tag !== 'Send') return false;
|
|
47
|
+
if (a.to !== b.to) return false;
|
|
48
|
+
return linesEqual(a.lines, b.lines);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function linesEqual(a: RawLine[], b: RawLine[]): boolean {
|
|
52
|
+
if (a.length !== b.length) return false;
|
|
53
|
+
for (let i = 0; i < a.length; i++) {
|
|
54
|
+
const x = a[i];
|
|
55
|
+
const y = b[i];
|
|
56
|
+
if (x === undefined || y === undefined || x.text !== y.text) return false;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Interprets `effects` in order against `runtime`. Returns when every
|
|
63
|
+
* effect has been performed; the first rejection propagates.
|
|
64
|
+
*/
|
|
65
|
+
export async function dispatch(effects: Effect[], runtime: IrcRuntime): Promise<void> {
|
|
66
|
+
let previous: Effect | undefined;
|
|
67
|
+
for (const effect of effects) {
|
|
68
|
+
if (previous !== undefined && isSameSend(previous, effect)) {
|
|
69
|
+
previous = effect;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
await handle(effect, runtime);
|
|
73
|
+
previous = effect;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function handle(effect: Effect, runtime: IrcRuntime): Promise<void> {
|
|
78
|
+
// The dispatch table is keyed by tag; the cast is the standard TS pattern
|
|
79
|
+
// for exhaustive-union dispatch.
|
|
80
|
+
const handler = handlers[effect.tag] as Handler<Effect>;
|
|
81
|
+
await handler(effect, runtime);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Cap-aware Broadcast fanout.
|
|
86
|
+
*
|
|
87
|
+
* When `cap` is unset the reducer has no per-recipient preference, so we
|
|
88
|
+
* take the runtime's `broadcast` fast path. When `cap` is set dispatch
|
|
89
|
+
* routes per-recipient instead, consulting each member's advertised caps:
|
|
90
|
+
*
|
|
91
|
+
* - `cap` + `capLines` (split mode): cap-enabled members receive
|
|
92
|
+
* `capLines`, everyone else receives `lines`.
|
|
93
|
+
* - `cap` without `capLines` (cap-only mode): `lines` go ONLY to
|
|
94
|
+
* cap-enabled members; the rest receive nothing.
|
|
95
|
+
*
|
|
96
|
+
* `except` is always honored. A member whose connection info is unavailable
|
|
97
|
+
* (the connection has gone) is treated as lacking the cap.
|
|
98
|
+
*/
|
|
99
|
+
async function broadcast(e: BroadcastEffect, r: IrcRuntime): Promise<void> {
|
|
100
|
+
if (e.cap === undefined) {
|
|
101
|
+
await r.broadcast(e.chan, e.lines, e.except);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const snapshot = await r.getChannelSnapshot(e.chan);
|
|
106
|
+
if (snapshot === null) return;
|
|
107
|
+
|
|
108
|
+
for (const connId of snapshot.members.keys()) {
|
|
109
|
+
if (connId === e.except) continue;
|
|
110
|
+
|
|
111
|
+
const info = await r.getConnectionInfo(connId);
|
|
112
|
+
const hasCap = info?.caps.has(e.cap) ?? false;
|
|
113
|
+
|
|
114
|
+
if (e.capLines !== undefined) {
|
|
115
|
+
await r.send(connId, hasCap ? e.capLines : e.lines);
|
|
116
|
+
} else if (hasCap) {
|
|
117
|
+
await r.send(connId, e.lines);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @serverless-ircd/irc-server
|
|
3
|
+
*
|
|
4
|
+
* Orchestration layer: defines the {@link IrcRuntime} port, the {@link dispatch}
|
|
5
|
+
* interpreter that runs an `Effect[]` against it, and the {@link ConnectionActor}
|
|
6
|
+
* that turns a WebSocket text frame into reducer invocations + dispatch.
|
|
7
|
+
*/
|
|
8
|
+
export type { IrcRuntime } from './runtime.js';
|
|
9
|
+
export { dispatch } from './dispatch.js';
|
|
10
|
+
export { ConnectionActor, splitFrameLines } from './actor.js';
|
|
11
|
+
export type { ActorChannelAccess, ConnectionActorOptions } from './actor.js';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The {@link IrcRuntime} port.
|
|
3
|
+
*
|
|
4
|
+
* Every adapter (in-memory, CF, AWS) implements this interface. The pure
|
|
5
|
+
* core's `Effect[]` is interpreted against it by {@link dispatch}.
|
|
6
|
+
*
|
|
7
|
+
* Method-for-method compatibility with the {@link Effect} union is enforced
|
|
8
|
+
* by `dispatch.ts`'s handler table.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
ChanName,
|
|
13
|
+
ChanSnapshot,
|
|
14
|
+
ChannelDelta,
|
|
15
|
+
ConnId,
|
|
16
|
+
ConnSnapshot,
|
|
17
|
+
ConnectionState,
|
|
18
|
+
Nick,
|
|
19
|
+
RawLine,
|
|
20
|
+
} from '@serverless-ircd/irc-core';
|
|
21
|
+
|
|
22
|
+
export interface IrcRuntime {
|
|
23
|
+
// transport
|
|
24
|
+
send(to: ConnId, lines: RawLine[]): Promise<void>;
|
|
25
|
+
broadcast(chan: ChanName, lines: RawLine[], except?: ConnId): Promise<void>;
|
|
26
|
+
disconnect(conn: ConnId, reason?: string): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Routes `lines` to whichever connection currently owns `nick`. If the
|
|
29
|
+
* nick is offline and `notFoundLines` is supplied, those lines are sent
|
|
30
|
+
* to `sender` (PRIVMSG's `401`; NOTICE omits the fallback entirely).
|
|
31
|
+
*/
|
|
32
|
+
sendToNick(
|
|
33
|
+
nick: Nick,
|
|
34
|
+
sender: ConnId,
|
|
35
|
+
lines: RawLine[],
|
|
36
|
+
notFoundLines?: RawLine[],
|
|
37
|
+
): Promise<void>;
|
|
38
|
+
|
|
39
|
+
// nickname registry
|
|
40
|
+
reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }>;
|
|
41
|
+
changeNick(conn: ConnId, oldNick: Nick, newNick: Nick): Promise<boolean>;
|
|
42
|
+
releaseNick(nick: Nick): Promise<void>;
|
|
43
|
+
|
|
44
|
+
// channel ownership
|
|
45
|
+
applyChannelDelta(chan: ChanName, delta: ChannelDelta): Promise<void>;
|
|
46
|
+
|
|
47
|
+
// lookups (NAMES, WHOIS, routing)
|
|
48
|
+
lookupNick(nick: Nick): Promise<ConnId | null>;
|
|
49
|
+
getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null>;
|
|
50
|
+
/**
|
|
51
|
+
* Returns the live {@link ConnectionState} for `conn`, or `null` if the
|
|
52
|
+
* connection is unknown / offline. Used by reducers that need a peer's
|
|
53
|
+
* full state (e.g. WHOIS) — the returned object is the authority-owned
|
|
54
|
+
* reference for in-memory runtimes and a materialized view for
|
|
55
|
+
* distributed runtimes.
|
|
56
|
+
*/
|
|
57
|
+
getConnection(conn: ConnId): Promise<ConnectionState | null>;
|
|
58
|
+
getChannelSnapshot(chan: ChanName): Promise<ChanSnapshot | null>;
|
|
59
|
+
getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>>;
|
|
60
|
+
listChannels(): Promise<ChanSnapshot[]>;
|
|
61
|
+
}
|