serverless-ircd 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +34 -2
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/CHANGELOG.md +435 -0
- package/README.md +206 -27
- package/apps/aws-stack/README.md +37 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +106 -13
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +49 -3
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.toml +5 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +138 -28
- package/apps/local-cli/tests/e2e.test.ts +113 -29
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
- package/biome.json +1 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +110 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +15 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +66 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +40 -2
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +100 -4
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +238 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +6 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +185 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +38 -4
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +42 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +59 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +12 -9
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +72 -10
- package/packages/irc-core/src/effects.ts +41 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +568 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +57 -11
- package/packages/irc-core/src/protocol/outbound.ts +36 -4
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +120 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +129 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -0
- package/packages/irc-core/tests/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +699 -19
- package/packages/irc-server/src/dispatch.ts +109 -16
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +2523 -42
- package/packages/irc-server/tests/dispatch.test.ts +300 -2
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/index.ts +6 -0
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- package/tools/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -27,34 +27,57 @@
|
|
|
27
27
|
|
|
28
28
|
import {
|
|
29
29
|
type AccountStore,
|
|
30
|
+
type AwayStore,
|
|
30
31
|
type ChanName,
|
|
31
32
|
type ChannelState,
|
|
32
33
|
type Clock,
|
|
33
34
|
type ConnSnapshot,
|
|
34
35
|
type ConnectionState,
|
|
36
|
+
DEFAULT_MONITOR_LIMIT,
|
|
37
|
+
DEFAULT_MULTILINE_MAX_BYTES,
|
|
35
38
|
Effect,
|
|
36
39
|
type Effect as EffectType,
|
|
37
40
|
EmptyMotdProvider,
|
|
38
41
|
type IdFactory,
|
|
39
42
|
type IrcMessage,
|
|
43
|
+
LABELED_RESPONSE_CAP_NAME,
|
|
40
44
|
type Logger,
|
|
41
45
|
type MessageStore,
|
|
46
|
+
type MonitorStore,
|
|
42
47
|
type MotdProvider,
|
|
43
48
|
type MtlsIdentityProvider,
|
|
49
|
+
type MultilineEntry,
|
|
44
50
|
type NickHistoryStore,
|
|
45
51
|
NoopLoggerInstance,
|
|
46
52
|
Numerics,
|
|
47
53
|
type RawLine,
|
|
54
|
+
type ReadMarkerStore,
|
|
48
55
|
type ServerConfig,
|
|
56
|
+
type ServerStats,
|
|
57
|
+
applyLabelToEffects,
|
|
49
58
|
buildCtx,
|
|
59
|
+
buildMultilineFanout,
|
|
50
60
|
chathistoryReducer,
|
|
61
|
+
extractLabel,
|
|
62
|
+
finalizeMultilineBatch,
|
|
51
63
|
handleJoinZero,
|
|
64
|
+
hostmaskOf,
|
|
52
65
|
isChannelTarget,
|
|
53
66
|
isonReducer,
|
|
67
|
+
killReducer,
|
|
54
68
|
listReducer,
|
|
69
|
+
lusersReducer,
|
|
70
|
+
monitorReducer,
|
|
71
|
+
multilineByteLength,
|
|
55
72
|
parse,
|
|
73
|
+
recordMultilineMessage,
|
|
74
|
+
rehashLine,
|
|
75
|
+
rehashReducer,
|
|
76
|
+
statsReducer,
|
|
56
77
|
toSnapshot,
|
|
78
|
+
traceReducer,
|
|
57
79
|
userhostReducer,
|
|
80
|
+
wallopsReducer,
|
|
58
81
|
whoReducer,
|
|
59
82
|
whoisReducer,
|
|
60
83
|
whowasReducer,
|
|
@@ -100,6 +123,13 @@ export interface ConnectionActorOptions {
|
|
|
100
123
|
runtime: IrcRuntime;
|
|
101
124
|
channels: ActorChannelAccess;
|
|
102
125
|
serverConfig: ServerConfig;
|
|
126
|
+
/**
|
|
127
|
+
* Short label identifying the adapter's bound config source (e.g.
|
|
128
|
+
* `"KV"`, `"Secrets Manager"`, `"config file"`). Surfaced in the
|
|
129
|
+
* `382 RPL_REHASHING` reply's trailing text so the oper can see where the
|
|
130
|
+
* reload was attempted from. Defaults to `"server config"` when omitted.
|
|
131
|
+
*/
|
|
132
|
+
readonly configSource?: string;
|
|
103
133
|
clock: Clock;
|
|
104
134
|
ids: IdFactory;
|
|
105
135
|
motd?: MotdProvider;
|
|
@@ -138,6 +168,53 @@ export interface ConnectionActorOptions {
|
|
|
138
168
|
* preserves the no-store behaviour: `WHOWAS <nick>` emits `406`+`369`.
|
|
139
169
|
*/
|
|
140
170
|
readonly history?: NickHistoryStore;
|
|
171
|
+
/**
|
|
172
|
+
* Read-marker persistence source for IRCv3 `draft/read-marker` (absent
|
|
173
|
+
* when the deployment does not persist read markers). When bound,
|
|
174
|
+
* `ctx.readMarkers` is defined so a `+draft/read-marker=<msgid>` TAGMSG
|
|
175
|
+
* from an identified, cap-enabled client updates the per-`(account,
|
|
176
|
+
* channel)` marker, and SASL identify seeds the connection's
|
|
177
|
+
* `lastReadMarkers` from the stored values. Omitted (→ `ctx.readMarkers`
|
|
178
|
+
* undefined) preserves the session-only marker behaviour: the connection
|
|
179
|
+
* still tracks its own read position via chathistory playback, but the
|
|
180
|
+
* marker is not shared across connections or restored on reconnect.
|
|
181
|
+
*/
|
|
182
|
+
readonly readMarkers?: ReadMarkerStore;
|
|
183
|
+
/**
|
|
184
|
+
* Away-status persistence source for IRCv3 `draft/pre-away` (absent
|
|
185
|
+
* when the deployment does not persist away reasons). When bound,
|
|
186
|
+
* `ctx.away` is defined so a set/clear of `AWAY` from an identified
|
|
187
|
+
* connection mirrors the change into the per-`account` record, and
|
|
188
|
+
* SASL identify replays the stored reason onto the connection (emitting
|
|
189
|
+
* `306 RPL_NOWAWAY`) so a reconnect restores the user's away state.
|
|
190
|
+
* Omitted (→ `ctx.away` undefined) preserves the session-only
|
|
191
|
+
* behaviour: the connection still tracks its own `AWAY` but the reason
|
|
192
|
+
* is not shared across connections or restored on reconnect.
|
|
193
|
+
*/
|
|
194
|
+
readonly away?: AwayStore;
|
|
195
|
+
/**
|
|
196
|
+
* Monitor-subscription registry for the IRCv3 `MONITOR` command (absent
|
|
197
|
+
* when MONITOR push notifications are disabled). When bound, the actor
|
|
198
|
+
* syncs each connection's watchlist into the store and the store's
|
|
199
|
+
* online/offline hooks fan `730`/`731` lines out to subscribed watchers.
|
|
200
|
+
* Omitted (→ no push notifications) preserves the existing behaviour
|
|
201
|
+
* for callers that have not opted in: `MONITOR +nick` still works
|
|
202
|
+
* synchronously (the reducer resolves online/offline status against
|
|
203
|
+
* the store's `onlineNicks` snapshot) but no async push fires when a
|
|
204
|
+
* watched nick later connects or quits.
|
|
205
|
+
*/
|
|
206
|
+
readonly monitor?: MonitorStore;
|
|
207
|
+
/**
|
|
208
|
+
* Network-statistics source for the `LUSERS` and `STATS` commands
|
|
209
|
+
* (absent when no stats backend is wired). When bound, the actor
|
|
210
|
+
* awaits `stats.getStats()` once per LUSERS/STATS invocation and hands
|
|
211
|
+
* the resulting snapshot to the pure reducers — same pattern as
|
|
212
|
+
* `listChannels` → `listReducer`. Omitted (→ a zero-count fallback)
|
|
213
|
+
* preserves graceful behaviour for deployments/tests that have not
|
|
214
|
+
* opted in: `LUSERS` reports zero users/channels, `STATS u` reports
|
|
215
|
+
* zero uptime.
|
|
216
|
+
*/
|
|
217
|
+
readonly stats?: ServerStats;
|
|
141
218
|
/**
|
|
142
219
|
* Structured-logger port. When omitted the actor runs
|
|
143
220
|
* silently — observability is purely opt-in. When supplied, every
|
|
@@ -160,13 +237,24 @@ export interface ConnectionActorOptions {
|
|
|
160
237
|
* pipeline is shared unchanged across both transports.
|
|
161
238
|
*/
|
|
162
239
|
readonly transport?: Transport;
|
|
240
|
+
/**
|
|
241
|
+
* Transport-level TLS fact (PLAN §5 / docs/PlanExtensions.md "user mode
|
|
242
|
+
* `S`"). Set to `true` when the connection arrived over TLS — a `wss://`
|
|
243
|
+
* WebSocket or an `irc+tls://` raw TCP connection. The actor stamps it
|
|
244
|
+
* onto `state.secure` at construction; the registration reducer then
|
|
245
|
+
* surfaces it as read-only user mode `S` (visible in `221`/`WHOIS`,
|
|
246
|
+
* rejected by the `MODE` parser with `501`). Defaults to `false` so
|
|
247
|
+
* plain-transport tests and deployments keep the pre-TLS-mode behaviour.
|
|
248
|
+
*/
|
|
249
|
+
readonly secure?: boolean;
|
|
163
250
|
}
|
|
164
251
|
|
|
165
252
|
export class ConnectionActor {
|
|
166
253
|
private readonly state: ConnectionState;
|
|
167
254
|
private readonly runtime: IrcRuntime;
|
|
168
255
|
private readonly channels: ActorChannelAccess;
|
|
169
|
-
private
|
|
256
|
+
private serverConfig: ServerConfig;
|
|
257
|
+
private readonly configSource: string;
|
|
170
258
|
private readonly clock: Clock;
|
|
171
259
|
private readonly ids: IdFactory;
|
|
172
260
|
private readonly motd: MotdProvider;
|
|
@@ -174,6 +262,10 @@ export class ConnectionActor {
|
|
|
174
262
|
private readonly accounts: AccountStore | undefined;
|
|
175
263
|
private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
|
|
176
264
|
private readonly history: NickHistoryStore | undefined;
|
|
265
|
+
private readonly readMarkers: ReadMarkerStore | undefined;
|
|
266
|
+
private readonly away: AwayStore | undefined;
|
|
267
|
+
private readonly monitor: MonitorStore | undefined;
|
|
268
|
+
private readonly stats: ServerStats | undefined;
|
|
177
269
|
private readonly reducers: RoutedReducers;
|
|
178
270
|
/**
|
|
179
271
|
* Line-framing seam. Turns inbound input (a WS text frame or a raw TCP
|
|
@@ -194,12 +286,29 @@ export class ConnectionActor {
|
|
|
194
286
|
* that need a non-undefined id in test assertions.
|
|
195
287
|
*/
|
|
196
288
|
private readonly pinnedTraceId: string | undefined;
|
|
289
|
+
/**
|
|
290
|
+
* In-progress IRCv3 `draft/multiline` batch, if any. Set when the client
|
|
291
|
+
* sends `BATCH +id draft/multiline :<target>` (and the connection
|
|
292
|
+
* negotiated the cap), populated by each intervening `PRIVMSG`/`NOTICE`,
|
|
293
|
+
* and cleared when the closing `BATCH -id` is finalized. While set, the
|
|
294
|
+
* actor diverts `PRIVMSG`/`NOTICE` into the accumulator instead of
|
|
295
|
+
* running the normal channel/user reducers.
|
|
296
|
+
*/
|
|
297
|
+
private openMultilineBatch:
|
|
298
|
+
| {
|
|
299
|
+
readonly id: string;
|
|
300
|
+
readonly target: string;
|
|
301
|
+
command: 'PRIVMSG' | 'NOTICE';
|
|
302
|
+
readonly entries: MultilineEntry[];
|
|
303
|
+
}
|
|
304
|
+
| undefined;
|
|
197
305
|
|
|
198
306
|
constructor(opts: ConnectionActorOptions) {
|
|
199
307
|
this.state = opts.state;
|
|
200
308
|
this.runtime = opts.runtime;
|
|
201
309
|
this.channels = opts.channels;
|
|
202
310
|
this.serverConfig = opts.serverConfig;
|
|
311
|
+
this.configSource = opts.configSource ?? 'server config';
|
|
203
312
|
this.clock = opts.clock;
|
|
204
313
|
this.ids = opts.ids;
|
|
205
314
|
this.motd = opts.motd ?? EmptyMotdProvider;
|
|
@@ -207,6 +316,10 @@ export class ConnectionActor {
|
|
|
207
316
|
this.accounts = opts.accounts;
|
|
208
317
|
this.mtlsIdentity = opts.mtlsIdentity;
|
|
209
318
|
this.history = opts.history;
|
|
319
|
+
this.readMarkers = opts.readMarkers;
|
|
320
|
+
this.away = opts.away;
|
|
321
|
+
this.monitor = opts.monitor;
|
|
322
|
+
this.stats = opts.stats;
|
|
210
323
|
this.logger = opts.logger ?? NoopLoggerInstance;
|
|
211
324
|
this.pinnedTraceId = opts.traceId;
|
|
212
325
|
// Bind the connection id once so every record the actor emits carries
|
|
@@ -221,6 +334,15 @@ export class ConnectionActor {
|
|
|
221
334
|
// regardless of which authority owns the command's state.
|
|
222
335
|
this.reducers = buildRoutedReducers(opts.serverConfig);
|
|
223
336
|
this.transport = opts.transport ?? new WsTextFrameTransport();
|
|
337
|
+
// Surface the transport's TLS fact on the connection state so the
|
|
338
|
+
// registration reducer can stamp read-only user mode `S` from it.
|
|
339
|
+
// Only stamped when the caller explicitly asserts the fact; adapters
|
|
340
|
+
// that reconstruct the actor from persisted state (where `secure` was
|
|
341
|
+
// already written at construction time) omit the option so the
|
|
342
|
+
// deserialized value survives untouched.
|
|
343
|
+
if (opts.secure !== undefined) {
|
|
344
|
+
this.state.secure = opts.secure;
|
|
345
|
+
}
|
|
224
346
|
}
|
|
225
347
|
|
|
226
348
|
/**
|
|
@@ -285,12 +407,75 @@ export class ConnectionActor {
|
|
|
285
407
|
// the channel authority (e.g. CF ChannelDO). Done before `route` so the
|
|
286
408
|
// synchronous reducers observe up-to-date rosters / modes / topics.
|
|
287
409
|
await this.prefetchChannelState(msg);
|
|
288
|
-
const
|
|
410
|
+
const label = extractLabel(msg.tags);
|
|
411
|
+
const effects = await this.route(msg, label);
|
|
412
|
+
const labeled = this.applyLabel(effects, label);
|
|
413
|
+
await dispatch(labeled, this.runtime, this.dispatchOpts(log, traceId));
|
|
414
|
+
// After dispatch completes the bound MonitorStore (if any) may have
|
|
415
|
+
// enqueued `730`/`731` pushes for ReserveNick/ChangeNick/ReleaseNick
|
|
416
|
+
// transitions; turn each into one `Send` effect and dispatch them in a
|
|
417
|
+
// follow-up pass. Kept separate from the original effects array so the
|
|
418
|
+
// reducer's outputs continue to dedupe against themselves, not against
|
|
419
|
+
// fan-out sends.
|
|
420
|
+
await this.drainMonitorPushes(log, traceId);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Drains pending {@link MonitorPush}es from the bound {@link MonitorStore}
|
|
425
|
+
* and dispatches one `Send` effect per push. The watcher's nick is
|
|
426
|
+
* resolved via the live {@link ConnectionState} so the wire line carries
|
|
427
|
+
* the canonical spelling (`*` placeholder when the watcher is no longer
|
|
428
|
+
* registered — the runtime drops the send in that case anyway).
|
|
429
|
+
*/
|
|
430
|
+
private async drainMonitorPushes(log: Logger, traceId: string): Promise<void> {
|
|
431
|
+
if (this.monitor === undefined) return;
|
|
432
|
+
const pushes = this.monitor.drainPushes();
|
|
433
|
+
if (pushes.length === 0) return;
|
|
434
|
+
const effects: EffectType[] = [];
|
|
435
|
+
for (const push of pushes) {
|
|
436
|
+
const watcher = await this.runtime.getConnection(push.connId);
|
|
437
|
+
const watcherNick = watcher?.nick ?? '*';
|
|
438
|
+
const code = push.status === 'online' ? Numerics.RPL_MONONLINE : Numerics.RPL_MONOFFLINE;
|
|
439
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
440
|
+
effects.push(
|
|
441
|
+
Effect.send(push.connId, [
|
|
442
|
+
{ text: `:${this.serverConfig.serverName} ${codeStr} ${watcherNick} :${push.display}` },
|
|
443
|
+
]),
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
// `effects` has one entry per push; pushes was non-empty (early
|
|
447
|
+
// return above), so the second dispatch always has work to do.
|
|
289
448
|
await dispatch(effects, this.runtime, this.dispatchOpts(log, traceId));
|
|
290
449
|
}
|
|
291
450
|
|
|
292
451
|
private dispatchOpts(log: Logger, traceId: string): DispatchOptions {
|
|
293
|
-
return {
|
|
452
|
+
return {
|
|
453
|
+
logger: log,
|
|
454
|
+
traceId,
|
|
455
|
+
connectionId: this.state.id,
|
|
456
|
+
...(this.monitor !== undefined ? { monitor: this.monitor } : {}),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* IRCv3 `labeled-response` wrapping.
|
|
462
|
+
*
|
|
463
|
+
* When the inbound message carried a `+label` client tag AND the requester
|
|
464
|
+
* negotiated both `batch` and `labeled-response`, every `Send`-to-self the
|
|
465
|
+
* reducer emitted is gathered into one `BATCH +label labeled-response …
|
|
466
|
+
* -label` frame (and an empty batch is emitted when there was no reply).
|
|
467
|
+
* Otherwise the effect list is returned unchanged.
|
|
468
|
+
*
|
|
469
|
+
* The label is read back from {@link Ctx.label} (plumbed through `route`)
|
|
470
|
+
* so the correlation is attributable to the same per-invocation context the
|
|
471
|
+
* reducers observed.
|
|
472
|
+
*/
|
|
473
|
+
private applyLabel(effects: EffectType[], label: string | undefined): EffectType[] {
|
|
474
|
+
if (label === undefined) return effects;
|
|
475
|
+
if (!this.state.caps.has('batch') || !this.state.caps.has(LABELED_RESPONSE_CAP_NAME)) {
|
|
476
|
+
return effects;
|
|
477
|
+
}
|
|
478
|
+
return applyLabelToEffects(effects, { label, connId: this.state.id });
|
|
294
479
|
}
|
|
295
480
|
|
|
296
481
|
/**
|
|
@@ -306,7 +491,7 @@ export class ConnectionActor {
|
|
|
306
491
|
await Promise.all(targets.map((name) => refresh.call(this.channels, name)));
|
|
307
492
|
}
|
|
308
493
|
|
|
309
|
-
private async route(msg: IrcMessage): Promise<EffectType[]> {
|
|
494
|
+
private async route(msg: IrcMessage, label: string | undefined): Promise<EffectType[]> {
|
|
310
495
|
const ctx = buildCtx({
|
|
311
496
|
serverConfig: this.serverConfig,
|
|
312
497
|
clock: this.clock,
|
|
@@ -317,8 +502,21 @@ export class ConnectionActor {
|
|
|
317
502
|
...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
|
|
318
503
|
...(this.mtlsIdentity !== undefined ? { mtlsIdentity: this.mtlsIdentity } : {}),
|
|
319
504
|
...(this.history !== undefined ? { history: this.history } : {}),
|
|
505
|
+
...(this.readMarkers !== undefined ? { readMarkers: this.readMarkers } : {}),
|
|
506
|
+
...(this.away !== undefined ? { away: this.away } : {}),
|
|
507
|
+
...(label !== undefined ? { label } : {}),
|
|
320
508
|
});
|
|
321
509
|
|
|
510
|
+
// While a `draft/multiline` batch is open, intervening PRIVMSG/NOTICE
|
|
511
|
+
// lines are accumulated into the batch instead of being delivered
|
|
512
|
+
// immediately. The closing BATCH -id finalizes and fans the batch out.
|
|
513
|
+
if (
|
|
514
|
+
this.openMultilineBatch !== undefined &&
|
|
515
|
+
(msg.command === 'PRIVMSG' || msg.command === 'NOTICE')
|
|
516
|
+
) {
|
|
517
|
+
return this.accumulateMultilineLine(msg);
|
|
518
|
+
}
|
|
519
|
+
|
|
322
520
|
switch (msg.command) {
|
|
323
521
|
// -------- Connection-authority commands --------
|
|
324
522
|
case 'PING':
|
|
@@ -337,12 +535,18 @@ export class ConnectionActor {
|
|
|
337
535
|
return this.reducers.motd(this.state, msg, ctx).effects;
|
|
338
536
|
case 'OPER':
|
|
339
537
|
return this.reducers.oper(this.state, msg, ctx).effects;
|
|
538
|
+
case 'REHASH':
|
|
539
|
+
return this.routeRehash(msg, ctx);
|
|
340
540
|
case 'CAP':
|
|
341
541
|
return this.reducers.cap(this.state, msg, ctx).effects;
|
|
342
542
|
case 'AUTHENTICATE':
|
|
343
543
|
return this.reducers.authenticate(this.state, msg, ctx).effects;
|
|
544
|
+
case 'BATCH':
|
|
545
|
+
return this.routeBatch(msg, ctx);
|
|
344
546
|
case 'AWAY':
|
|
345
547
|
return this.reducers.away(this.state, msg, ctx).effects;
|
|
548
|
+
case 'SETNAME':
|
|
549
|
+
return this.reducers.setname(this.state, msg, ctx).effects;
|
|
346
550
|
|
|
347
551
|
// -------- Server-info query commands --------
|
|
348
552
|
case 'VERSION':
|
|
@@ -357,6 +561,8 @@ export class ConnectionActor {
|
|
|
357
561
|
return this.routeUserhost(msg, ctx);
|
|
358
562
|
case 'ISON':
|
|
359
563
|
return this.routeIson(msg, ctx);
|
|
564
|
+
case 'MONITOR':
|
|
565
|
+
return this.routeMonitor(msg, ctx);
|
|
360
566
|
|
|
361
567
|
// -------- Channel-authority commands --------
|
|
362
568
|
case 'JOIN':
|
|
@@ -382,6 +588,16 @@ export class ConnectionActor {
|
|
|
382
588
|
return this.routeWhois(msg, ctx);
|
|
383
589
|
case 'WHOWAS':
|
|
384
590
|
return this.routeWhowas(msg, ctx);
|
|
591
|
+
case 'KILL':
|
|
592
|
+
return this.routeKill(msg, ctx);
|
|
593
|
+
case 'TRACE':
|
|
594
|
+
return this.routeTrace(msg, ctx);
|
|
595
|
+
case 'WALLOPS':
|
|
596
|
+
return this.routeWallops(msg, ctx);
|
|
597
|
+
case 'LUSERS':
|
|
598
|
+
return this.routeLusers(msg, ctx);
|
|
599
|
+
case 'STATS':
|
|
600
|
+
return this.routeStats(msg, ctx);
|
|
385
601
|
case 'CHATHISTORY':
|
|
386
602
|
return this.routeChathistory(msg, ctx);
|
|
387
603
|
|
|
@@ -418,14 +634,17 @@ export class ConnectionActor {
|
|
|
418
634
|
default:
|
|
419
635
|
// Remaining standard IRC verbs not yet implemented. They fall through
|
|
420
636
|
// to a graceful `421 ERR_UNKNOWNCOMMAND`. Triage of remaining verbs:
|
|
421
|
-
// - Deferred (serverless-incompatible /
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
// -
|
|
428
|
-
// USERS.
|
|
637
|
+
// - Deferred (serverless-incompatible / needs new infra):
|
|
638
|
+
// (none — LUSERS/STATS are now implemented).
|
|
639
|
+
// - Deliberately dropped (S2S — PLAN non-goal):
|
|
640
|
+
// CONNECT, SQUIT, LINKS. There is no server mesh to connect,
|
|
641
|
+
// split, or list; these stay 421. Their reserved numerics
|
|
642
|
+
// (RPL_LINKS/RPL_ENDOFLINKS/ERR_CANTKILLSERVER) were removed.
|
|
643
|
+
// - Deliberately dropped (RFC 2812 obsolete, never widely
|
|
644
|
+
// implemented): SERVICE, SUMMON, USERS. These
|
|
645
|
+
// stay 421; their reserved numerics
|
|
646
|
+
// (RPL_YOURESERVICE/ERR_NOSUCHSERVICE/ERR_SUMMONDISABLED/
|
|
647
|
+
// ERR_USERSDISABLED) were removed.
|
|
429
648
|
return [this.unknownCommandEffect(msg.command)];
|
|
430
649
|
}
|
|
431
650
|
}
|
|
@@ -438,6 +657,40 @@ export class ConnectionActor {
|
|
|
438
657
|
return listReducer(snapshots, msg, ctx).effects;
|
|
439
658
|
}
|
|
440
659
|
|
|
660
|
+
/**
|
|
661
|
+
* Routes `REHASH` (oper-gated config reload). The pure reducer enforces the
|
|
662
|
+
* oper gate and emits `382 RPL_REHASHING`; when the gate passes the actor
|
|
663
|
+
* re-invokes the runtime's bound config loader and swaps its live
|
|
664
|
+
* `serverConfig` reference so subsequent commands observe the refreshed
|
|
665
|
+
* value (rotated oper creds, new MOTD source, etc.).
|
|
666
|
+
*
|
|
667
|
+
* On reload failure the previous config stays in effect and a graceful
|
|
668
|
+
* error-suffixed `382` follows — the oper is NOT disconnected (per the
|
|
669
|
+
* serverless reload contract: a bad store read must never sever a live
|
|
670
|
+
* connection).
|
|
671
|
+
*/
|
|
672
|
+
private async routeRehash(
|
|
673
|
+
msg: IrcMessage,
|
|
674
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
675
|
+
): Promise<EffectType[]> {
|
|
676
|
+
const result = rehashReducer(this.state, msg, ctx, this.configSource);
|
|
677
|
+
if (!result.shouldReload) {
|
|
678
|
+
return result.effects;
|
|
679
|
+
}
|
|
680
|
+
const effects = [...result.effects];
|
|
681
|
+
try {
|
|
682
|
+
this.serverConfig = await this.runtime.reloadConfig();
|
|
683
|
+
} catch (err) {
|
|
684
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
685
|
+
this.logger.warn('rehash.reload-failed', { reason });
|
|
686
|
+
const nick = this.state.nick ?? '*';
|
|
687
|
+
effects.push(
|
|
688
|
+
Effect.send(this.state.id, [rehashLine(ctx.serverName, nick, this.configSource, reason)]),
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
return effects;
|
|
692
|
+
}
|
|
693
|
+
|
|
441
694
|
private async routeWho(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): Promise<EffectType[]> {
|
|
442
695
|
const target = msg.params[0] ?? '';
|
|
443
696
|
if (target.length === 0) {
|
|
@@ -474,6 +727,78 @@ export class ConnectionActor {
|
|
|
474
727
|
return whoisReducer(target, channels, msg, ctx).effects;
|
|
475
728
|
}
|
|
476
729
|
|
|
730
|
+
/**
|
|
731
|
+
* Routes `KILL <nick> <comment>` to the kill reducer. The actor resolves
|
|
732
|
+
* the target via the nick registry + a live connection fetch (mirroring
|
|
733
|
+
* {@link routeWhois}); no channel is created as a side effect. The
|
|
734
|
+
* reducer performs the oper gate and, on success, emits a
|
|
735
|
+
* {@link DisconnectEffect} for the target — the runtime's `disconnect`
|
|
736
|
+
* closes the target transport and fans QUIT out to shared-channel peers
|
|
737
|
+
* (peer fanout is the runtime's responsibility, not the reducer's).
|
|
738
|
+
*/
|
|
739
|
+
private async routeKill(
|
|
740
|
+
msg: IrcMessage,
|
|
741
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
742
|
+
): Promise<EffectType[]> {
|
|
743
|
+
const targetNick = msg.params[0] ?? '';
|
|
744
|
+
let target: ConnectionState | undefined;
|
|
745
|
+
if (targetNick.length > 0) {
|
|
746
|
+
const connId = await this.runtime.lookupNick(targetNick);
|
|
747
|
+
if (connId !== null) {
|
|
748
|
+
target = (await this.runtime.getConnection(connId)) ?? undefined;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return killReducer(target, msg, ctx).effects;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Routes `TRACE [<target>]` to the trace reducer. Single-server
|
|
756
|
+
* deployment: when `<target>` is absent or equals the local server name
|
|
757
|
+
* the reducer emits only the `262 RPL_ENDOFTRACE` terminator; when
|
|
758
|
+
* `<target>` resolves to a nick the reducer emits `205`/`204` per-conn
|
|
759
|
+
* detail (oper requester only) plus `262`; any other `<target>` is
|
|
760
|
+
* treated as a non-local server and yields `402 ERR_NOSUCHSERVER`.
|
|
761
|
+
*
|
|
762
|
+
* The actor resolves the target via `lookupNick` + `getConnection`
|
|
763
|
+
* (mirroring {@link routeWhois}). The local-server check is
|
|
764
|
+
* case-insensitive against `serverConfig.serverName`. Multi-hop S2S
|
|
765
|
+
* tracing is a PLAN non-goal.
|
|
766
|
+
*/
|
|
767
|
+
private async routeTrace(
|
|
768
|
+
msg: IrcMessage,
|
|
769
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
770
|
+
): Promise<EffectType[]> {
|
|
771
|
+
const rawTarget = msg.params[0] ?? '';
|
|
772
|
+
if (rawTarget.length === 0) {
|
|
773
|
+
return traceReducer(undefined, msg, ctx).effects;
|
|
774
|
+
}
|
|
775
|
+
// Local server name (case-insensitive) → collapse to the no-target path.
|
|
776
|
+
if (rawTarget.toLowerCase() === ctx.serverName.toLowerCase()) {
|
|
777
|
+
return traceReducer(undefined, msg, ctx).effects;
|
|
778
|
+
}
|
|
779
|
+
// Try to resolve as a nick.
|
|
780
|
+
const connId = await this.runtime.lookupNick(rawTarget);
|
|
781
|
+
if (connId !== null) {
|
|
782
|
+
const target = (await this.runtime.getConnection(connId)) ?? undefined;
|
|
783
|
+
return traceReducer(target, msg, ctx).effects;
|
|
784
|
+
}
|
|
785
|
+
// Unresolved: treat as a remote server → 402 ERR_NOSUCHSERVER.
|
|
786
|
+
return traceReducer(undefined, msg, ctx, rawTarget).effects;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Routes `WALLOPS :<message>` to the wallops reducer. The pure reducer
|
|
791
|
+
* enforces the oper gate (`481`) and the non-empty-message check
|
|
792
|
+
* (`412`); on success it emits a single {@link BroadcastWallopsEffect}
|
|
793
|
+
* that the dispatch layer interprets via the runtime's
|
|
794
|
+
* `broadcastWallops` method — a global fan-out to every `+w`
|
|
795
|
+
* connection (not channel-scoped). The originator is always excluded
|
|
796
|
+
* (skip-self, matching NOTICE/PRIVMSG broadcast semantics).
|
|
797
|
+
*/
|
|
798
|
+
private routeWallops(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): EffectType[] {
|
|
799
|
+
return wallopsReducer(this.state, msg, ctx).effects;
|
|
800
|
+
}
|
|
801
|
+
|
|
477
802
|
/**
|
|
478
803
|
* Routes `WHOWAS <nick>{,<nick>} [<count>]` to the whowas reducer. The
|
|
479
804
|
* command is query-only: it reads the bound nick-history store
|
|
@@ -488,6 +813,70 @@ export class ConnectionActor {
|
|
|
488
813
|
return whowasReducer(this.history, msg, ctx).effects;
|
|
489
814
|
}
|
|
490
815
|
|
|
816
|
+
/**
|
|
817
|
+
* Routes `LUSERS [<mask> [<server>]]` to the lusers reducer. Awaits the
|
|
818
|
+
* bound {@link ServerStats} backend (or falls back to a zero-count
|
|
819
|
+
* snapshot when no backend is wired) and hands the result to the pure
|
|
820
|
+
* reducer, which emits the `251`–`255`/`265`/`266` numerics.
|
|
821
|
+
*
|
|
822
|
+
* Single-server deployment: a `<server>` argument that does not match
|
|
823
|
+
* the local server name (case-insensitive) is treated as a remote
|
|
824
|
+
* server and yields `402 ERR_NOSUCHSERVER`. S2S aggregation is a PLAN
|
|
825
|
+
* non-goal.
|
|
826
|
+
*/
|
|
827
|
+
private async routeLusers(
|
|
828
|
+
msg: IrcMessage,
|
|
829
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
830
|
+
): Promise<EffectType[]> {
|
|
831
|
+
const serverTarget = serverTargetOf(msg.params[1] ?? '', ctx.serverName);
|
|
832
|
+
const snapshot = await this.fetchStats();
|
|
833
|
+
return lusersReducer(snapshot, msg, ctx, serverTarget).effects;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Routes `STATS <query> [<server>]` to the stats reducer. Awaits the
|
|
838
|
+
* bound {@link ServerStats} backend (or zero-count fallback) and hands
|
|
839
|
+
* the result to the pure reducer. The reducer handles the implemented
|
|
840
|
+
* query letters (`u`, `l`), the unknown-letter charybdis-style empty
|
|
841
|
+
* body, and the `219` terminator; non-local `<server>` arguments
|
|
842
|
+
* produce `402 ERR_NOSUCHSERVER`.
|
|
843
|
+
*/
|
|
844
|
+
private async routeStats(
|
|
845
|
+
msg: IrcMessage,
|
|
846
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
847
|
+
): Promise<EffectType[]> {
|
|
848
|
+
const serverTarget = serverTargetOf(msg.params[1] ?? '', ctx.serverName);
|
|
849
|
+
const snapshot = await this.fetchStats();
|
|
850
|
+
return statsReducer(snapshot, msg, ctx, serverTarget).effects;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Fetches the current {@link ServerStatsSnapshot} from the bound backend.
|
|
855
|
+
* When no backend is wired, returns a zero-count snapshot so LUSERS/STATS
|
|
856
|
+
* still emit a well-formed reply rather than a 421/empty stream — the
|
|
857
|
+
* uptime anchor defaults to the actor's wall clock so `STATS u` reports
|
|
858
|
+
* zero uptime instead of a negative duration.
|
|
859
|
+
*/
|
|
860
|
+
private async fetchStats(): Promise<import('@serverless-ircd/irc-core').ServerStatsSnapshot> {
|
|
861
|
+
if (this.stats !== undefined) {
|
|
862
|
+
return this.stats.getStats();
|
|
863
|
+
}
|
|
864
|
+
const now = this.clock.now();
|
|
865
|
+
return {
|
|
866
|
+
users: 0,
|
|
867
|
+
invisible: 0,
|
|
868
|
+
opers: 0,
|
|
869
|
+
unknownConnections: 0,
|
|
870
|
+
channels: 0,
|
|
871
|
+
servers: 1,
|
|
872
|
+
localConns: 0,
|
|
873
|
+
globalConns: 0,
|
|
874
|
+
maxLocalConns: 0,
|
|
875
|
+
maxGlobalConns: 0,
|
|
876
|
+
uptimeStartedAt: now,
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
|
|
491
880
|
/**
|
|
492
881
|
* Routes `USERHOST <nick>{ <nick>}` to the userhost reducer. Resolves each
|
|
493
882
|
* requested nick via the nick registry + connection lookup, passing the
|
|
@@ -500,10 +889,8 @@ export class ConnectionActor {
|
|
|
500
889
|
ctx: ReturnType<typeof buildCtx>,
|
|
501
890
|
): Promise<EffectType[]> {
|
|
502
891
|
const cap = ctx.serverConfig.maxTargetsPerCommand;
|
|
503
|
-
const nicks = msg.params.slice(0, cap);
|
|
504
892
|
const resolved = new Map<string, ConnSnapshot | null>();
|
|
505
|
-
for (const nick of
|
|
506
|
-
if (nick.length === 0) continue;
|
|
893
|
+
for (const nick of msg.params.slice(0, cap)) {
|
|
507
894
|
const connId = await this.runtime.lookupNick(nick);
|
|
508
895
|
if (connId === null) {
|
|
509
896
|
resolved.set(nick, null);
|
|
@@ -526,13 +913,14 @@ export class ConnectionActor {
|
|
|
526
913
|
ctx: ReturnType<typeof buildCtx>,
|
|
527
914
|
): Promise<EffectType[]> {
|
|
528
915
|
const cap = ctx.serverConfig.maxTargetsPerCommand;
|
|
529
|
-
const nicks = msg.params.slice(0, cap);
|
|
530
916
|
const online = new Map<string, string>();
|
|
531
|
-
for (const nick of
|
|
532
|
-
if (nick.length === 0) continue;
|
|
917
|
+
for (const nick of msg.params.slice(0, cap)) {
|
|
533
918
|
const connId = await this.runtime.lookupNick(nick);
|
|
534
919
|
if (connId === null) continue;
|
|
535
920
|
const conn = await this.runtime.getConnection(connId);
|
|
921
|
+
// `conn` can be null in the race where lookupNick resolved but the
|
|
922
|
+
// connection has since torn down (the nick map lags the connection
|
|
923
|
+
// map). Skip those — they are no longer online.
|
|
536
924
|
if (conn?.nick !== undefined) {
|
|
537
925
|
online.set(conn.nick.toLowerCase(), conn.nick);
|
|
538
926
|
}
|
|
@@ -540,6 +928,74 @@ export class ConnectionActor {
|
|
|
540
928
|
return isonReducer(online, msg, ctx).effects;
|
|
541
929
|
}
|
|
542
930
|
|
|
931
|
+
/**
|
|
932
|
+
* Routes `MONITOR <sub> [<nicks>]` to the monitor reducer. Resolves the
|
|
933
|
+
* currently-online nicks (folded → display) via the nick registry so the
|
|
934
|
+
* reducer can emit `730`/`731` synchronously. The reducer also updates
|
|
935
|
+
* `state.monitorList`; the actor then forwards adds/removes to the bound
|
|
936
|
+
* {@link MonitorStore} (when present) so future online/offline
|
|
937
|
+
* transitions push `730`/`731` to the watcher.
|
|
938
|
+
*/
|
|
939
|
+
private async routeMonitor(
|
|
940
|
+
msg: IrcMessage,
|
|
941
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
942
|
+
): Promise<EffectType[]> {
|
|
943
|
+
// Resolve every nick the reducer might consult — the union of the
|
|
944
|
+
// existing watchlist and any newly-requested targets — so the reducer
|
|
945
|
+
// can emit `730`/`731` synchronously without an O(N) lookupNick call
|
|
946
|
+
// per nick inside its own loop. Mirrors the routeIson pattern.
|
|
947
|
+
const requested =
|
|
948
|
+
msg.params[1] !== undefined
|
|
949
|
+
? msg.params[1]
|
|
950
|
+
.split(',')
|
|
951
|
+
.map((s) => s.trim())
|
|
952
|
+
.filter((s) => s.length > 0)
|
|
953
|
+
: [];
|
|
954
|
+
const candidates = new Set<string>(ctx.connection.monitorList ?? []);
|
|
955
|
+
for (const n of requested) candidates.add(n);
|
|
956
|
+
const online = await this.resolveOnlineMap(candidates);
|
|
957
|
+
|
|
958
|
+
const before = new Set(ctx.connection.monitorList ?? []);
|
|
959
|
+
const effects = monitorReducer(online, DEFAULT_MONITOR_LIMIT, msg, ctx).effects;
|
|
960
|
+
// Sync the watchlist delta (if any) into the bound MonitorStore so
|
|
961
|
+
// later ReserveNick / ReleaseNick dispatches can fire push effects.
|
|
962
|
+
if (this.monitor !== undefined) {
|
|
963
|
+
const after = ctx.connection.monitorList ?? new Set<string>();
|
|
964
|
+
this.monitor.sync(ctx.connId, before, after);
|
|
965
|
+
// Remove the connection's subscription entirely when its watchlist
|
|
966
|
+
// becomes empty — keeps the reverse index tidy.
|
|
967
|
+
if (after.size === 0) {
|
|
968
|
+
this.monitor.unsubscribe(ctx.connId);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return effects;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Resolves each candidate nick via the runtime's nick registry and
|
|
976
|
+
* returns a folded-keyed → display-spelling map of the online subset.
|
|
977
|
+
* Mirrors {@link routeIson}'s per-nick lookup loop.
|
|
978
|
+
*/
|
|
979
|
+
private async resolveOnlineMap(candidates: Iterable<string>): Promise<Map<string, string>> {
|
|
980
|
+
const online = new Map<string, string>();
|
|
981
|
+
for (const nick of candidates) {
|
|
982
|
+
const connId = await this.runtime.lookupNick(nick);
|
|
983
|
+
if (connId === null) continue;
|
|
984
|
+
const conn = await this.runtime.getConnection(connId);
|
|
985
|
+
// `conn` can be null in the race where lookupNick resolved but the
|
|
986
|
+
// connection has since torn down (the nick map lags the connection
|
|
987
|
+
// map). When the connection is alive its nick is defined (the
|
|
988
|
+
// registry only resolves nicks that have been reserved, which sets
|
|
989
|
+
// `nick` on the state), so the optional chain is purely defensive
|
|
990
|
+
// against that race.
|
|
991
|
+
const display = conn?.nick;
|
|
992
|
+
if (display !== undefined) {
|
|
993
|
+
online.set(display.toLowerCase(), display);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return online;
|
|
997
|
+
}
|
|
998
|
+
|
|
543
999
|
/**
|
|
544
1000
|
* Routes `CHATHISTORY <sub> <target> [<args>...]` to the chathistory
|
|
545
1001
|
* reducer. The command is query-only and must NOT create a channel as a
|
|
@@ -553,6 +1009,213 @@ export class ConnectionActor {
|
|
|
553
1009
|
* Cap-gating, param validation, and batch framing all live in the pure
|
|
554
1010
|
* reducer — this method only resolves the channel and delegates.
|
|
555
1011
|
*/
|
|
1012
|
+
/**
|
|
1013
|
+
* Routes an inbound `BATCH +id …` / `BATCH -id` frame. The only
|
|
1014
|
+
* client-initiated batch type this server honours is `draft/multiline`
|
|
1015
|
+
* (and only when the connection negotiated both `batch` and
|
|
1016
|
+
* `draft/multiline`); every other form falls through to
|
|
1017
|
+
* {@link unknownCommandEffect} (`421`), preserving the pre-batch behaviour.
|
|
1018
|
+
*
|
|
1019
|
+
* On `+id draft/multiline :<target>` the actor opens the accumulator
|
|
1020
|
+
* ({@link openMultilineBatch}); intervening PRIVMSG/NOTICE frames populate
|
|
1021
|
+
* it ({@link accumulateMultilineLine}); `BATCH -id` finalizes the batch
|
|
1022
|
+
* via the pure {@link finalizeMultilineBatch} reducer (channel target) or
|
|
1023
|
+
* the actor-side per-recipient path (nick target) and returns its fanout
|
|
1024
|
+
* effects.
|
|
1025
|
+
*/
|
|
1026
|
+
private async routeBatch(
|
|
1027
|
+
msg: IrcMessage,
|
|
1028
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
1029
|
+
): Promise<EffectType[]> {
|
|
1030
|
+
const marker = msg.params[0] ?? '';
|
|
1031
|
+
// `+<id>` opens a batch; `-<id>` closes one. Anything else is malformed.
|
|
1032
|
+
if (marker.startsWith('+')) {
|
|
1033
|
+
return this.beginMultilineBatch(marker.slice(1), msg, ctx);
|
|
1034
|
+
}
|
|
1035
|
+
if (marker.startsWith('-')) {
|
|
1036
|
+
return this.closeMultilineBatch(marker.slice(1), ctx);
|
|
1037
|
+
}
|
|
1038
|
+
return [this.unknownCommandEffect(msg.command)];
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Opens a `draft/multiline` accumulator. Requires the connection to have
|
|
1043
|
+
* negotiated both `batch` and `draft/multiline`; otherwise the open is
|
|
1044
|
+
* rejected as an unknown command (the client cannot legally send the
|
|
1045
|
+
* frame without the caps).
|
|
1046
|
+
*/
|
|
1047
|
+
private beginMultilineBatch(
|
|
1048
|
+
id: string,
|
|
1049
|
+
msg: IrcMessage,
|
|
1050
|
+
_ctx: ReturnType<typeof buildCtx>,
|
|
1051
|
+
): EffectType[] {
|
|
1052
|
+
// Cap gate: the client must have negotiated both caps to use the frame.
|
|
1053
|
+
if (!this.state.caps.has('batch') || !this.state.caps.has('draft/multiline')) {
|
|
1054
|
+
return [this.unknownCommandEffect(msg.command)];
|
|
1055
|
+
}
|
|
1056
|
+
// Only the `draft/multiline` batch type is supported client→server.
|
|
1057
|
+
const type = msg.params[1];
|
|
1058
|
+
const target = msg.params[2];
|
|
1059
|
+
if (type !== 'draft/multiline' || target === undefined || target.length === 0) {
|
|
1060
|
+
return [this.unknownCommandEffect(msg.command)];
|
|
1061
|
+
}
|
|
1062
|
+
// Reject re-open while another batch is mid-flight: the IRCv3 batch spec
|
|
1063
|
+
// forbids reusing a ref id while one is open. Surface as 421 so the
|
|
1064
|
+
// client does not silently lose the first batch's lines.
|
|
1065
|
+
if (this.openMultilineBatch !== undefined) {
|
|
1066
|
+
return [this.unknownCommandEffect(msg.command)];
|
|
1067
|
+
}
|
|
1068
|
+
this.openMultilineBatch = { id, target, command: 'PRIVMSG', entries: [] };
|
|
1069
|
+
// The open marker itself produces no server→client reply.
|
|
1070
|
+
return [];
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Accumulates one intervening PRIVMSG/NOTICE line into the open
|
|
1075
|
+
* `draft/multiline` batch. The inner line's target must match the batch
|
|
1076
|
+
* target and (after the first line) its command must match too; a
|
|
1077
|
+
* mismatch aborts the batch with a `FAIL` and clears the accumulator.
|
|
1078
|
+
*/
|
|
1079
|
+
private accumulateMultilineLine(msg: IrcMessage): EffectType[] {
|
|
1080
|
+
const open = this.openMultilineBatch;
|
|
1081
|
+
if (open === undefined) return [];
|
|
1082
|
+
const target = msg.params[0];
|
|
1083
|
+
const text = msg.params[1] ?? '';
|
|
1084
|
+
// Target mismatch: the spec requires every inner line to address the
|
|
1085
|
+
// batch's target. Abort the batch.
|
|
1086
|
+
if (target !== open.target) {
|
|
1087
|
+
this.openMultilineBatch = undefined;
|
|
1088
|
+
return [
|
|
1089
|
+
Effect.send(this.state.id, [
|
|
1090
|
+
{
|
|
1091
|
+
text: `:${this.serverConfig.serverName} FAIL BATCH MULTILINE_INVALID_TARGET ${open.target} :Inner line target does not match the batch target`,
|
|
1092
|
+
},
|
|
1093
|
+
]),
|
|
1094
|
+
];
|
|
1095
|
+
}
|
|
1096
|
+
const command = msg.command as 'PRIVMSG' | 'NOTICE';
|
|
1097
|
+
// The first inner line pins the command; subsequent lines must match.
|
|
1098
|
+
if (open.entries.length === 0) {
|
|
1099
|
+
open.command = command;
|
|
1100
|
+
} else if (command !== open.command) {
|
|
1101
|
+
this.openMultilineBatch = undefined;
|
|
1102
|
+
return [
|
|
1103
|
+
Effect.send(this.state.id, [
|
|
1104
|
+
{
|
|
1105
|
+
text: `:${this.serverConfig.serverName} FAIL BATCH MULTILINE_MIXED_COMMANDS ${open.target} :Multi-line batch mixes PRIVMSG and NOTICE`,
|
|
1106
|
+
},
|
|
1107
|
+
]),
|
|
1108
|
+
];
|
|
1109
|
+
}
|
|
1110
|
+
open.entries.push({ text });
|
|
1111
|
+
return [];
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Finalizes an open `draft/multiline` batch on `BATCH -id`. Channel targets
|
|
1116
|
+
* delegate to the pure {@link finalizeMultilineBatch} reducer (cap-split
|
|
1117
|
+
* Broadcast). Nick targets use the actor-side per-recipient path
|
|
1118
|
+
* ({@link closeMultilineBatchToNick}) because the recipient is a single
|
|
1119
|
+
* connection whose caps must be resolved before delivery. An unknown /
|
|
1120
|
+
* mismatched id, or a close with no batch open, falls through to `421`.
|
|
1121
|
+
*/
|
|
1122
|
+
private async closeMultilineBatch(
|
|
1123
|
+
id: string,
|
|
1124
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
1125
|
+
): Promise<EffectType[]> {
|
|
1126
|
+
const open = this.openMultilineBatch;
|
|
1127
|
+
if (open === undefined || open.id !== id) {
|
|
1128
|
+
return [this.unknownCommandEffect('BATCH')];
|
|
1129
|
+
}
|
|
1130
|
+
this.openMultilineBatch = undefined;
|
|
1131
|
+
const maxBytes = this.serverConfig.multilineMaxBytes ?? DEFAULT_MULTILINE_MAX_BYTES;
|
|
1132
|
+
if (isChannelTarget(open.target)) {
|
|
1133
|
+
const result = finalizeMultilineBatch(open.target, open.command, open.entries, maxBytes, ctx);
|
|
1134
|
+
return result.effects;
|
|
1135
|
+
}
|
|
1136
|
+
return this.closeMultilineBatchToNick(open, maxBytes, ctx);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Finalizes a nick-target multi-line batch. Resolves the single recipient
|
|
1141
|
+
* via the nick registry, inspects their caps, and delivers the appropriate
|
|
1142
|
+
* wire form: the batched replay to `draft/multiline`+`batch` peers, the
|
|
1143
|
+
* tagged joined PRIVMSG to `message-tags` peers, or the bare joined
|
|
1144
|
+
* PRIVMSG to legacy peers. PRIVMSG surfaces `401 ERR_NOSUCHNICK` when the
|
|
1145
|
+
* recipient is offline; NOTICE is silently dropped (RFC: NOTICE MUST NOT
|
|
1146
|
+
* generate numerics).
|
|
1147
|
+
*/
|
|
1148
|
+
private async closeMultilineBatchToNick(
|
|
1149
|
+
open: {
|
|
1150
|
+
readonly id: string;
|
|
1151
|
+
readonly target: string;
|
|
1152
|
+
readonly command: 'PRIVMSG' | 'NOTICE';
|
|
1153
|
+
readonly entries: readonly MultilineEntry[];
|
|
1154
|
+
},
|
|
1155
|
+
maxBytes: number,
|
|
1156
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
1157
|
+
): Promise<EffectType[]> {
|
|
1158
|
+
if (open.entries.length === 0) {
|
|
1159
|
+
return [];
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const bytes = multilineByteLength(open.entries);
|
|
1163
|
+
if (bytes > maxBytes) {
|
|
1164
|
+
return [
|
|
1165
|
+
Effect.send(ctx.connId, [
|
|
1166
|
+
{
|
|
1167
|
+
text: `:${ctx.serverName} FAIL BATCH MULTILINE_MAX_BYTES ${open.target} :Multi-line batch exceeds the ${maxBytes} byte limit`,
|
|
1168
|
+
},
|
|
1169
|
+
]),
|
|
1170
|
+
];
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const recipientConnId = await this.runtime.lookupNick(open.target);
|
|
1174
|
+
if (recipientConnId === null) {
|
|
1175
|
+
return this.nickNotFoundEffects(open, ctx);
|
|
1176
|
+
}
|
|
1177
|
+
const recipient = await this.runtime.getConnection(recipientConnId);
|
|
1178
|
+
if (recipient === null) {
|
|
1179
|
+
return this.nickNotFoundEffects(open, ctx);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const hostmask = hostmaskOf(this.state) ?? '?';
|
|
1183
|
+
const batchId = ctx.ids.batchId();
|
|
1184
|
+
const fanout = buildMultilineFanout(open.target, open.command, open.entries, hostmask, batchId);
|
|
1185
|
+
const msgid = ctx.ids.nonce();
|
|
1186
|
+
recordMultilineMessage(ctx, open.target, open.command, fanout.joined, msgid);
|
|
1187
|
+
|
|
1188
|
+
let lines: RawLine[];
|
|
1189
|
+
if (recipient.caps.has('draft/multiline') && recipient.caps.has('batch')) {
|
|
1190
|
+
lines = [...fanout.batchedReplay];
|
|
1191
|
+
} else if (recipient.caps.has('message-tags')) {
|
|
1192
|
+
lines = [fanout.taggedJoined];
|
|
1193
|
+
} else {
|
|
1194
|
+
lines = [fanout.bareJoined];
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
return [Effect.send(recipientConnId, lines)];
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Builds the `401 ERR_NOSUCHNICK` effect for an offline nick-target batch
|
|
1202
|
+
* close. PRIVMSG surfaces the error; NOTICE returns nothing (the RFC
|
|
1203
|
+
* forbids numerics in response to NOTICE).
|
|
1204
|
+
*/
|
|
1205
|
+
private nickNotFoundEffects(
|
|
1206
|
+
open: { readonly target: string; readonly command: 'PRIVMSG' | 'NOTICE' },
|
|
1207
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
1208
|
+
): EffectType[] {
|
|
1209
|
+
if (open.command === 'NOTICE') return [];
|
|
1210
|
+
const nick = ctx.connection.nick ?? '*';
|
|
1211
|
+
const code = Numerics.ERR_NOSUCHNICK.toString().padStart(3, '0');
|
|
1212
|
+
return [
|
|
1213
|
+
Effect.send(ctx.connId, [
|
|
1214
|
+
{ text: `:${ctx.serverName} ${code} ${nick} ${open.target} :No such nick/channel` },
|
|
1215
|
+
]),
|
|
1216
|
+
];
|
|
1217
|
+
}
|
|
1218
|
+
|
|
556
1219
|
private async routeChathistory(
|
|
557
1220
|
msg: IrcMessage,
|
|
558
1221
|
ctx: ReturnType<typeof buildCtx>,
|
|
@@ -699,6 +1362,20 @@ function splitCsv(param: string): string[] {
|
|
|
699
1362
|
return param.split(',').filter((p) => p.length > 0);
|
|
700
1363
|
}
|
|
701
1364
|
|
|
1365
|
+
/**
|
|
1366
|
+
* Resolves the `<server>` parameter shared by `LUSERS` / `STATS` (and any
|
|
1367
|
+
* future single-server-aware query verb). Returns `undefined` when the
|
|
1368
|
+
* argument is absent OR names the local server (case-insensitive) — the
|
|
1369
|
+
* caller then proceeds with the local-server code path. Returns the raw
|
|
1370
|
+
* argument verbatim when it names a non-local server, which the reducer
|
|
1371
|
+
* surfaces as `402 ERR_NOSUCHSERVER`.
|
|
1372
|
+
*/
|
|
1373
|
+
function serverTargetOf(rawServer: string, localServerName: string): string | undefined {
|
|
1374
|
+
if (rawServer.length === 0) return undefined;
|
|
1375
|
+
if (rawServer.toLowerCase() === localServerName.toLowerCase()) return undefined;
|
|
1376
|
+
return rawServer;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
702
1379
|
/**
|
|
703
1380
|
* Extracts the channel names a reducer will read for the supplied command.
|
|
704
1381
|
* Used by `prefetchChannelState` to refresh only the channels the upcoming
|
|
@@ -708,8 +1385,11 @@ function splitCsv(param: string): string[] {
|
|
|
708
1385
|
* Commands not listed here either have no channel target (PING, NICK, …)
|
|
709
1386
|
* or read channel membership from `ctx.connection.joinedChannels` directly
|
|
710
1387
|
* (QUIT), so they don't need a prefetch.
|
|
1388
|
+
*
|
|
1389
|
+
* Exported so the per-command dispatch table can be unit-tested without
|
|
1390
|
+
* standing up a full {@link ConnectionActor} + runtime.
|
|
711
1391
|
*/
|
|
712
|
-
function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
|
|
1392
|
+
export function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
|
|
713
1393
|
const cmd = msg.command;
|
|
714
1394
|
switch (cmd) {
|
|
715
1395
|
case 'JOIN':
|