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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IRCv3 `labeled-response` — correlate a request with its replies.
|
|
3
|
+
*
|
|
4
|
+
* Spec: https://ircv3.net/specs/extensions/labeled-response
|
|
5
|
+
*
|
|
6
|
+
* A client tags an inbound message with the `+label=<id>` client tag; the
|
|
7
|
+
* server then wraps every reply caused by that message (addressed back to
|
|
8
|
+
* the requester) in a single `BATCH +id labeled-response … BATCH -id`
|
|
9
|
+
* frame. If the request produces no reply for the requester, the server
|
|
10
|
+
* emits an empty batch so the client can still correlate the label with a
|
|
11
|
+
* definitive "no replies" answer.
|
|
12
|
+
*
|
|
13
|
+
* Unlike the generic {@link wrapBatch} helper — which elides empty batches
|
|
14
|
+
* because the IRCv3 `batch` spec forbids them — {@link wrapLabeledResponse}
|
|
15
|
+
* ALWAYS emits the start/end markers. The empty-batch case is the whole
|
|
16
|
+
* point of labeled-response: it is the correlation signal when there is
|
|
17
|
+
* nothing else to send.
|
|
18
|
+
*
|
|
19
|
+
* This module is pure data transformation. The actor layer:
|
|
20
|
+
* 1. extracts the label from the parsed message tags,
|
|
21
|
+
* 2. plumbs it onto {@link Ctx.label} so every reducer's emitted effects
|
|
22
|
+
* are uniformly attributable to the labeled request, and
|
|
23
|
+
* 3. runs {@link applyLabelToEffects} over the reducer's `Effect[]` to
|
|
24
|
+
* gather every `Send`-to-self and wrap them (or emit an empty batch).
|
|
25
|
+
*
|
|
26
|
+
* Only `Send` effects addressed to the originating connection are wrapped.
|
|
27
|
+
* Broadcasts / `SendToNick` / `BroadcastWallops` reach OTHER connections
|
|
28
|
+
* and are left in place; the label correlation applies solely to the
|
|
29
|
+
* requester's own reply stream.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { Effect } from '../effects.js';
|
|
33
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
34
|
+
import type { ConnId } from '../state/connection.js';
|
|
35
|
+
|
|
36
|
+
/** Wire name of the `labeled-response` capability. */
|
|
37
|
+
export const LABELED_RESPONSE_CAP_NAME = 'labeled-response';
|
|
38
|
+
|
|
39
|
+
/** Wire name of the `+label` client tag carried on the inbound message. */
|
|
40
|
+
export const LABEL_TAG = '+label';
|
|
41
|
+
|
|
42
|
+
/** Batch type token used for every labeled-response frame. */
|
|
43
|
+
const LABELED_RESPONSE_BATCH_TYPE = 'labeled-response';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reads the `+label` client tag from a parsed message's tag map.
|
|
47
|
+
*
|
|
48
|
+
* Returns `undefined` when the tag is absent OR present-but-empty, so callers
|
|
49
|
+
* can treat a missing label and a bare `+label` (no value) identically: both
|
|
50
|
+
* mean "this request is unlabeled".
|
|
51
|
+
*/
|
|
52
|
+
export function extractLabel(tags: Readonly<Record<string, string>>): string | undefined {
|
|
53
|
+
const value = tags[LABEL_TAG];
|
|
54
|
+
if (value === undefined || value.length === 0) return undefined;
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Wraps `lines` in IRCv3 `BATCH +label labeled-response … BATCH -label`
|
|
60
|
+
* framing. ALWAYS emits the start and end markers — even when `lines` is
|
|
61
|
+
* empty — because the labeled-response spec requires an empty batch when a
|
|
62
|
+
* labeled request produces no reply (it is the correlation signal).
|
|
63
|
+
*/
|
|
64
|
+
export function wrapLabeledResponse(label: string, lines: readonly RawLine[]): RawLine[] {
|
|
65
|
+
const start: RawLine = { text: `BATCH +${label} ${LABELED_RESPONSE_BATCH_TYPE}` };
|
|
66
|
+
const end: RawLine = { text: `BATCH -${label}` };
|
|
67
|
+
return [start, ...lines, end];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Options for {@link applyLabelToEffects}.
|
|
72
|
+
*
|
|
73
|
+
* `label` is `string | undefined` (NOT an optional field) so the helper can
|
|
74
|
+
* be a clean no-op under `exactOptionalPropertyTypes` when the inbound
|
|
75
|
+
* request carried no label.
|
|
76
|
+
*/
|
|
77
|
+
export interface LabelApplyOptions {
|
|
78
|
+
/** The label extracted from the inbound `+label` tag, or `undefined`. */
|
|
79
|
+
readonly label: string | undefined;
|
|
80
|
+
/** The originating connection id (replies wrapped are addressed here). */
|
|
81
|
+
readonly connId: ConnId;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Gathers every `Send` effect addressed to `connId` and replaces them with a
|
|
86
|
+
* single `Send` whose lines are wrapped in one `labeled-response` batch.
|
|
87
|
+
*
|
|
88
|
+
* Behaviour:
|
|
89
|
+
* - When `label` is `undefined` the effect list is returned unchanged
|
|
90
|
+
* (copy) — the request was not labeled.
|
|
91
|
+
* - All `Send`-to-`connId` effects, wherever they appear, are accumulated
|
|
92
|
+
* in original order into one batch.
|
|
93
|
+
* - The wrapped batch is placed at the position of the FIRST collected
|
|
94
|
+
* `Send`-to-self, so the reducer's intended ordering relative to other
|
|
95
|
+
* effects (broadcasts, etc.) is preserved.
|
|
96
|
+
* - When NO `Send`-to-self exists, an empty labeled batch is appended at
|
|
97
|
+
* the end — the requester still observes the label correlation.
|
|
98
|
+
* - Every other effect (Broadcast, SendToNick, Disconnect, …) is left in
|
|
99
|
+
* place and in order; only the requester's own reply stream is wrapped.
|
|
100
|
+
*/
|
|
101
|
+
export function applyLabelToEffects(
|
|
102
|
+
effects: readonly EffectType[],
|
|
103
|
+
opts: LabelApplyOptions,
|
|
104
|
+
): EffectType[] {
|
|
105
|
+
const { label, connId } = opts;
|
|
106
|
+
if (label === undefined) return [...effects];
|
|
107
|
+
|
|
108
|
+
const selfLines: RawLine[] = [];
|
|
109
|
+
let firstSelfIdx = -1;
|
|
110
|
+
for (const [i, effect] of effects.entries()) {
|
|
111
|
+
if (effect.tag === 'Send' && effect.to === connId) {
|
|
112
|
+
if (firstSelfIdx === -1) firstSelfIdx = i;
|
|
113
|
+
selfLines.push(...effect.lines);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const batchEffect: EffectType = Effect.send(connId, wrapLabeledResponse(label, selfLines));
|
|
118
|
+
|
|
119
|
+
// No self-send to anchor on: append the empty labeled batch at the end.
|
|
120
|
+
if (firstSelfIdx === -1) {
|
|
121
|
+
return [...effects, batchEffect];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const out: EffectType[] = [];
|
|
125
|
+
for (const [i, effect] of effects.entries()) {
|
|
126
|
+
// Emit the single wrapped batch where the first self-send lived.
|
|
127
|
+
if (i === firstSelfIdx) out.push(batchEffect);
|
|
128
|
+
// Drop every original self-send (they are now inside the batch).
|
|
129
|
+
if (effect.tag === 'Send' && effect.to === connId) continue;
|
|
130
|
+
out.push(effect);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
@@ -16,6 +16,36 @@ export const Numerics = {
|
|
|
16
16
|
// Capability / stats admin / version
|
|
17
17
|
RPL_ADMINME: 256,
|
|
18
18
|
|
|
19
|
+
// LUSERS (RFC 2812 §4.6.2). Network-wide user/channel/server counts.
|
|
20
|
+
// 251 is the human-readable summary; 252–254 are emitted only when their
|
|
21
|
+
// count is non-zero (per RFC). 255 is the local-server summary. 265/266
|
|
22
|
+
// carry the optional local/global max-connection context.
|
|
23
|
+
RPL_LUSERCLIENT: 251,
|
|
24
|
+
RPL_LUSEROP: 252,
|
|
25
|
+
RPL_LUSERUNKNOWN: 253,
|
|
26
|
+
RPL_LUSERCHANNELS: 254,
|
|
27
|
+
RPL_LUSERME: 255,
|
|
28
|
+
RPL_LOCALUSERS: 265,
|
|
29
|
+
RPL_GLOBALUSERS: 266,
|
|
30
|
+
|
|
31
|
+
// STATS (RFC 2812 §4.6.3 / §5.1). 211 is link-info (the `l` query),
|
|
32
|
+
// 242 is the uptime line (the `u` query), and 219 terminates every
|
|
33
|
+
// STATS reply regardless of query letter. The remaining RPL_STATS*
|
|
34
|
+
// codes (212–218, 240–248) are deferred behind per-letter tests.
|
|
35
|
+
RPL_STATSLINKINFO: 211,
|
|
36
|
+
RPL_ENDOFSTATS: 219,
|
|
37
|
+
RPL_STATSUPTIME: 242,
|
|
38
|
+
|
|
39
|
+
// TRACE (RFC 2812 §4.6.3). In a single-server deployment TRACE collapses
|
|
40
|
+
// to a local-server line (200/206) plus optional per-connection detail
|
|
41
|
+
// (204 oper / 205 user), terminated by 262. Multi-hop S2S tracing is a
|
|
42
|
+
// PLAN non-goal.
|
|
43
|
+
RPL_TRACELINK: 200,
|
|
44
|
+
RPL_TRACEOPERATOR: 204,
|
|
45
|
+
RPL_TRACEUSER: 205,
|
|
46
|
+
RPL_TRACESERVER: 206,
|
|
47
|
+
RPL_ENDOFTRACE: 262,
|
|
48
|
+
|
|
19
49
|
// Away / userhost / ison
|
|
20
50
|
RPL_AWAY: 301,
|
|
21
51
|
RPL_USERHOST: 302,
|
|
@@ -23,6 +53,15 @@ export const Numerics = {
|
|
|
23
53
|
RPL_UNAWAY: 305,
|
|
24
54
|
RPL_NOWAWAY: 306,
|
|
25
55
|
|
|
56
|
+
// IRCv3 MONITOR (modern ISON replacement). The watchlist lives on
|
|
57
|
+
// {@link ConnectionState.monitorList}; transitions push 730/731
|
|
58
|
+
// asynchronously via the actor's nick-registry hooks.
|
|
59
|
+
RPL_MONONLINE: 730,
|
|
60
|
+
RPL_MONOFFLINE: 731,
|
|
61
|
+
RPL_MONLIST: 732,
|
|
62
|
+
RPL_ENDOFMONLIST: 733,
|
|
63
|
+
ERR_MONLISTFULL: 734,
|
|
64
|
+
|
|
26
65
|
// Version
|
|
27
66
|
RPL_VERSION: 351,
|
|
28
67
|
|
|
@@ -34,6 +73,12 @@ export const Numerics = {
|
|
|
34
73
|
RPL_ENDOFWHOIS: 318,
|
|
35
74
|
RPL_WHOISCHANNELS: 319,
|
|
36
75
|
RPL_WHOISACCOUNT: 330,
|
|
76
|
+
/**
|
|
77
|
+
* `276 RPL_WHOISSECURE` — target is connected over TLS (user mode `S`).
|
|
78
|
+
* Surfaced between the oper/account lines and the idle line so clients
|
|
79
|
+
* render it as part of the secure-connection WHOIS block.
|
|
80
|
+
*/
|
|
81
|
+
RPL_WHOISSECURE: 276,
|
|
37
82
|
RPL_WHOWASUSER: 314,
|
|
38
83
|
|
|
39
84
|
// LIST
|
|
@@ -61,10 +106,8 @@ export const Numerics = {
|
|
|
61
106
|
RPL_NAMREPLY: 353,
|
|
62
107
|
RPL_ENDOFNAMES: 366,
|
|
63
108
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
RPL_LINKS: 364,
|
|
67
|
-
RPL_ENDOFLINKS: 365,
|
|
109
|
+
// Ban / whowas. (The S2S `LINKS` verb and its `RPL_LINKS`/`RPL_ENDOFLINKS`
|
|
110
|
+
// numerics were dropped — S2S is a PLAN non-goal.)
|
|
68
111
|
RPL_BANLIST: 367,
|
|
69
112
|
RPL_ENDOFBANLIST: 368,
|
|
70
113
|
RPL_ENDOFWHOWAS: 369,
|
|
@@ -76,11 +119,12 @@ export const Numerics = {
|
|
|
76
119
|
RPL_MOTDSTART: 375,
|
|
77
120
|
RPL_ENDOFMOTD: 376,
|
|
78
121
|
|
|
79
|
-
// Oper. RPL_REHASHING
|
|
80
|
-
// config
|
|
122
|
+
// Oper. RPL_REHASHING is emitted by the oper-gated `REHASH` reducer when a
|
|
123
|
+
// config reload is requested (the actor re-invokes the adapter's loader).
|
|
124
|
+
// (The RFC 2812 `SERVICE` verb and its `RPL_YOURESERVICE` numeric were
|
|
125
|
+
// dropped — obsolete, never widely implemented.)
|
|
81
126
|
RPL_YOUREOPER: 381,
|
|
82
127
|
RPL_REHASHING: 382,
|
|
83
|
-
RPL_YOURESERVICE: 383,
|
|
84
128
|
RPL_TIME: 391,
|
|
85
129
|
|
|
86
130
|
// SASL (IRCv3 sasl-3.2)
|
|
@@ -102,7 +146,8 @@ export const Numerics = {
|
|
|
102
146
|
ERR_TOOMANYCHANNELS: 405,
|
|
103
147
|
ERR_WASNOSUCHNICK: 406,
|
|
104
148
|
ERR_TOOMANYTARGETS: 407,
|
|
105
|
-
ERR_NOSUCHSERVICE
|
|
149
|
+
// `ERR_NOSUCHSERVICE` (408) was dropped with the obsolete RFC 2812 `SERVICE`
|
|
150
|
+
// verb.
|
|
106
151
|
ERR_INVALIDCAPCMD: 410,
|
|
107
152
|
ERR_NORECIPIENT: 411,
|
|
108
153
|
ERR_NOTEXTTOSEND: 412,
|
|
@@ -121,8 +166,8 @@ export const Numerics = {
|
|
|
121
166
|
ERR_NOTONCHANNEL: 442,
|
|
122
167
|
ERR_USERONCHANNEL: 443,
|
|
123
168
|
ERR_NOLOGIN: 444,
|
|
124
|
-
ERR_SUMMONDISABLED
|
|
125
|
-
|
|
169
|
+
// `ERR_SUMMONDISABLED` (445) / `ERR_USERSDISABLED` (446) were dropped with
|
|
170
|
+
// the obsolete RFC 2812 `SUMMON`/`USERS` verbs.
|
|
126
171
|
ERR_NOTREGISTERED: 451,
|
|
127
172
|
ERR_NEEDMOREPARAMS: 461,
|
|
128
173
|
ERR_ALREADYREGISTRED: 462,
|
|
@@ -140,7 +185,8 @@ export const Numerics = {
|
|
|
140
185
|
ERR_BANLISTFULL: 478,
|
|
141
186
|
ERR_NOPRIVILEGES: 481,
|
|
142
187
|
ERR_CHANOPRIVSNEEDED: 482,
|
|
143
|
-
ERR_CANTKILLSERVER
|
|
188
|
+
// `ERR_CANTKILLSERVER` (483) was dropped with the S2S `SQUIT` verb — S2S is
|
|
189
|
+
// a PLAN non-goal.
|
|
144
190
|
ERR_NOOPERHOST: 491,
|
|
145
191
|
ERR_UMODEUNKNOWNFLAG: 501,
|
|
146
192
|
ERR_USERSDONTMATCH: 502,
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { RawLine } from '../effects.js';
|
|
19
|
+
import { MAX_WS_MESSAGE_BYTES, type WsFrameMode } from '../ws-framing.js';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing
|
|
@@ -65,7 +66,7 @@ export function applyServerTime(line: RawLine, caps: ReadonlySet<string>, now: n
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
/**
|
|
68
|
-
* Enforces the IRC
|
|
69
|
+
* Enforces the IRC line-length budget on `line.text`.
|
|
69
70
|
*
|
|
70
71
|
* For recipients that negotiated `message-tags`, the tag section (the
|
|
71
72
|
* leading `@... ` prefix) is part of the wire budget and is preserved
|
|
@@ -73,11 +74,22 @@ export function applyServerTime(line: RawLine, caps: ReadonlySet<string>, now: n
|
|
|
73
74
|
* For legacy recipients the entire `line.text` is the body and may be
|
|
74
75
|
* truncated from the right.
|
|
75
76
|
*
|
|
77
|
+
* The optional `mode` selects which byte budget applies: an IRCv3
|
|
78
|
+
* WebSocket spec mode (`spec-text` / `spec-binary`) caps the message at
|
|
79
|
+
* {@link MAX_WS_MESSAGE_BYTES} (510) directly, because WebSocket messages
|
|
80
|
+
* carry no trailing CR-LF; legacy mode (the default) caps at
|
|
81
|
+
* `MAX_LINE_BYTES - 2` (512 − 2 = 510). Both resolve to 510 today, but the
|
|
82
|
+
* mode-aware call sites need no edits if either constant ever changes.
|
|
83
|
+
*
|
|
76
84
|
* The returned {@link RawLine} is the original object when no truncation
|
|
77
85
|
* was needed, otherwise a fresh object with the truncated text.
|
|
78
86
|
*/
|
|
79
|
-
export function enforceLineLimit(
|
|
80
|
-
|
|
87
|
+
export function enforceLineLimit(
|
|
88
|
+
line: RawLine,
|
|
89
|
+
caps: ReadonlySet<string>,
|
|
90
|
+
mode?: WsFrameMode,
|
|
91
|
+
): RawLine {
|
|
92
|
+
const budget = isSpecMode(mode) ? MAX_WS_MESSAGE_BYTES : MAX_LINE_BYTES - TRAILING_CRLF_LEN;
|
|
81
93
|
if (line.text.length <= budget) return line;
|
|
82
94
|
|
|
83
95
|
const hasTags = caps.has('message-tags') && line.text.startsWith('@');
|
|
@@ -114,6 +126,14 @@ export function enforceLineLimit(line: RawLine, caps: ReadonlySet<string>): RawL
|
|
|
114
126
|
* Server-defined tags (no `+` prefix) are left in place — each is gated by
|
|
115
127
|
* its own capability (server-time, account-tag, …) applied elsewhere.
|
|
116
128
|
*
|
|
129
|
+
* Spec exception: IRCv3 `draft/typing` carves out a whitelist for the
|
|
130
|
+
* `+draft/typing` client tag. A recipient that announced `draft/typing`
|
|
131
|
+
* (but NOT `message-tags`) still receives `+draft/typing` so typing
|
|
132
|
+
* indicators work without the broader message-tags cap. `draft/read-marker`
|
|
133
|
+
* carves out the same exception for its `+draft/read-marker` tag so a
|
|
134
|
+
* read-marker update reaches the user's other connections. Every other
|
|
135
|
+
* client tag remains stripped for such recipients.
|
|
136
|
+
*
|
|
117
137
|
* When filtering removes every tag, the leading `@... ` section is dropped
|
|
118
138
|
* entirely so legacy clients never observe a bare tag prefix.
|
|
119
139
|
*
|
|
@@ -130,10 +150,17 @@ export function filterClientTags(line: RawLine, caps: ReadonlySet<string>): RawL
|
|
|
130
150
|
const rest = line.text.slice(spaceIdx + 1);
|
|
131
151
|
const entries = tagSection.split(';');
|
|
132
152
|
|
|
153
|
+
const typingWhitelisted = caps.has('draft/typing');
|
|
154
|
+
const readMarkerWhitelisted = caps.has('draft/read-marker');
|
|
133
155
|
const hasClientTag = entries.some((entry) => clientTagKey(entry).startsWith('+'));
|
|
134
156
|
if (!hasClientTag) return line;
|
|
135
157
|
|
|
136
|
-
const kept = entries.filter((entry) =>
|
|
158
|
+
const kept = entries.filter((entry) => {
|
|
159
|
+
const key = clientTagKey(entry);
|
|
160
|
+
if (!key.startsWith('+')) return true;
|
|
161
|
+
if (typingWhitelisted && key === '+draft/typing') return true;
|
|
162
|
+
return readMarkerWhitelisted && key === '+draft/read-marker';
|
|
163
|
+
});
|
|
137
164
|
if (kept.length === 0) return { text: rest };
|
|
138
165
|
return { text: `@${kept.join(';')} ${rest}` };
|
|
139
166
|
}
|
|
@@ -143,3 +170,8 @@ function clientTagKey(entry: string): string {
|
|
|
143
170
|
const eq = entry.indexOf('=');
|
|
144
171
|
return eq === -1 ? entry : entry.slice(0, eq);
|
|
145
172
|
}
|
|
173
|
+
|
|
174
|
+
/** True when `mode` is an IRCv3 WebSocket spec framing mode (not legacy/absent). */
|
|
175
|
+
function isSpecMode(mode: WsFrameMode | undefined): boolean {
|
|
176
|
+
return mode === 'spec-text' || mode === 'spec-binary';
|
|
177
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IRCv3 `standard-replies` — `FAIL` / `WARN` / `NOTE` dispatch helper.
|
|
3
|
+
*
|
|
4
|
+
* Spec: https://ircv3.net/specs/extensions/standard-replies-3.0
|
|
5
|
+
*
|
|
6
|
+
* Modern IRC clients that negotiated the `standard-replies` cap accept the
|
|
7
|
+
* structured reply form
|
|
8
|
+
*
|
|
9
|
+
* :<server> FAIL <command> <code> [<context>...] :<description>
|
|
10
|
+
* :<server> WARN <command> <code> [<context>...] :<description>
|
|
11
|
+
* :<server> NOTE <command> <code> [<context>...] :<description>
|
|
12
|
+
*
|
|
13
|
+
* in place of the legacy `:server NNN nick ... :trailing` numerics for a
|
|
14
|
+
* curated subset of errors. Numeric-only replies (MOTD lines, NAMES,
|
|
15
|
+
* welcome block, …) stay on the numeric form — only the curated error
|
|
16
|
+
* numerics are translated.
|
|
17
|
+
*
|
|
18
|
+
* This module is the data-driven mapping plus a single pure dispatch
|
|
19
|
+
* helper ({@link toStandardReply}) that the dispatch / outbound layer
|
|
20
|
+
* applies per recipient. Translating at the outbound layer (rather than
|
|
21
|
+
* scattering `if (caps.has('standard-replies'))` through every reducer)
|
|
22
|
+
* keeps the reducer code cap-agnostic, matching the model already used
|
|
23
|
+
* for `server-time` and `message-tags`.
|
|
24
|
+
*
|
|
25
|
+
* Reducers keep emitting the canonical numeric line; the dispatch layer
|
|
26
|
+
* runs this helper to translate the curated numerics for cap-enabled
|
|
27
|
+
* recipients and passes everything else through verbatim.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { RawLine } from '../effects.js';
|
|
31
|
+
import { Numerics } from './numerics.js';
|
|
32
|
+
|
|
33
|
+
/** One of the IRCv3 standard-reply verbs. */
|
|
34
|
+
export type ReplyKind = 'FAIL' | 'WARN' | 'NOTE';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One row of the {@link numericToStandardReply} table, as a discriminated
|
|
38
|
+
* union over where the COMMAND token comes from on the wire.
|
|
39
|
+
*
|
|
40
|
+
* - When {@link CommandFromMiddle.commandFromMiddle} is `true`, the
|
|
41
|
+
* numeric's middle parameter IS the failed command name (true for
|
|
42
|
+
* `461 ERR_NEEDMOREPARAMS`, whose middle is the command the client
|
|
43
|
+
* undersupplied).
|
|
44
|
+
* - When {@link CommandFixed.commandFromMiddle} is `false`, a fixed
|
|
45
|
+
* {@link CommandFixed.command} is emitted (true for `432`/`433`,
|
|
46
|
+
* always `NICK`, and for SASL failures, always `AUTHENTICATE`).
|
|
47
|
+
*
|
|
48
|
+
* The discriminated union lets `pickCommand` return `mapping.command`
|
|
49
|
+
* directly in the fixed branch with no defensive `?? null` — the type
|
|
50
|
+
* system proves `command` is always set there.
|
|
51
|
+
*/
|
|
52
|
+
export type StandardReplyMapping = CommandFromMiddle | CommandFixed;
|
|
53
|
+
|
|
54
|
+
/** A row whose COMMAND token comes from the numeric's middle parameter. */
|
|
55
|
+
export interface CommandFromMiddle {
|
|
56
|
+
/** Discriminant: true → read COMMAND from `parsed.middle[0]`. */
|
|
57
|
+
commandFromMiddle: true;
|
|
58
|
+
/** Which of `FAIL`/`WARN`/`NOTE` to emit. */
|
|
59
|
+
kind: ReplyKind;
|
|
60
|
+
/** Stable wire code (`INVALID_PARAMS`, …). */
|
|
61
|
+
code: string;
|
|
62
|
+
/** Always false here — the middle is consumed as the command, not context. */
|
|
63
|
+
contextFromMiddle: false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A row whose COMMAND token is a fixed string. */
|
|
67
|
+
export interface CommandFixed {
|
|
68
|
+
/** Discriminant: false → read COMMAND from {@link command}. */
|
|
69
|
+
commandFromMiddle: false;
|
|
70
|
+
/** Which of `FAIL`/`WARN`/`NOTE` to emit. */
|
|
71
|
+
kind: ReplyKind;
|
|
72
|
+
/** Stable wire code (`NO_SUCH_CHANNEL`, `INVALID_NICK`, …). */
|
|
73
|
+
code: string;
|
|
74
|
+
/** Fixed command token emitted after the FAIL/WARN/NOTE verb. */
|
|
75
|
+
command: string;
|
|
76
|
+
/**
|
|
77
|
+
* True iff the numeric's middle parameter carries a context token
|
|
78
|
+
* (the bad nick, the bad channel) that should follow the code.
|
|
79
|
+
*/
|
|
80
|
+
contextFromMiddle: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The curated numeric → standard-reply mapping.
|
|
85
|
+
*
|
|
86
|
+
* Only a deliberately small subset of error numerics is translated —
|
|
87
|
+
* the ones that carry enough context for the structured form to be a
|
|
88
|
+
* strict improvement over the bare numeric. Numeric-only families
|
|
89
|
+
* (MOTD, NAMES, the welcome block, mode replies, …) stay on the
|
|
90
|
+
* numeric form per the spec.
|
|
91
|
+
*/
|
|
92
|
+
export const numericToStandardReply: ReadonlyMap<number, StandardReplyMapping> = new Map<
|
|
93
|
+
number,
|
|
94
|
+
StandardReplyMapping
|
|
95
|
+
>([
|
|
96
|
+
[
|
|
97
|
+
Numerics.ERR_NEEDMOREPARAMS,
|
|
98
|
+
{
|
|
99
|
+
kind: 'FAIL',
|
|
100
|
+
code: 'INVALID_PARAMS',
|
|
101
|
+
commandFromMiddle: true,
|
|
102
|
+
contextFromMiddle: false,
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
[
|
|
106
|
+
Numerics.ERR_NOSUCHCHANNEL,
|
|
107
|
+
{
|
|
108
|
+
kind: 'FAIL',
|
|
109
|
+
code: 'NO_SUCH_CHANNEL',
|
|
110
|
+
commandFromMiddle: false,
|
|
111
|
+
command: 'JOIN',
|
|
112
|
+
contextFromMiddle: true,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
[
|
|
116
|
+
Numerics.ERR_ERRONEUSNICKNAME,
|
|
117
|
+
{
|
|
118
|
+
kind: 'FAIL',
|
|
119
|
+
code: 'INVALID_NICK',
|
|
120
|
+
commandFromMiddle: false,
|
|
121
|
+
command: 'NICK',
|
|
122
|
+
contextFromMiddle: true,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
[
|
|
126
|
+
Numerics.ERR_NICKNAMEINUSE,
|
|
127
|
+
{
|
|
128
|
+
kind: 'FAIL',
|
|
129
|
+
code: 'NICKNAME_IN_USE',
|
|
130
|
+
commandFromMiddle: false,
|
|
131
|
+
command: 'NICK',
|
|
132
|
+
contextFromMiddle: true,
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
[
|
|
136
|
+
Numerics.ERR_SASLFAIL,
|
|
137
|
+
{
|
|
138
|
+
kind: 'FAIL',
|
|
139
|
+
code: 'SASL_FAILED',
|
|
140
|
+
commandFromMiddle: false,
|
|
141
|
+
command: 'AUTHENTICATE',
|
|
142
|
+
contextFromMiddle: false,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Translates a single outbound {@link RawLine} for a recipient whose
|
|
149
|
+
* negotiated caps are `caps`.
|
|
150
|
+
*
|
|
151
|
+
* - If the recipient lacks the `standard-replies` cap, the line is
|
|
152
|
+
* returned unchanged (the legacy numeric still ships).
|
|
153
|
+
* - If the line is not a `:server NNN …` numeric, it is returned
|
|
154
|
+
* unchanged (PRIVMSG, BATCH, FAIL/WARN/NOTE already emitted by
|
|
155
|
+
* reducers, etc.).
|
|
156
|
+
* - If the numeric is not in {@link numericToStandardReply}, the line
|
|
157
|
+
* is returned unchanged.
|
|
158
|
+
* - Otherwise the line is rewritten into the structured
|
|
159
|
+
* `FAIL`/`WARN`/`NOTE` form using the table.
|
|
160
|
+
*
|
|
161
|
+
* The input is never mutated; the original object is returned whenever
|
|
162
|
+
* no translation occurred so callers can cheaply detect "no change".
|
|
163
|
+
*/
|
|
164
|
+
export function toStandardReply(line: RawLine, caps: ReadonlySet<string>): RawLine {
|
|
165
|
+
if (!caps.has('standard-replies')) return line;
|
|
166
|
+
if (!line.text.startsWith(':')) return line;
|
|
167
|
+
|
|
168
|
+
const parsed = parseNumericLine(line.text);
|
|
169
|
+
if (parsed === null) return line;
|
|
170
|
+
|
|
171
|
+
const mapping = numericToStandardReply.get(parsed.code);
|
|
172
|
+
if (mapping === undefined) return line;
|
|
173
|
+
|
|
174
|
+
const command = pickCommand(parsed, mapping);
|
|
175
|
+
if (command === null) return line;
|
|
176
|
+
|
|
177
|
+
// The structured form requires a `:description` token. A curated numeric
|
|
178
|
+
// missing its trailing parameter (malformed input that never ships from a
|
|
179
|
+
// reducer) is left on the bare numeric so we never emit an empty FAIL.
|
|
180
|
+
if (parsed.trailing === undefined) return line;
|
|
181
|
+
|
|
182
|
+
const context = pickContext(parsed, mapping);
|
|
183
|
+
const trailing = parsed.trailing;
|
|
184
|
+
|
|
185
|
+
const head = `:${parsed.server} ${mapping.kind} ${command} ${mapping.code}`;
|
|
186
|
+
const withContext = context !== null ? `${head} ${context}` : head;
|
|
187
|
+
return { text: `${withContext} :${trailing}` };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Internal: the parsed shape of a `:server NNN nick [middle] [:trailing]` line. */
|
|
191
|
+
interface ParsedNumeric {
|
|
192
|
+
server: string;
|
|
193
|
+
code: number;
|
|
194
|
+
/** Every token between the nick and the trailing `:`-parameter. May be empty. */
|
|
195
|
+
middle: string[];
|
|
196
|
+
/** The trailing `:`-prefixed parameter, with the leading `:` stripped. */
|
|
197
|
+
trailing: string | undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Parses a single `:server NNN ...` line into its components.
|
|
202
|
+
*
|
|
203
|
+
* Returns `null` for any shape this server does not emit for numerics:
|
|
204
|
+
* lines without a server prefix, lines whose second token is not a
|
|
205
|
+
* three-digit numeric, and lines with no nick token. The trailing
|
|
206
|
+
* `:`-parameter is optional (a handful of numerics — currently none in
|
|
207
|
+
* the curated set — ship without one).
|
|
208
|
+
*/
|
|
209
|
+
function parseNumericLine(text: string): ParsedNumeric | null {
|
|
210
|
+
// Strip the leading `:server ` prefix.
|
|
211
|
+
const firstSpace = text.indexOf(' ');
|
|
212
|
+
if (firstSpace === -1) return null;
|
|
213
|
+
const server = text.slice(1, firstSpace);
|
|
214
|
+
|
|
215
|
+
const rest = text.slice(firstSpace + 1);
|
|
216
|
+
const secondSpace = rest.indexOf(' ');
|
|
217
|
+
if (secondSpace === -1) return null;
|
|
218
|
+
const codeTok = rest.slice(0, secondSpace);
|
|
219
|
+
if (!/^\d{3}$/.test(codeTok)) return null;
|
|
220
|
+
const code = Number.parseInt(codeTok, 10);
|
|
221
|
+
|
|
222
|
+
const afterCode = rest.slice(secondSpace + 1);
|
|
223
|
+
|
|
224
|
+
// The nick token is mandatory between the code and the optional trailing.
|
|
225
|
+
// Empty after-code or an `:`-prefixed after-code (the trailing param
|
|
226
|
+
// immediately following the code) is malformed for a numeric this server
|
|
227
|
+
// emits; bail so the caller passes the line through verbatim.
|
|
228
|
+
if (afterCode.length === 0) return null;
|
|
229
|
+
if (afterCode[0] === ':') return null;
|
|
230
|
+
|
|
231
|
+
// Split off the trailing `:`-parameter if present.
|
|
232
|
+
const trailingIdx = findTrailingColon(afterCode);
|
|
233
|
+
let head = afterCode;
|
|
234
|
+
let trailing: string | undefined;
|
|
235
|
+
if (trailingIdx !== -1) {
|
|
236
|
+
head = afterCode.slice(0, trailingIdx).trimEnd();
|
|
237
|
+
trailing = afterCode.slice(trailingIdx + 1);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// head starts with the nick and optional middle params.
|
|
241
|
+
const tokens = head.length === 0 ? [] : head.split(' ');
|
|
242
|
+
if (tokens.length === 0) return null;
|
|
243
|
+
// Drop the nick — it is not used in the structured form (the reply is
|
|
244
|
+
// routed to a specific recipient, not addressed by nick).
|
|
245
|
+
const middle = tokens.slice(1);
|
|
246
|
+
|
|
247
|
+
return { server, code, middle, trailing };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Returns the index of the ` :` that introduces the trailing parameter,
|
|
252
|
+
* or `-1` when the line has no trailing. The leading space is required
|
|
253
|
+
* so a `:` inside a middle token (e.g. a malformed channel name) is not
|
|
254
|
+
* misread.
|
|
255
|
+
*/
|
|
256
|
+
function findTrailingColon(s: string): number {
|
|
257
|
+
for (let i = 0; i < s.length; i++) {
|
|
258
|
+
if (s[i] === ' ' && s[i + 1] === ':') return i + 1;
|
|
259
|
+
}
|
|
260
|
+
return -1;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Resolves the COMMAND token for the structured reply per the mapping. */
|
|
264
|
+
function pickCommand(parsed: ParsedNumeric, mapping: StandardReplyMapping): string | null {
|
|
265
|
+
if (mapping.commandFromMiddle) {
|
|
266
|
+
const cmd = parsed.middle[0];
|
|
267
|
+
return cmd === undefined ? null : cmd;
|
|
268
|
+
}
|
|
269
|
+
return mapping.command;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Resolves the optional CONTEXT token for the structured reply per the mapping. */
|
|
273
|
+
function pickContext(parsed: ParsedNumeric, mapping: StandardReplyMapping): string | null {
|
|
274
|
+
if (!mapping.contextFromMiddle) return null;
|
|
275
|
+
const ctx = parsed.middle[0];
|
|
276
|
+
return ctx ?? null;
|
|
277
|
+
}
|
|
@@ -20,13 +20,21 @@ export type RegistrationState = 'pre-registration' | 'registering' | 'registered
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* User modes applied to a connection. The IRC user-mode letters are
|
|
23
|
-
* `i` (invisible), `o` (oper), `w` (wallops), `s` (server notices)
|
|
23
|
+
* `i` (invisible), `o` (oper), `w` (wallops), `s` (server notices),
|
|
24
|
+
* `S` (TLS connected — read-only, set by the transport at registration).
|
|
24
25
|
*/
|
|
25
26
|
export interface UserModes {
|
|
26
27
|
invisible: boolean;
|
|
27
28
|
oper: boolean;
|
|
28
29
|
wallops: boolean;
|
|
29
30
|
serverNotices: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* User mode `S` — the connection arrived over TLS (wss or irc+tls).
|
|
33
|
+
* Read-only: set once by the registration reducer from
|
|
34
|
+
* {@link ConnectionState.secure} and never mutated afterwards. Not
|
|
35
|
+
* settable via `MODE` (the parser rejects `+S`/`-S` with `501`).
|
|
36
|
+
*/
|
|
37
|
+
tls: boolean;
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
/**
|
|
@@ -84,6 +92,15 @@ export interface ConnectionState {
|
|
|
84
92
|
* `CAP END` arrives (per IRCv3 capability-negotiation spec).
|
|
85
93
|
*/
|
|
86
94
|
capNegotiating: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Transport-level fact: true when the connection arrived over TLS
|
|
97
|
+
* (`wss://` WebSocket or `irc+tls://` raw TCP). Set once by the adapter
|
|
98
|
+
* at construction from the platform's transport metadata and read by the
|
|
99
|
+
* registration reducer to surface user mode `S` ({@link UserModes.tls}).
|
|
100
|
+
* Defaults to `false` so existing callers (plain ws test harnesses) keep
|
|
101
|
+
* the pre-TLS-mode behaviour.
|
|
102
|
+
*/
|
|
103
|
+
secure: boolean;
|
|
87
104
|
/** IRCv3 capabilities negotiated via `CAP`. */
|
|
88
105
|
caps: Set<string>;
|
|
89
106
|
/** Away reason; absent when not away. */
|
|
@@ -109,6 +126,18 @@ export interface ConnectionState {
|
|
|
109
126
|
* PART/re-JOIN cycle with no new activity replays nothing.
|
|
110
127
|
*/
|
|
111
128
|
lastReadMarkers?: Map<string, string>;
|
|
129
|
+
/**
|
|
130
|
+
* Per-connection MONITOR watchlist (IRCv3 `MONITOR`). Each entry is the
|
|
131
|
+
* case-folded (rfc1459) spelling of a watched nick; the display spelling
|
|
132
|
+
* is resolved at emission time via the nick registry. Lazily allocated
|
|
133
|
+
* on first `MONITOR +`; persisted verbatim so a reconnect can restore
|
|
134
|
+
* the watchlist without re-issuing every `MONITOR` line.
|
|
135
|
+
*
|
|
136
|
+
* Capped at the deployment's `MONITOR=<n>` ISUPPORT value (default 30);
|
|
137
|
+
* the {@link monitorReducer} enforces the cap and emits `734
|
|
138
|
+
* ERR_MONLISTFULL` for rejected nicks.
|
|
139
|
+
*/
|
|
140
|
+
monitorList?: Set<string>;
|
|
112
141
|
}
|
|
113
142
|
|
|
114
143
|
/**
|
|
@@ -147,6 +176,7 @@ export function createConnection(opts: CreateConnectionOptions): ConnectionState
|
|
|
147
176
|
id: opts.id,
|
|
148
177
|
registration: 'pre-registration',
|
|
149
178
|
capNegotiating: false,
|
|
179
|
+
secure: false,
|
|
150
180
|
caps: new Set<string>(),
|
|
151
181
|
joinedChannels: new Set<ChanName>(),
|
|
152
182
|
userModes: {
|
|
@@ -154,6 +184,7 @@ export function createConnection(opts: CreateConnectionOptions): ConnectionState
|
|
|
154
184
|
oper: false,
|
|
155
185
|
wallops: false,
|
|
156
186
|
serverNotices: false,
|
|
187
|
+
tls: false,
|
|
157
188
|
},
|
|
158
189
|
lastSeen: opts.lastSeen ?? opts.connectedSince,
|
|
159
190
|
connectedSince: opts.connectedSince,
|