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,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `AWAY` command + IRCv3 `away-notify` capability.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: AWAY runs in the Connection entity. The
|
|
5
|
+
* reducer's `state` argument is the connection's authoritative
|
|
6
|
+
* {@link ConnectionState}; the away reason is stored as `state.away`.
|
|
7
|
+
*
|
|
8
|
+
* Wire format:
|
|
9
|
+
* - `AWAY [:<reason>]` — sets away when `<reason>` is non-empty; clears
|
|
10
|
+
* it when absent or empty.
|
|
11
|
+
* - `:server 305 <nick> :You are no longer marked as being away` (RPL_UNAWAY)
|
|
12
|
+
* - `:server 306 <nick> :You have been marked as being away` (RPL_NOWAWAY)
|
|
13
|
+
*
|
|
14
|
+
* IRCv3 away-notify: connections with the `away-notify` cap negotiated who
|
|
15
|
+
* share a channel with the away user receive an `AWAY` message broadcast:
|
|
16
|
+
* - `:<hostmask> AWAY :<reason>` on set
|
|
17
|
+
* - `:<hostmask> AWAY` on unset (no trailing param)
|
|
18
|
+
*
|
|
19
|
+
* The broadcast is cap-only (non-cap peers receive nothing) so legacy
|
|
20
|
+
* clients are unaffected.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Effect } from '../effects.js';
|
|
24
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
25
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
26
|
+
import type { ChanName } from '../state/channel.js';
|
|
27
|
+
import { hostmaskOf } from '../state/connection.js';
|
|
28
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
29
|
+
import type { Reducer } from '../types.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Handles `AWAY [:<reason>]`.
|
|
33
|
+
*
|
|
34
|
+
* Validation order: missing/empty param → unset (305); otherwise set (306).
|
|
35
|
+
* away-notify cap peers in shared channels receive a cap-only broadcast.
|
|
36
|
+
*/
|
|
37
|
+
export const awayReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
38
|
+
state.lastSeen = ctx.clock.now();
|
|
39
|
+
|
|
40
|
+
const effects: EffectType[] = [];
|
|
41
|
+
|
|
42
|
+
const reason = msg.params[0];
|
|
43
|
+
const isSettingAway = reason !== undefined && reason.length > 0;
|
|
44
|
+
|
|
45
|
+
const nick = state.nick ?? '*';
|
|
46
|
+
|
|
47
|
+
if (isSettingAway) {
|
|
48
|
+
state.away = reason;
|
|
49
|
+
effects.push(
|
|
50
|
+
Effect.send(ctx.connId, [
|
|
51
|
+
{
|
|
52
|
+
text: `:${ctx.serverName} ${Numerics.RPL_NOWAWAY} ${nick} :You have been marked as being away`,
|
|
53
|
+
},
|
|
54
|
+
]),
|
|
55
|
+
);
|
|
56
|
+
} else {
|
|
57
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
58
|
+
delete state.away;
|
|
59
|
+
effects.push(
|
|
60
|
+
Effect.send(ctx.connId, [
|
|
61
|
+
{
|
|
62
|
+
text: `:${ctx.serverName} ${Numerics.RPL_UNAWAY} ${nick} :You are no longer marked as being away`,
|
|
63
|
+
},
|
|
64
|
+
]),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// away-notify fanout: emit a cap-only broadcast to each shared channel.
|
|
69
|
+
// The broadcast has no `lines` for non-cap peers (cap-only mode); cap
|
|
70
|
+
// peers receive the AWAY line.
|
|
71
|
+
if (state.joinedChannels.size > 0) {
|
|
72
|
+
const hostmask = hostmaskOf(state) ?? state.nick ?? '?';
|
|
73
|
+
const line: RawLine = isSettingAway
|
|
74
|
+
? { text: `:${hostmask} AWAY :${reason}` }
|
|
75
|
+
: { text: `:${hostmask} AWAY` };
|
|
76
|
+
for (const chan of state.joinedChannels) {
|
|
77
|
+
effects.push(Effect.broadcast(chan as ChanName, [line], undefined, 'away-notify'));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { state, effects };
|
|
82
|
+
};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRCv3 `CAP` command.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: `CAP` runs in the Connection entity.
|
|
5
|
+
* The reducer consults the capability table in `caps/capabilities.ts` and
|
|
6
|
+
* maintains two pieces of state on {@link ConnectionState}:
|
|
7
|
+
*
|
|
8
|
+
* - `caps` — the set of negotiated cap names (sticky, consulted by every
|
|
9
|
+
* reducer that needs to know whether a peer opted into an IRCv3
|
|
10
|
+
* extension).
|
|
11
|
+
* - `capNegotiating` — true between `CAP LS`/`CAP REQ` and `CAP END`.
|
|
12
|
+
* While true the registration welcome block is deferred (IRCv3 spec
|
|
13
|
+
* requirement) so the client can complete capability negotiation
|
|
14
|
+
* before being considered registered.
|
|
15
|
+
*
|
|
16
|
+
* Wire format (IRCv3 Capability Negotiation):
|
|
17
|
+
*
|
|
18
|
+
* Client -> Server:
|
|
19
|
+
* `CAP LS [<version>]`
|
|
20
|
+
* `CAP REQ :<cap>{ <cap>}` (each cap may be `-`-prefixed to disable)
|
|
21
|
+
* `CAP END`
|
|
22
|
+
* `CAP LIST`
|
|
23
|
+
*
|
|
24
|
+
* Server -> Client:
|
|
25
|
+
* `:<server> CAP <nick> LS [*] :<caps>` (multiline with the `*` marker)
|
|
26
|
+
* `:<server> CAP <nick> ACK :<caps>`
|
|
27
|
+
* `:<server> CAP <nick> NAK :<caps>`
|
|
28
|
+
* `:<server> CAP <nick> LIST [*] :<caps>`
|
|
29
|
+
* `:<server> 410 <nick> <cmd> :Invalid CAP command`
|
|
30
|
+
*
|
|
31
|
+
* `CAP NEW` and `CAP DEL` are server-to-client only in IRCv3.2; when sent
|
|
32
|
+
* by a client they are rejected with `410 ERR_INVALIDCAPCMD`.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
SUPPORTED_CAPABILITIES,
|
|
37
|
+
capToLsString,
|
|
38
|
+
isSupportedCap,
|
|
39
|
+
splitCapsForWire,
|
|
40
|
+
} from '../caps/capabilities.js';
|
|
41
|
+
import { Effect } from '../effects.js';
|
|
42
|
+
import type { RawLine } from '../effects.js';
|
|
43
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
44
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
45
|
+
import type { Ctx, Reducer, ReducerResult } from '../types.js';
|
|
46
|
+
import { emitWelcomeIfReady } from './registration.js';
|
|
47
|
+
|
|
48
|
+
/** RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing CR-LF. */
|
|
49
|
+
const MAX_LINE_BYTES = 512;
|
|
50
|
+
|
|
51
|
+
/** Length of the trailing CR-LF that the wire layer appends to each line. */
|
|
52
|
+
const TRAILING_CRLF_LEN = 2;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Handles `CAP <subcommand> [<args>]`. Dispatches to a per-subcommand
|
|
56
|
+
* handler; unknown subcommands (including server-to-client-only ones
|
|
57
|
+
* like `NEW`/`DEL`) fall through to `410 ERR_INVALIDCAPCMD`.
|
|
58
|
+
*/
|
|
59
|
+
export const capReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
60
|
+
state.lastSeen = ctx.clock.now();
|
|
61
|
+
|
|
62
|
+
const rawSub = msg.params[0];
|
|
63
|
+
const sub = (rawSub ?? '').toUpperCase();
|
|
64
|
+
|
|
65
|
+
switch (sub) {
|
|
66
|
+
case 'LS':
|
|
67
|
+
return handleLs(state, ctx);
|
|
68
|
+
case 'LIST':
|
|
69
|
+
return handleList(state, ctx);
|
|
70
|
+
case 'REQ':
|
|
71
|
+
return handleReq(state, msg, ctx);
|
|
72
|
+
case 'END':
|
|
73
|
+
return handleEnd(state, ctx);
|
|
74
|
+
default:
|
|
75
|
+
return { state, effects: [Effect.send(ctx.connId, [invalidCapCmd(ctx, rawSub)])] };
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `CAP LS` — advertise the supported capability set.
|
|
81
|
+
*
|
|
82
|
+
* Marks the connection as `capNegotiating` so the welcome block is
|
|
83
|
+
* deferred until `CAP END`. Splits the payload across multiple lines
|
|
84
|
+
* when it would exceed the 512-byte wire limit, using the `*` multiline
|
|
85
|
+
* marker per the IRCv3 spec.
|
|
86
|
+
*/
|
|
87
|
+
function handleLs(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionState> {
|
|
88
|
+
state.capNegotiating = true;
|
|
89
|
+
|
|
90
|
+
const target = state.nick ?? '*';
|
|
91
|
+
const tokens = SUPPORTED_CAPABILITIES.map(capToLsString);
|
|
92
|
+
const maxLen = maxCapsContentLen(ctx, target);
|
|
93
|
+
const chunks = splitCapsForWire(tokens, maxLen);
|
|
94
|
+
|
|
95
|
+
const lines: RawLine[] = chunks.map((chunk, i) => {
|
|
96
|
+
const isLast = i === chunks.length - 1;
|
|
97
|
+
if (isLast) {
|
|
98
|
+
return { text: `:${ctx.serverName} CAP ${target} LS :${chunk}` };
|
|
99
|
+
}
|
|
100
|
+
return { text: `:${ctx.serverName} CAP ${target} LS * :${chunk}` };
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return { state, effects: [Effect.send(ctx.connId, lines)] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `CAP LIST` — report the caps currently negotiated on this connection.
|
|
108
|
+
*
|
|
109
|
+
* Read-only: does not mutate `caps` and does not start CAP negotiation.
|
|
110
|
+
*/
|
|
111
|
+
function handleList(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionState> {
|
|
112
|
+
const target = state.nick ?? '*';
|
|
113
|
+
const tokens = [...state.caps].sort();
|
|
114
|
+
const maxLen = maxCapsContentLen(ctx, target);
|
|
115
|
+
const chunks = splitCapsForWire(tokens, maxLen);
|
|
116
|
+
|
|
117
|
+
const lines: RawLine[] = chunks.map((chunk, i) => {
|
|
118
|
+
const isLast = i === chunks.length - 1;
|
|
119
|
+
if (isLast) {
|
|
120
|
+
return { text: `:${ctx.serverName} CAP ${target} LIST :${chunk}` };
|
|
121
|
+
}
|
|
122
|
+
return { text: `:${ctx.serverName} CAP ${target} LIST * :${chunk}` };
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return { state, effects: [Effect.send(ctx.connId, lines)] };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `CAP REQ :<caps>` — atomically enable or disable caps.
|
|
130
|
+
*
|
|
131
|
+
* Per IRCv3 the request is all-or-nothing: if every requested cap is
|
|
132
|
+
* supported (and the disable-marker form is well-formed), the server
|
|
133
|
+
* ACKs the request verbatim and updates `state.caps`. If any cap is
|
|
134
|
+
* unknown the entire request is NAKed and `state.caps` is untouched.
|
|
135
|
+
*
|
|
136
|
+
* Marks the connection as `capNegotiating` so the welcome stays deferred.
|
|
137
|
+
*/
|
|
138
|
+
function handleReq(
|
|
139
|
+
state: ConnectionState,
|
|
140
|
+
msg: { params: string[] },
|
|
141
|
+
ctx: Ctx,
|
|
142
|
+
): ReducerResult<ConnectionState> {
|
|
143
|
+
const rawCaps = msg.params[1];
|
|
144
|
+
if (rawCaps === undefined || rawCaps === '') {
|
|
145
|
+
return {
|
|
146
|
+
state,
|
|
147
|
+
effects: [
|
|
148
|
+
Effect.send(ctx.connId, [
|
|
149
|
+
numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'CAP'),
|
|
150
|
+
]),
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
state.capNegotiating = true;
|
|
156
|
+
|
|
157
|
+
const requested = rawCaps.split(' ').filter((c) => c.length > 0);
|
|
158
|
+
const target = state.nick ?? '*';
|
|
159
|
+
|
|
160
|
+
const allSupported = requested.every((tok) => {
|
|
161
|
+
const name = tok.startsWith('-') ? tok.slice(1) : tok;
|
|
162
|
+
return isSupportedCap(name);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (!allSupported) {
|
|
166
|
+
return {
|
|
167
|
+
state,
|
|
168
|
+
effects: [
|
|
169
|
+
Effect.send(ctx.connId, [
|
|
170
|
+
L(`:${ctx.serverName} CAP ${target} NAK :${requested.join(' ')}`),
|
|
171
|
+
]),
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const tok of requested) {
|
|
177
|
+
if (tok.startsWith('-')) {
|
|
178
|
+
state.caps.delete(tok.slice(1));
|
|
179
|
+
} else {
|
|
180
|
+
state.caps.add(tok);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
state,
|
|
186
|
+
effects: [
|
|
187
|
+
Effect.send(ctx.connId, [L(`:${ctx.serverName} CAP ${target} ACK :${requested.join(' ')}`)]),
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* `CAP END` — client signals end of capability negotiation.
|
|
194
|
+
*
|
|
195
|
+
* Clears `capNegotiating` and attempts to finalize registration. If
|
|
196
|
+
* `NICK` and `USER` have already been received, the welcome block is
|
|
197
|
+
* emitted here; otherwise the connection stays pre-registered until the
|
|
198
|
+
* basic handshake catches up.
|
|
199
|
+
*/
|
|
200
|
+
function handleEnd(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionState> {
|
|
201
|
+
state.capNegotiating = false;
|
|
202
|
+
const welcome = emitWelcomeIfReady(state, ctx);
|
|
203
|
+
return { state, effects: welcome !== null ? [welcome] : [] };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Builds the `410 ERR_INVALIDCAPCMD` reply. `rawSub` is echoed verbatim
|
|
208
|
+
* (may be undefined when the client sent a bare `CAP`).
|
|
209
|
+
*/
|
|
210
|
+
function invalidCapCmd(ctx: Ctx, rawSub: string | undefined): RawLine {
|
|
211
|
+
const nick = ctx.connection.nick ?? '*';
|
|
212
|
+
const codeStr = Numerics.ERR_INVALIDCAPCMD.toString().padStart(3, '0');
|
|
213
|
+
if (rawSub !== undefined && rawSub !== '') {
|
|
214
|
+
return { text: `:${ctx.serverName} ${codeStr} ${nick} ${rawSub} :Invalid CAP command` };
|
|
215
|
+
}
|
|
216
|
+
return { text: `:${ctx.serverName} ${codeStr} ${nick} :Invalid CAP command` };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Formats a numeric error line addressed to the connection's nick (or `*`). */
|
|
220
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
221
|
+
const nick = ctx.connection.nick ?? '*';
|
|
222
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
223
|
+
return { text: [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`].join(' ') };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Constructs a {@link RawLine} from raw text (terse alias for the hot path). */
|
|
227
|
+
function L(text: string): RawLine {
|
|
228
|
+
return { text };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Returns the maximum content-byte length available for the cap list in a
|
|
233
|
+
* single `CAP ... <SUB> [*] :<caps>` line.
|
|
234
|
+
*
|
|
235
|
+
* Wire layout:
|
|
236
|
+
* `:<server> CAP <target> <SUB> [*] :<caps>\r\n`
|
|
237
|
+
*
|
|
238
|
+
* We budget for the multiline form (`*` marker present) on every line so
|
|
239
|
+
* the final greedy-packing decision never has to be re-evaluated for the
|
|
240
|
+
* non-final vs final distinction.
|
|
241
|
+
*/
|
|
242
|
+
function maxCapsContentLen(ctx: Ctx, target: string): number {
|
|
243
|
+
// 1 (':') + server + ' CAP ' + target + ' XXX ' (subcommand + spaces) + '* ' + ':' + CRLF
|
|
244
|
+
// ` CAP ` = 5, ` LS ` / ` LIST ` = 4 (` LS `) up to 6 (` LIST `); use the larger.
|
|
245
|
+
const subOverhead = 6;
|
|
246
|
+
const multilineMarker = 2;
|
|
247
|
+
const overhead =
|
|
248
|
+
TRAILING_CRLF_LEN +
|
|
249
|
+
1 +
|
|
250
|
+
ctx.serverName.length +
|
|
251
|
+
5 +
|
|
252
|
+
target.length +
|
|
253
|
+
subOverhead +
|
|
254
|
+
multilineMarker +
|
|
255
|
+
1;
|
|
256
|
+
return Math.max(0, MAX_LINE_BYTES - overhead);
|
|
257
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Effect } from '../effects.js';
|
|
2
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
3
|
+
import type { ChanName } from '../state/channel.js';
|
|
4
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
5
|
+
|
|
6
|
+
export interface ChgHostInput {
|
|
7
|
+
conn: ConnectionState;
|
|
8
|
+
oldUser: string;
|
|
9
|
+
oldHost: string;
|
|
10
|
+
newUser: string;
|
|
11
|
+
newHost: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function emitChghost(input: ChgHostInput): EffectType[] {
|
|
15
|
+
const { conn, oldUser, oldHost, newUser, newHost } = input;
|
|
16
|
+
|
|
17
|
+
if (conn.registration !== 'registered') return [];
|
|
18
|
+
if (oldUser === newUser && oldHost === newHost) return [];
|
|
19
|
+
|
|
20
|
+
const nick = conn.nick ?? '?';
|
|
21
|
+
const line: RawLine = {
|
|
22
|
+
text: `:${nick}!${oldUser}@${oldHost} CHGHOST ${newUser} :${newHost}`,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const effects: EffectType[] = [];
|
|
26
|
+
for (const chan of conn.joinedChannels) {
|
|
27
|
+
effects.push(Effect.broadcast(chan as ChanName, [], undefined, 'chghost', [line]));
|
|
28
|
+
}
|
|
29
|
+
return effects;
|
|
30
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command reducers (pure). One file per command family.
|
|
3
|
+
*
|
|
4
|
+
* Each reducer follows the signature
|
|
5
|
+
* `(state, msg, ctx) => { state, effects }`
|
|
6
|
+
* from {@link Reducer}. Side effects are values, not performed here.
|
|
7
|
+
*/
|
|
8
|
+
export { pingReducer, pongReducer } from './ping.js';
|
|
9
|
+
export {
|
|
10
|
+
buildWelcomeLines,
|
|
11
|
+
emitWelcomeIfReady,
|
|
12
|
+
isValidNick,
|
|
13
|
+
nickReducer,
|
|
14
|
+
passReducer,
|
|
15
|
+
userReducer,
|
|
16
|
+
} from './registration.js';
|
|
17
|
+
export { quitReducer } from './quit.js';
|
|
18
|
+
export { handleJoinZero, isValidChannelName, joinReducer } from './join.js';
|
|
19
|
+
export { partReducer } from './part.js';
|
|
20
|
+
export { motdReducer } from './motd.js';
|
|
21
|
+
export {
|
|
22
|
+
isChannelTarget,
|
|
23
|
+
noticeChannelReducer,
|
|
24
|
+
noticeUserReducer,
|
|
25
|
+
privmsgChannelReducer,
|
|
26
|
+
privmsgUserReducer,
|
|
27
|
+
} from './privmsg.js';
|
|
28
|
+
export { kickReducer } from './kick.js';
|
|
29
|
+
export { topicReducer } from './topic.js';
|
|
30
|
+
export { namesReducer } from './names.js';
|
|
31
|
+
export { listReducer } from './list.js';
|
|
32
|
+
export { channelModeReducer, userModeReducer } from './mode.js';
|
|
33
|
+
export { inviteReducer } from './invite.js';
|
|
34
|
+
export { whoReducer } from './who.js';
|
|
35
|
+
export { whoisReducer } from './whois.js';
|
|
36
|
+
export { generateIsupport } from './isupport.js';
|
|
37
|
+
export { capReducer } from './cap.js';
|
|
38
|
+
export { emitChghost } from './chghost.js';
|
|
39
|
+
export type { ChgHostInput } from './chghost.js';
|
|
40
|
+
export { awayReducer } from './away.js';
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `INVITE` command.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: INVITE runs in the Channel entity. The
|
|
5
|
+
* reducer's `state` argument is the channel's authoritative state; the
|
|
6
|
+
* inviter's identity is read from `ctx.connection`. The pending-invite
|
|
7
|
+
* record (which lets the invitee bypass `+i` on a later JOIN) lives on
|
|
8
|
+
* the channel via {@link ChannelState.pendingInvites}.
|
|
9
|
+
*
|
|
10
|
+
* The actual routing of the INVITE notice to the invitee's connection is
|
|
11
|
+
* expressed as a `SendToNick` effect — the runtime resolves the nick.
|
|
12
|
+
*
|
|
13
|
+
* Wire format (RFC 2812 §3.2.7):
|
|
14
|
+
* - `:<server> 341 <inviter> <chan> <invitee>` — RPL_INVITING
|
|
15
|
+
* - `:<inviter-hostmask> INVITE <invitee> <chan>` — sent to the invitee
|
|
16
|
+
* - `443 ERR_USERONCHANNEL <inviter> <invitee> <chan> :is already on channel`
|
|
17
|
+
*
|
|
18
|
+
* +i (invite-only) requires the inviter to be a channel operator; non-+i
|
|
19
|
+
* channels allow any member to invite. The invitee's nick is resolved
|
|
20
|
+
* case-insensitively against the roster (443 if already a member).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Effect } from '../effects.js';
|
|
24
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
25
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
26
|
+
import type { ChannelState } from '../state/channel.js';
|
|
27
|
+
import { hostmaskOf } from '../state/connection.js';
|
|
28
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
29
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
30
|
+
|
|
31
|
+
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
32
|
+
|
|
33
|
+
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
34
|
+
if (name.length === 0 || name.length > maxLen) return false;
|
|
35
|
+
return CHANNEL_NAME_RE.test(name);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Returns the caller's hostmask, falling back to nick then `?`. */
|
|
39
|
+
function callerHostmask(conn: ConnectionState): string {
|
|
40
|
+
return hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Formats a numeric error/reply line addressed to the connection's nick. */
|
|
44
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
|
|
45
|
+
const nick = ctx.connection.nick ?? '*';
|
|
46
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
47
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
48
|
+
if (middle !== undefined) parts.push(middle);
|
|
49
|
+
parts.push(`:${trailing}`);
|
|
50
|
+
return { text: parts.join(' ') };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function lowerNick(nick: string): string {
|
|
54
|
+
return nick.toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Finds a roster entry's connId by nick (case-insensitive). */
|
|
58
|
+
function findConnIdByNick(state: ChannelState, nick: string): string | null {
|
|
59
|
+
const target = lowerNick(nick);
|
|
60
|
+
for (const entry of state.members.values()) {
|
|
61
|
+
if (lowerNick(entry.nick) === target) return entry.conn;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Handles `INVITE <nick> <chan>`.
|
|
68
|
+
*
|
|
69
|
+
* Validation order: missing-params → invalid-name → not-on-channel →
|
|
70
|
+
* +i-requires-op → invitee-already-on-channel → success.
|
|
71
|
+
*/
|
|
72
|
+
export const inviteReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
73
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
74
|
+
|
|
75
|
+
const effects: EffectType[] = [];
|
|
76
|
+
|
|
77
|
+
const targetNick = msg.params[0];
|
|
78
|
+
const rawName = msg.params[1];
|
|
79
|
+
|
|
80
|
+
if (targetNick === undefined || rawName === undefined) {
|
|
81
|
+
effects.push(
|
|
82
|
+
Effect.send(ctx.connId, [
|
|
83
|
+
numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'INVITE'),
|
|
84
|
+
]),
|
|
85
|
+
);
|
|
86
|
+
return { state, effects };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!isValidChannelName(rawName, ctx.serverConfig.channelLen)) {
|
|
90
|
+
effects.push(
|
|
91
|
+
Effect.send(ctx.connId, [
|
|
92
|
+
numericErr(ctx, Numerics.ERR_NOSUCHCHANNEL, 'No such channel', rawName),
|
|
93
|
+
]),
|
|
94
|
+
);
|
|
95
|
+
return { state, effects };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Inviter must be on the channel.
|
|
99
|
+
const callerEntry = state.members.get(ctx.connId);
|
|
100
|
+
if (callerEntry === undefined) {
|
|
101
|
+
effects.push(
|
|
102
|
+
Effect.send(ctx.connId, [
|
|
103
|
+
numericErr(ctx, Numerics.ERR_NOTONCHANNEL, "You're not on that channel", state.name),
|
|
104
|
+
]),
|
|
105
|
+
);
|
|
106
|
+
return { state, effects };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// +i (invite-only) requires op to invite.
|
|
110
|
+
if (state.modes.inviteOnly && !callerEntry.op) {
|
|
111
|
+
effects.push(
|
|
112
|
+
Effect.send(ctx.connId, [
|
|
113
|
+
numericErr(ctx, Numerics.ERR_CHANOPRIVSNEEDED, "You're not channel operator", state.name),
|
|
114
|
+
]),
|
|
115
|
+
);
|
|
116
|
+
return { state, effects };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Invitee already on the channel?
|
|
120
|
+
const targetConnId = findConnIdByNick(state, targetNick);
|
|
121
|
+
if (targetConnId !== null) {
|
|
122
|
+
effects.push(
|
|
123
|
+
Effect.send(ctx.connId, [
|
|
124
|
+
numericErr(
|
|
125
|
+
ctx,
|
|
126
|
+
Numerics.ERR_USERONCHANNEL,
|
|
127
|
+
'is already on channel',
|
|
128
|
+
`${targetNick} ${state.name}`,
|
|
129
|
+
),
|
|
130
|
+
]),
|
|
131
|
+
);
|
|
132
|
+
return { state, effects };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Success: record the pending invite (by lowercased nick) so a later
|
|
136
|
+
// JOIN bypasses +i, emit 341, and route the INVITE notice to the invitee.
|
|
137
|
+
state.pendingInvites.add(targetNick.toLowerCase());
|
|
138
|
+
|
|
139
|
+
const inviterNick = ctx.connection.nick ?? '*';
|
|
140
|
+
effects.push(
|
|
141
|
+
Effect.send(ctx.connId, [
|
|
142
|
+
{ text: `:${ctx.serverName} 341 ${inviterNick} ${state.name} ${targetNick}` },
|
|
143
|
+
]),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const hostmask = callerHostmask(ctx.connection);
|
|
147
|
+
const inviteLine: RawLine = { text: `:${hostmask} INVITE ${targetNick} ${state.name}` };
|
|
148
|
+
effects.push(Effect.sendToNick(targetNick, ctx.connId, [inviteLine]));
|
|
149
|
+
|
|
150
|
+
// IRCv3 invite-notify: channel members with the `invite-notify` cap
|
|
151
|
+
// receive a copy of the INVITE. Cap-only broadcast so legacy members see
|
|
152
|
+
// nothing; the invitee is reached via SendToNick above (exactly once).
|
|
153
|
+
effects.push(Effect.broadcast(state.name, [inviteLine], undefined, 'invite-notify'));
|
|
154
|
+
|
|
155
|
+
return { state, effects };
|
|
156
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure generator for the IRC `005 RPL_ISUPPORT` reply.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §3 / RFC 2812 §3.1.1 / <https://modern.ircdocs.horse/#rplisupport-005>:
|
|
5
|
+
*
|
|
6
|
+
* `<prefix> 005 <nick> <token>{ <token>} :are supported by this server`
|
|
7
|
+
*
|
|
8
|
+
* The full token set rarely fits in a single 510-byte wire line, so daemons
|
|
9
|
+
* split it across multiple `005` lines; only the final line carries the
|
|
10
|
+
* trailing `:are supported by this server` parameter. This module computes
|
|
11
|
+
* the token list from `serverConfig`, then packs tokens into batches that
|
|
12
|
+
* keep every emitted line within the 510-byte ceiling.
|
|
13
|
+
*
|
|
14
|
+
* Tokens are derived from config, not hard-coded, so adapters can tune limits
|
|
15
|
+
* per-deployment (PLAN §10 capacity planning).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { RawLine } from '../effects.js';
|
|
19
|
+
import type { ServerConfig } from '../types.js';
|
|
20
|
+
|
|
21
|
+
/** Max bytes per IRC wire line, including trailing `\r\n`. */
|
|
22
|
+
const LINE_MAX_BYTES = 510;
|
|
23
|
+
|
|
24
|
+
/** Canonical channel mode classes, in CHANMODES order. */
|
|
25
|
+
const CHANMODES = {
|
|
26
|
+
/** Class A — list modes (always take a parameter when set/unset). */
|
|
27
|
+
list: 'b',
|
|
28
|
+
/** Class B — parameter modes (param when set, no param when unset). */
|
|
29
|
+
paramSet: 'k',
|
|
30
|
+
/** Class C — parameter modes (param when set AND unset). */
|
|
31
|
+
paramAlways: 'l',
|
|
32
|
+
/** Class D — boolean modes (no parameter). */
|
|
33
|
+
boolean: 'imnpst',
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
/** Channel-user prefix sigils, in PREFIX order (highest rank first). */
|
|
37
|
+
const PREFIX = '(ov)@+';
|
|
38
|
+
|
|
39
|
+
/** Channel name sigils this server recognises. */
|
|
40
|
+
const CHANTYPES = '#';
|
|
41
|
+
|
|
42
|
+
/** Case-mapping algorithm — v1 uses RFC 1459 (matches `lower.ts`). */
|
|
43
|
+
const CASEMAPPING = 'rfc1459';
|
|
44
|
+
|
|
45
|
+
/** Default max mode changes per single MODE command (PLAN §3). */
|
|
46
|
+
const MODES_PER_COMMAND = 4;
|
|
47
|
+
|
|
48
|
+
/** Trailing parameter appended to the final 005 line. */
|
|
49
|
+
const TRAILER = 'are supported by this server';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Builds the token list for one client based on its capabilities and the
|
|
53
|
+
* server's configuration.
|
|
54
|
+
*
|
|
55
|
+
* Order matters: RFC-style daemons emit tokens in a stable order so clients
|
|
56
|
+
* can diff between servers. Tokens are space-separated, never empty.
|
|
57
|
+
*/
|
|
58
|
+
function buildTokens(cfg: ServerConfig): string[] {
|
|
59
|
+
return [
|
|
60
|
+
`CHANTYPES=${CHANTYPES}`,
|
|
61
|
+
`CHANMODES=${CHANMODES.list},${CHANMODES.paramSet},${CHANMODES.paramAlways},${CHANMODES.boolean}`,
|
|
62
|
+
`PREFIX=${PREFIX}`,
|
|
63
|
+
`MODES=${MODES_PER_COMMAND}`,
|
|
64
|
+
`NICKLEN=${cfg.nickLen}`,
|
|
65
|
+
`CHANNELLEN=${cfg.channelLen}`,
|
|
66
|
+
`TOPICLEN=${cfg.topicLen}`,
|
|
67
|
+
`CASEMAPPING=${CASEMAPPING}`,
|
|
68
|
+
`NETWORK=${cfg.networkName}`,
|
|
69
|
+
`MAXCHANNELS=${cfg.maxChannelsPerUser}`,
|
|
70
|
+
`MAXTARGETS=${cfg.maxTargetsPerCommand}`,
|
|
71
|
+
// SAFELIST is a boolean ISUPPORT token advertising that LIST will not
|
|
72
|
+
// get the connection killed (the matching IRCv3 `safelist` cap is the
|
|
73
|
+
// per-connection opt-in). LIST is always non-destructive here so the
|
|
74
|
+
// token is unconditional.
|
|
75
|
+
'SAFELIST',
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Greedy packer: accumulates tokens into a single space-joined prefix until
|
|
81
|
+
* adding the next token would exceed the 510-byte ceiling, then starts a new
|
|
82
|
+
* batch. The caller appends the trailer separately.
|
|
83
|
+
*
|
|
84
|
+
* Each batch returns just the joined token string (no `005`, no trailer).
|
|
85
|
+
*/
|
|
86
|
+
function packTokens(tokens: ReadonlyArray<string>, baseLen: number): string[] {
|
|
87
|
+
const batches: string[] = [];
|
|
88
|
+
let current = '';
|
|
89
|
+
for (const tok of tokens) {
|
|
90
|
+
const candidate = current === '' ? tok : `${current} ${tok}`;
|
|
91
|
+
// +1 for the trailing space before the `:`-prefixed trailer.
|
|
92
|
+
if (baseLen + candidate.length + 1 > LINE_MAX_BYTES && current !== '') {
|
|
93
|
+
batches.push(current);
|
|
94
|
+
current = tok;
|
|
95
|
+
} else {
|
|
96
|
+
current = candidate;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (current !== '') batches.push(current);
|
|
100
|
+
return batches;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Generates the `005 RPL_ISUPPORT` reply for a connection.
|
|
105
|
+
*
|
|
106
|
+
* @param cfg Server configuration (size limits, network name).
|
|
107
|
+
* @param _connCaps IRCv3 capabilities negotiated by the connection
|
|
108
|
+
* (reserved for `BOT=` and similar v3 tokens; currently
|
|
109
|
+
* unused — kept in the signature per the ticket spec).
|
|
110
|
+
* @param nick Optional nick for the reply target; defaults to `*`.
|
|
111
|
+
* @returns One or more {@link RawLine}s, each within the 510-byte ceiling.
|
|
112
|
+
* Only the final line carries the `:are supported by this server`
|
|
113
|
+
* trailer.
|
|
114
|
+
*/
|
|
115
|
+
export function generateIsupport(
|
|
116
|
+
cfg: ServerConfig,
|
|
117
|
+
_connCaps: ReadonlySet<string>,
|
|
118
|
+
nick = '*',
|
|
119
|
+
): RawLine[] {
|
|
120
|
+
const tokens = buildTokens(cfg);
|
|
121
|
+
const prefix = `:${cfg.serverName} 005 ${nick} `;
|
|
122
|
+
const baseLen = prefix.length;
|
|
123
|
+
|
|
124
|
+
const batches = packTokens(tokens, baseLen);
|
|
125
|
+
|
|
126
|
+
const lines: RawLine[] = batches.map((batch, idx) => {
|
|
127
|
+
const isLast = idx === batches.length - 1;
|
|
128
|
+
const trailer = isLast ? ` :${TRAILER}` : '';
|
|
129
|
+
return { text: `${prefix}${batch}${trailer}` };
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return lines;
|
|
133
|
+
}
|