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,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `MOTD` command.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: MOTD is read-only — it does not mutate
|
|
5
|
+
* connection or channel state, only emits `Send` numeric replies addressed
|
|
6
|
+
* to the requester. The reducer runs in the Connection entity (state is
|
|
7
|
+
* {@link ConnectionState}).
|
|
8
|
+
*
|
|
9
|
+
* The MOTD text comes from the {@link MotdProvider} port on {@link Ctx}.
|
|
10
|
+
* Adapters pre-load the MOTD into the provider at startup (e.g. from KV,
|
|
11
|
+
* D1, or Secrets Manager) so the reducer itself stays pure and
|
|
12
|
+
* deterministic — no IO in the handler.
|
|
13
|
+
*
|
|
14
|
+
* Wire format (RFC 2812 §5):
|
|
15
|
+
* - `375 RPL_MOTDSTART` — `:<server> 375 <nick> :- <server> Message of the day -`
|
|
16
|
+
* - `372 RPL_MOTD` — `:<server> 372 <nick> :- <line>` (one per MOTD line)
|
|
17
|
+
* - `376 RPL_ENDOFMOTD` — `:<server> 376 <nick> :End of MOTD command`
|
|
18
|
+
* - `422 ERR_NOMOTD` — `:<server> 422 <nick> :MOTD File is missing`
|
|
19
|
+
*
|
|
20
|
+
* Per RFC 1459 §2.3 the maximum wire line length is 512 bytes including the
|
|
21
|
+
* trailing CR-LF. Over-long MOTD content lines are hard-wrapped at the
|
|
22
|
+
* 372-line content budget so every emitted line fits within the 512-byte
|
|
23
|
+
* limit (also leaving headroom for message-tags).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Effect } from '../effects.js';
|
|
27
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
28
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
29
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
30
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
31
|
+
|
|
32
|
+
/** RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing CR-LF. */
|
|
33
|
+
const MAX_LINE_BYTES = 512;
|
|
34
|
+
|
|
35
|
+
/** Length of the trailing CR-LF that the wire layer appends to each line. */
|
|
36
|
+
const TRAILING_CRLF_LEN = 2;
|
|
37
|
+
|
|
38
|
+
/** Fixed text emitted for `375 RPL_MOTDSTART` (after `:- `). */
|
|
39
|
+
const MOTDSTART_TRAILING_TEXT = '- $server Message of the day -';
|
|
40
|
+
|
|
41
|
+
/** Fixed text emitted for `376 RPL_ENDOFMOTD`. */
|
|
42
|
+
const ENDOFMOTD_TRAILING_TEXT = 'End of MOTD command';
|
|
43
|
+
|
|
44
|
+
/** Fixed text emitted for `422 ERR_NOMOTD`. */
|
|
45
|
+
const NOMOTD_TRAILING_TEXT = 'MOTD File is missing';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Handles `MOTD [server]`.
|
|
49
|
+
*
|
|
50
|
+
* The optional `<server>` target parameter is ignored: this is a
|
|
51
|
+
* single-server implementation (PLAN §1 non-goal: no S2S linking), so a
|
|
52
|
+
* request addressed to another server still gets the local MOTD. This
|
|
53
|
+
* matches the behavior of common modern IRC daemons on a single-node
|
|
54
|
+
* deployment.
|
|
55
|
+
*/
|
|
56
|
+
export const motdReducer: Reducer<ConnectionState> = (state, _msg, ctx) => {
|
|
57
|
+
state.lastSeen = ctx.clock.now();
|
|
58
|
+
|
|
59
|
+
const lines = ctx.motd.lines();
|
|
60
|
+
|
|
61
|
+
if (lines.length === 0) {
|
|
62
|
+
return {
|
|
63
|
+
state,
|
|
64
|
+
effects: [
|
|
65
|
+
Effect.send(ctx.connId, [numericLine(ctx, Numerics.ERR_NOMOTD, NOMOTD_TRAILING_TEXT)]),
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const out: RawLine[] = [
|
|
71
|
+
numericLine(
|
|
72
|
+
ctx,
|
|
73
|
+
Numerics.RPL_MOTDSTART,
|
|
74
|
+
MOTDSTART_TRAILING_TEXT.replace('$server', ctx.serverName),
|
|
75
|
+
),
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const maxContent = maxMotdContentLen(ctx);
|
|
79
|
+
for (const raw of lines) {
|
|
80
|
+
for (const chunk of splitForMotd(raw, maxContent)) {
|
|
81
|
+
out.push(numericLine(ctx, Numerics.RPL_MOTD, `- ${chunk}`));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
out.push(numericLine(ctx, Numerics.RPL_ENDOFMOTD, ENDOFMOTD_TRAILING_TEXT));
|
|
86
|
+
|
|
87
|
+
const effects: EffectType[] = [Effect.send(ctx.connId, out)];
|
|
88
|
+
return { state, effects };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Builds a `:<server> <code> <nick> :<trailing>` line. The nick falls back
|
|
93
|
+
* to `*` when the connection has not yet registered one, matching the rest
|
|
94
|
+
* of the codebase's numeric-error formatting.
|
|
95
|
+
*/
|
|
96
|
+
function numericLine(ctx: Ctx, code: number, trailing: string): RawLine {
|
|
97
|
+
const nick = ctx.connection.nick ?? '*';
|
|
98
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
99
|
+
return { text: `:${ctx.serverName} ${codeStr} ${nick} :${trailing}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Returns the maximum content-byte length that fits in a single `372 RPL_MOTD`
|
|
104
|
+
* line for this connection, given the server name and the connection's nick.
|
|
105
|
+
*
|
|
106
|
+
* The 372 wire format is `:<server> 372 <nick> :- <chunk>\r\n`, so the
|
|
107
|
+
* per-chunk budget is:
|
|
108
|
+
*
|
|
109
|
+
* 512
|
|
110
|
+
* - 2 (trailing CR-LF)
|
|
111
|
+
* - 1 (leading `:`)
|
|
112
|
+
* - <server>.length
|
|
113
|
+
* - 5 (` 372 `)
|
|
114
|
+
* - <nick>.length
|
|
115
|
+
* - 4 (` :- `)
|
|
116
|
+
*
|
|
117
|
+
* The result is clamped at 0 so a pathologically long server/nick combo
|
|
118
|
+
* cannot drive the budget negative.
|
|
119
|
+
*/
|
|
120
|
+
function maxMotdContentLen(ctx: Ctx): number {
|
|
121
|
+
const nick = ctx.connection.nick ?? '*';
|
|
122
|
+
const overhead = TRAILING_CRLF_LEN + 1 + ctx.serverName.length + 5 + nick.length + 4;
|
|
123
|
+
return Math.max(0, MAX_LINE_BYTES - overhead);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Splits a single MOTD content line into chunks that each fit the per-372
|
|
128
|
+
* content budget.
|
|
129
|
+
*
|
|
130
|
+
* Hard-wraps at the byte budget (no word-boundary awareness) — matching the
|
|
131
|
+
* behavior of mainstream IRC daemons and keeping the reducer deterministic.
|
|
132
|
+
*
|
|
133
|
+
* Edge cases:
|
|
134
|
+
* - Empty string → `['']` (one empty 372 line, still RFC-conformant).
|
|
135
|
+
* - Zero/negative budget (impossible in practice but defensive) → `['']`
|
|
136
|
+
* so the reducer always emits at least one 372 per input line.
|
|
137
|
+
*/
|
|
138
|
+
function splitForMotd(text: string, maxLen: number): string[] {
|
|
139
|
+
if (maxLen <= 0) return [''];
|
|
140
|
+
if (text.length === 0) return [''];
|
|
141
|
+
const chunks: string[] = [];
|
|
142
|
+
for (let i = 0; i < text.length; i += maxLen) {
|
|
143
|
+
chunks.push(text.slice(i, i + maxLen));
|
|
144
|
+
}
|
|
145
|
+
return chunks;
|
|
146
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `NAMES` command.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: NAMES reads channel state (roster +
|
|
5
|
+
* visibility modes) but does not mutate it, so the reducer's `state`
|
|
6
|
+
* argument is the channel's authoritative {@link ChannelState}; the
|
|
7
|
+
* requester is `ctx.connection`.
|
|
8
|
+
*
|
|
9
|
+
* Wire format (RFC 2812 §3.2.5):
|
|
10
|
+
* - `353 RPL_NAMREPLY` — `:<server> 353 <nick> <sigil> <chan> :<names>`
|
|
11
|
+
* - `366 RPL_ENDOFNAMES` — `:<server> 366 <nick> <chan> :End of /NAMES list.`
|
|
12
|
+
*
|
|
13
|
+
* The `<sigil>` reflects channel visibility: `=` (public), `*` (secret `+s`),
|
|
14
|
+
* `@` (private `+p`). Private wins over secret when both are set, matching
|
|
15
|
+
* the JOIN reducer's NAMES reply and RFC 2812 §3.2.4.
|
|
16
|
+
*
|
|
17
|
+
* Visibility rules (PLAN §3 + ticket spec):
|
|
18
|
+
* - `+s` or `+p` set: requester must be a member to receive the NAMES list.
|
|
19
|
+
* Otherwise `442 ERR_NOTONCHANNEL`.
|
|
20
|
+
* - Otherwise: any connection may list.
|
|
21
|
+
*
|
|
22
|
+
* Multi-prefix (IRCv3): with the `multi-prefix` cap every applicable prefix
|
|
23
|
+
* is shown (`@+nick`); without the cap only the highest-priority prefix is
|
|
24
|
+
* shown (`@nick` for an op+voice user).
|
|
25
|
+
*
|
|
26
|
+
* Multi-channel (`NAMES #a,#b`) and no-args (`NAMES`) variants are split by
|
|
27
|
+
* the actor layer; this reducer handles exactly one channel
|
|
28
|
+
* per invocation.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { Effect } from '../effects.js';
|
|
32
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
33
|
+
import { wrapBatch } from '../protocol/batch.js';
|
|
34
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
35
|
+
import type { ChannelState, RosterEntry } from '../state/channel.js';
|
|
36
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
37
|
+
|
|
38
|
+
/** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
|
|
39
|
+
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
40
|
+
|
|
41
|
+
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
42
|
+
if (name.length === 0 || name.length > maxLen) return false;
|
|
43
|
+
return CHANNEL_NAME_RE.test(name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Formats a numeric error/reply line addressed to the connection's nick. */
|
|
47
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
|
|
48
|
+
const nick = ctx.connection.nick ?? '*';
|
|
49
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
50
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
51
|
+
if (middle !== undefined) parts.push(middle);
|
|
52
|
+
parts.push(`:${trailing}`);
|
|
53
|
+
return { text: parts.join(' ') };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Returns the 353 NAMES sigil for a channel given its modes:
|
|
58
|
+
* `@` for `+p` (private), `*` for `+s` (secret), `=` otherwise (public).
|
|
59
|
+
*
|
|
60
|
+
* `+p` wins over `+s` when both are set (RFC 2812 §3.2.4). Identical to the
|
|
61
|
+
* helper inside `join.ts`; duplicated to keep NAMES self-contained.
|
|
62
|
+
*/
|
|
63
|
+
function namesSigil(state: ChannelState): string {
|
|
64
|
+
if (state.modes.private) return '@';
|
|
65
|
+
if (state.modes.secret) return '*';
|
|
66
|
+
return '=';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Returns the IRC prefix character for the highest-priority mode an entry
|
|
71
|
+
* holds (`@` beats `+`). Returns `''` for a regular member.
|
|
72
|
+
*/
|
|
73
|
+
function highestPrefix(entry: RosterEntry): string {
|
|
74
|
+
if (entry.op) return '@';
|
|
75
|
+
if (entry.voice) return '+';
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns every applicable prefix character in canonical order (`@` before
|
|
81
|
+
* `+`). Used when the connection has negotiated `multi-prefix`.
|
|
82
|
+
*/
|
|
83
|
+
function allPrefixes(entry: RosterEntry): string {
|
|
84
|
+
let out = '';
|
|
85
|
+
if (entry.op) out += '@';
|
|
86
|
+
if (entry.voice) out += '+';
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Returns the prefix-rendering function appropriate for the requester's caps. */
|
|
91
|
+
function prefixFormatter(ctx: Ctx): (entry: RosterEntry) => string {
|
|
92
|
+
return ctx.connection.caps.has('multi-prefix') ? allPrefixes : highestPrefix;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Builds the space-separated NAMES list with mode prefixes for a channel. */
|
|
96
|
+
function buildNamesList(state: ChannelState, ctx: Ctx): string {
|
|
97
|
+
const format = prefixFormatter(ctx);
|
|
98
|
+
const names: string[] = [];
|
|
99
|
+
for (const entry of state.members.values()) {
|
|
100
|
+
names.push(format(entry) + entry.nick);
|
|
101
|
+
}
|
|
102
|
+
return names.join(' ');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Handles `NAMES <chan>`.
|
|
107
|
+
*
|
|
108
|
+
* Validation order (deliberate): missing-param → invalid-name →
|
|
109
|
+
* visibility (+s/+p membership) → success.
|
|
110
|
+
*/
|
|
111
|
+
export const namesReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
112
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
113
|
+
|
|
114
|
+
const effects: EffectType[] = [];
|
|
115
|
+
|
|
116
|
+
const rawName = msg.params[0];
|
|
117
|
+
if (rawName === undefined) {
|
|
118
|
+
effects.push(
|
|
119
|
+
Effect.send(ctx.connId, [
|
|
120
|
+
numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'NAMES'),
|
|
121
|
+
]),
|
|
122
|
+
);
|
|
123
|
+
return { state, effects };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!isValidChannelName(rawName, ctx.serverConfig.channelLen)) {
|
|
127
|
+
effects.push(
|
|
128
|
+
Effect.send(ctx.connId, [
|
|
129
|
+
numericErr(ctx, Numerics.ERR_NOSUCHCHANNEL, 'No such channel', rawName),
|
|
130
|
+
]),
|
|
131
|
+
);
|
|
132
|
+
return { state, effects };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Visibility: +s/+p channels cannot be listed by non-members.
|
|
136
|
+
const isMember = state.members.has(ctx.connId);
|
|
137
|
+
if ((state.modes.secret || state.modes.private) && !isMember) {
|
|
138
|
+
effects.push(
|
|
139
|
+
Effect.send(ctx.connId, [
|
|
140
|
+
numericErr(ctx, Numerics.ERR_NOTONCHANNEL, "You're not on that channel", state.name),
|
|
141
|
+
]),
|
|
142
|
+
);
|
|
143
|
+
return { state, effects };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const nick = ctx.connection.nick ?? '*';
|
|
147
|
+
const sigil = namesSigil(state);
|
|
148
|
+
const names = buildNamesList(state, ctx);
|
|
149
|
+
const replyLines: RawLine[] = [
|
|
150
|
+
{ text: `:${ctx.serverName} 353 ${nick} ${sigil} ${state.name} :${names}` },
|
|
151
|
+
{ text: `:${ctx.serverName} 366 ${nick} ${state.name} :End of /NAMES list.` },
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
// IRCv3 batch: wrap the multi-line reply for clients that negotiated it.
|
|
155
|
+
// Netbatch ref id allocated via the injected IdFactory so the wrapping is
|
|
156
|
+
// deterministic in tests.
|
|
157
|
+
const lines = ctx.connection.caps.has('batch')
|
|
158
|
+
? wrapBatch(replyLines, ctx.ids.batchId(), 'names')
|
|
159
|
+
: replyLines;
|
|
160
|
+
|
|
161
|
+
effects.push(Effect.send(ctx.connId, lines));
|
|
162
|
+
|
|
163
|
+
return { state, effects };
|
|
164
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `PART` command.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: PART runs in the Channel entity. The
|
|
5
|
+
* reducer's `state` argument is the channel's authoritative state; the
|
|
6
|
+
* parter's identity is read from `ctx.connection`. Mutations to both
|
|
7
|
+
* `state.members` (the roster, via `ApplyChannelDelta`) and
|
|
8
|
+
* `ctx.connection.joinedChannels` (the connection's joined list) are
|
|
9
|
+
* expressed as effects / cross-authority mutations the actor layer persists.
|
|
10
|
+
*
|
|
11
|
+
* Multi-channel PART (`PART #a,#b :reason`) is split by the actor layer;
|
|
12
|
+
* this reducer handles one channel per invocation.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Effect } from '../effects.js';
|
|
16
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
17
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
18
|
+
import type { ChannelState } from '../state/channel.js';
|
|
19
|
+
import { hostmaskOf } from '../state/connection.js';
|
|
20
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
21
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
22
|
+
|
|
23
|
+
/** Channel-name validation mirrors JOIN: same grammar, same length cap. */
|
|
24
|
+
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
25
|
+
|
|
26
|
+
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
27
|
+
if (name.length === 0 || name.length > maxLen) return false;
|
|
28
|
+
return CHANNEL_NAME_RE.test(name);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Formats a single numeric error line addressed to the connection's current
|
|
33
|
+
* nick (or `*` when unregistered). Optional `middle` is emitted before the
|
|
34
|
+
* trailing `:`-prefixed text.
|
|
35
|
+
*/
|
|
36
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
|
|
37
|
+
const nick = ctx.connection.nick ?? '*';
|
|
38
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
39
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
40
|
+
if (middle !== undefined) parts.push(middle);
|
|
41
|
+
parts.push(`:${trailing}`);
|
|
42
|
+
return { text: parts.join(' ') };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns the hostmask string for the connection, falling back to the bare
|
|
47
|
+
* nick (or `?` when the connection has no nick yet). Used as the PART
|
|
48
|
+
* message source.
|
|
49
|
+
*/
|
|
50
|
+
function parterHostmask(conn: ConnectionState): string {
|
|
51
|
+
return hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Handles `PART <chan> [:reason]` for a single channel.
|
|
56
|
+
*
|
|
57
|
+
* Pre-condition: `state` is the authoritative ChannelState for the channel
|
|
58
|
+
* named in `msg.params[0]`.
|
|
59
|
+
*
|
|
60
|
+
* Validation order (deliberate): missing-param → invalid-name →
|
|
61
|
+
* not-on-channel → success.
|
|
62
|
+
*/
|
|
63
|
+
export const partReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
64
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
65
|
+
|
|
66
|
+
const effects: EffectType[] = [];
|
|
67
|
+
|
|
68
|
+
const rawName = msg.params[0];
|
|
69
|
+
if (rawName === undefined) {
|
|
70
|
+
effects.push(
|
|
71
|
+
Effect.send(ctx.connId, [
|
|
72
|
+
numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'PART'),
|
|
73
|
+
]),
|
|
74
|
+
);
|
|
75
|
+
return { state, effects };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!isValidChannelName(rawName, ctx.serverConfig.channelLen)) {
|
|
79
|
+
effects.push(
|
|
80
|
+
Effect.send(ctx.connId, [
|
|
81
|
+
numericErr(ctx, Numerics.ERR_NOSUCHCHANNEL, 'No such channel', rawName),
|
|
82
|
+
]),
|
|
83
|
+
);
|
|
84
|
+
return { state, effects };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Not on the channel: reject before any mutation.
|
|
88
|
+
if (!state.members.has(ctx.connId)) {
|
|
89
|
+
effects.push(
|
|
90
|
+
Effect.send(ctx.connId, [
|
|
91
|
+
numericErr(ctx, Numerics.ERR_NOTONCHANNEL, "You're not on that channel", rawName),
|
|
92
|
+
]),
|
|
93
|
+
);
|
|
94
|
+
return { state, effects };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// All checks passed: build the PART line and remove the parter.
|
|
98
|
+
const hostmask = parterHostmask(ctx.connection);
|
|
99
|
+
const reason = msg.params[1];
|
|
100
|
+
const line: RawLine =
|
|
101
|
+
reason && reason.length > 0
|
|
102
|
+
? { text: `:${hostmask} PART ${state.name} :${reason}` }
|
|
103
|
+
: { text: `:${hostmask} PART ${state.name}` };
|
|
104
|
+
effects.push(Effect.broadcast(state.name, [line]));
|
|
105
|
+
|
|
106
|
+
// Drop the parter from the roster (the actor layer persists the delta).
|
|
107
|
+
state.members.delete(ctx.connId);
|
|
108
|
+
effects.push(
|
|
109
|
+
Effect.applyChannelDelta(state.name, {
|
|
110
|
+
memberships: [{ type: 'remove', conn: ctx.connId }],
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Cross-authority: remove the channel from the connection's joined set.
|
|
115
|
+
ctx.connection.joinedChannels.delete(state.nameLower);
|
|
116
|
+
|
|
117
|
+
return { state, effects };
|
|
118
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducers for the IRC `PING` / `PONG` commands.
|
|
3
|
+
*
|
|
4
|
+
* Per PLAN §2.1 these run in the Connection entity. The reducer for
|
|
5
|
+
* client-initiated `PING` emits a single `Send(PONG …)` back to the sender;
|
|
6
|
+
* the reducer for `PONG` (the client's reply to a server-initiated PING)
|
|
7
|
+
* emits nothing. Server-initiated PINGs and idle / PING bookkeeping are the
|
|
8
|
+
* runtime's job; the reducers here only refresh `lastSeen` and
|
|
9
|
+
* emit replies.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Effect } from '../effects.js';
|
|
13
|
+
import type { RawLine } from '../effects.js';
|
|
14
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
15
|
+
import type { Reducer } from '../types.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Handles a client-initiated `PING [token]`.
|
|
19
|
+
*
|
|
20
|
+
* Replies with `PONG :<token>` when a token was supplied, or a bare `PONG`
|
|
21
|
+
* when one was not (per modern client behavior). Refreshes `lastSeen`.
|
|
22
|
+
*/
|
|
23
|
+
export const pingReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
24
|
+
state.lastSeen = ctx.clock.now();
|
|
25
|
+
|
|
26
|
+
const token = msg.params[0];
|
|
27
|
+
const line: RawLine = token === undefined ? { text: 'PONG' } : { text: `PONG :${token}` };
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
state,
|
|
31
|
+
effects: [Effect.send(ctx.connId, [line])],
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Handles a client `PONG` (the reply to a server-initiated PING).
|
|
37
|
+
*
|
|
38
|
+
* Emits no effects; only refreshes `lastSeen`. The idle / PING bookkeeping
|
|
39
|
+
* that consumes `lastSeen` lives in the runtime.
|
|
40
|
+
*/
|
|
41
|
+
export const pongReducer: Reducer<ConnectionState> = (state, _msg, ctx) => {
|
|
42
|
+
state.lastSeen = ctx.clock.now();
|
|
43
|
+
return {
|
|
44
|
+
state,
|
|
45
|
+
effects: [],
|
|
46
|
+
};
|
|
47
|
+
};
|