serverless-ircd 0.4.0 → 0.5.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.
Files changed (124) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/README.md +88 -19
  3. package/apps/aws-stack/README.md +4 -3
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/aws-stack/src/aws-stack.ts +32 -4
  6. package/apps/aws-stack/tests/stack.test.ts +47 -1
  7. package/apps/cf-tcp-container/package.json +1 -1
  8. package/apps/cf-worker/package.json +1 -1
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/server.ts +129 -21
  11. package/apps/local-cli/tests/e2e.test.ts +1 -1
  12. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  13. package/package.json +2 -2
  14. package/packages/aws-adapter/package.json +1 -1
  15. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  16. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  17. package/packages/aws-adapter/src/handlers/default.ts +63 -5
  18. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  19. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  20. package/packages/aws-adapter/src/index.ts +2 -0
  21. package/packages/aws-adapter/src/serialize.ts +11 -1
  22. package/packages/aws-adapter/src/stats.ts +80 -0
  23. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  24. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  25. package/packages/aws-adapter/tests/connect.test.ts +97 -1
  26. package/packages/aws-adapter/tests/handlers.test.ts +148 -0
  27. package/packages/aws-adapter/tests/nlb-stream.test.ts +2 -0
  28. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  29. package/packages/cf-adapter/package.json +5 -1
  30. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  31. package/packages/cf-adapter/src/channel-do.ts +2 -2
  32. package/packages/cf-adapter/src/connection-do.ts +182 -54
  33. package/packages/cf-adapter/src/env.ts +25 -6
  34. package/packages/cf-adapter/src/index.ts +2 -0
  35. package/packages/cf-adapter/src/registry-do.ts +22 -3
  36. package/packages/cf-adapter/src/sharding.ts +1 -2
  37. package/packages/cf-adapter/src/stats.ts +65 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  41. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  42. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  43. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  44. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  45. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  46. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  47. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  48. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  49. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  50. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  51. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  52. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  53. package/packages/cf-adapter/wrangler.test.toml +7 -0
  54. package/packages/in-memory-runtime/package.json +1 -1
  55. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  56. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  57. package/packages/irc-core/package.json +1 -1
  58. package/packages/irc-core/src/admission.ts +16 -15
  59. package/packages/irc-core/src/caps/capabilities.ts +1 -1
  60. package/packages/irc-core/src/commands/index.ts +8 -0
  61. package/packages/irc-core/src/commands/invite.ts +2 -4
  62. package/packages/irc-core/src/commands/isupport.ts +6 -2
  63. package/packages/irc-core/src/commands/kick.ts +2 -4
  64. package/packages/irc-core/src/commands/kill.ts +127 -0
  65. package/packages/irc-core/src/commands/list.ts +1 -1
  66. package/packages/irc-core/src/commands/lusers.ts +204 -0
  67. package/packages/irc-core/src/commands/mode.ts +4 -8
  68. package/packages/irc-core/src/commands/names.ts +3 -5
  69. package/packages/irc-core/src/commands/part.ts +2 -4
  70. package/packages/irc-core/src/commands/rehash.ts +119 -0
  71. package/packages/irc-core/src/commands/setname.ts +109 -0
  72. package/packages/irc-core/src/commands/stats.ts +152 -0
  73. package/packages/irc-core/src/commands/topic.ts +2 -4
  74. package/packages/irc-core/src/commands/trace.ts +137 -0
  75. package/packages/irc-core/src/commands/wallops.ts +118 -0
  76. package/packages/irc-core/src/config.ts +7 -0
  77. package/packages/irc-core/src/effects.ts +27 -1
  78. package/packages/irc-core/src/index.ts +2 -0
  79. package/packages/irc-core/src/ports.ts +179 -0
  80. package/packages/irc-core/src/protocol/numerics.ts +42 -11
  81. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  82. package/packages/irc-core/src/types.ts +8 -1
  83. package/packages/irc-core/src/ws-framing.ts +132 -0
  84. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  85. package/packages/irc-core/tests/admission.test.ts +18 -0
  86. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  87. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  88. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  89. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  90. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  91. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  92. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  93. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  94. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  95. package/packages/irc-core/tests/effects.test.ts +14 -0
  96. package/packages/irc-core/tests/numerics.test.ts +90 -0
  97. package/packages/irc-core/tests/outbound.test.ts +51 -0
  98. package/packages/irc-core/tests/ports.test.ts +22 -0
  99. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  100. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  101. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  102. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  103. package/packages/irc-server/package.json +1 -1
  104. package/packages/irc-server/src/actor.ts +249 -16
  105. package/packages/irc-server/src/dispatch.ts +1 -0
  106. package/packages/irc-server/src/routing.ts +3 -0
  107. package/packages/irc-server/src/runtime.ts +31 -0
  108. package/packages/irc-server/src/transport.ts +10 -7
  109. package/packages/irc-server/tests/actor.test.ts +1089 -1
  110. package/packages/irc-server/tests/dispatch.test.ts +37 -0
  111. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  112. package/packages/irc-server/tests/routing.test.ts +1 -0
  113. package/packages/irc-server/tests/runtime.test.ts +7 -0
  114. package/packages/irc-test-support/package.json +1 -1
  115. package/packages/irc-test-support/src/scenarios.ts +9 -1
  116. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  117. package/pnpm-workspace.yaml +1 -0
  118. package/tools/ci-hardening/package.json +1 -1
  119. package/tools/package.json +6 -1
  120. package/tools/seed-cf-accounts.ts +4 -1
  121. package/tools/tcp-ws-forwarder/package.json +1 -1
  122. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  123. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  124. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import { caseFold } from './case-fold.js';
10
+ import type { ChannelState } from './state/channel.js';
11
+ import type { ConnId, ConnectionState } from './state/connection.js';
10
12
 
11
13
  /** Read-only wall clock, epoch milliseconds. */
12
14
  export interface Clock {
@@ -670,6 +672,183 @@ export class InMemoryNickHistoryStore implements NickHistoryStore {
670
672
  }
671
673
  }
672
674
 
675
+ // ============================================================================
676
+ // Network statistics (LUSERS / STATS)
677
+ // ============================================================================
678
+
679
+ /**
680
+ * Snapshot of network-wide counts returned by a {@link ServerStats} backend.
681
+ *
682
+ * The shape mirrors the fields the `LUSERS` and `STATS` reducers need to
683
+ * format their numerics:
684
+ * - `users` / `invisible`: the split registered-user counts that drive
685
+ * `251 RPL_LUSERCLIENT` ("There are X users and Y invisible on Z
686
+ * servers"). `users` is the *visible* (non-`+i`) registered count;
687
+ * `invisible` is the `+i` registered count. Total registered users
688
+ * is `users + invisible`.
689
+ * - `opers`: count of IRC operators; emitted as `252 RPL_LUSEROP` when
690
+ * non-zero. An oper counts here regardless of `+i` (an `+o` oper who
691
+ * is also `+i` still bumps `opers`, `invisible`, and `users` is
692
+ * computed as visible = total-registered − invisible).
693
+ * - `unknownConnections`: pre-registration connection count; emitted as
694
+ * `253 RPL_LUSERUNKNOWN` when non-zero.
695
+ * - `channels`: live channel count; emitted as `254 RPL_LUSERCHANNELS`
696
+ * when non-zero.
697
+ * - `servers`: server count seen by `251`/`255`. Always 1 for the
698
+ * single-server serverless deployment; S2S aggregation is a PLAN
699
+ * non-goal.
700
+ * - `localConns` / `globalConns`: total connection counts (registered
701
+ * + pre-registration) for the optional `265 RPL_LOCALUSERS` /
702
+ * `266 RPL_GLOBALUSERS` numerics. Equal for a single-server network.
703
+ * - `maxLocalConns` / `maxGlobalConns`: high-water marks accompanying
704
+ * `265`/`266`. When the backend has no recorded max it reports the
705
+ * current count (so the wire numeric never claims a max below live).
706
+ * - `uptimeStartedAt`: epoch-ms the server (or stats backend) started
707
+ * counting from. Drives the `STATS u` uptime line.
708
+ */
709
+ export interface ServerStatsSnapshot {
710
+ users: number;
711
+ invisible: number;
712
+ opers: number;
713
+ unknownConnections: number;
714
+ channels: number;
715
+ servers: number;
716
+ localConns: number;
717
+ globalConns: number;
718
+ maxLocalConns: number;
719
+ maxGlobalConns: number;
720
+ uptimeStartedAt: number;
721
+ }
722
+
723
+ /**
724
+ * Port that aggregates network-wide statistics for `LUSERS` and `STATS`.
725
+ *
726
+ * Unlike the synchronous {@link MotdProvider} / {@link AccountStore} ports
727
+ * (which reducers consume directly via {@link Ctx}), this port is
728
+ * **asynchronous** (`Promise`-returning) because the aggregation crosses
729
+ * every connection and channel in the deployment — it cannot be pre-loaded
730
+ * into a synchronous view the way MOTD or a credential table can. The
731
+ * actor layer awaits {@link getStats} once per `LUSERS` / `STATS`
732
+ * invocation, then passes the resulting {@link ServerStatsSnapshot} to the
733
+ * pure reducers (`lusersReducer`, `statsReducer`). This mirrors the
734
+ * `listChannels` → `listReducer` pattern.
735
+ *
736
+ * Each adapter implements this against its authoritative store:
737
+ * - in-memory runtime: derive from its `Map`s (reference impl
738
+ * {@link InMemoryStats}).
739
+ * - CF: aggregate via `listChannels` / connection-enumeration RPCs.
740
+ * - AWS: `Scan` / `Count` queries against `Connections` / `ChannelMeta`.
741
+ */
742
+ export interface ServerStats {
743
+ getStats(): Promise<ServerStatsSnapshot>;
744
+ }
745
+
746
+ /**
747
+ * Aggregates a snapshot from already-collected connection state and a
748
+ * channel count.
749
+ *
750
+ * Pure: backends that gather their data through async I/O (CF RPC fan-out,
751
+ * AWS DynamoDB `Scan`) call this once they have the raw inputs. The
752
+ * {@link InMemoryStats} reference backend delegates here so the
753
+ * classification logic (oper / invisible / unknown) lives in exactly one
754
+ * place and is unit-tested independently of any adapter's I/O.
755
+ *
756
+ * Counting rules (RFC 2812 §4.6.2 + modern practice):
757
+ * - `users` is the **visible** registered count (total registered minus
758
+ * `+i`); the first number in "There are X users and Y invisible".
759
+ * - `invisible` is the `+i` registered count; the second number.
760
+ * - An oper who is also `+i` increments `opers`, `invisible`, and
761
+ * reduces `users` (because they are invisible) — matching charybdis.
762
+ * - `localConns` is every tracked connection (registered + unknown).
763
+ */
764
+ export function computeStatsSnapshot(
765
+ connections: Iterable<ConnectionState>,
766
+ channelCount: number,
767
+ uptimeStartedAt: number,
768
+ opts?: { maxLocalConns?: number; maxGlobalConns?: number; servers?: number },
769
+ ): ServerStatsSnapshot {
770
+ let registered = 0;
771
+ let invisible = 0;
772
+ let opers = 0;
773
+ let unknown = 0;
774
+ let total = 0;
775
+ for (const conn of connections) {
776
+ total++;
777
+ if (conn.registration !== 'registered') {
778
+ unknown++;
779
+ continue;
780
+ }
781
+ registered++;
782
+ if (conn.userModes.invisible) invisible++;
783
+ if (conn.userModes.oper) opers++;
784
+ }
785
+ return {
786
+ users: registered - invisible,
787
+ invisible,
788
+ opers,
789
+ unknownConnections: unknown,
790
+ channels: channelCount,
791
+ servers: opts?.servers ?? 1,
792
+ localConns: total,
793
+ globalConns: total,
794
+ // The wire numeric must never claim a max below the current live count;
795
+ // when the backend's recorded max is stale (or never seeded) the live
796
+ // count is the lower bound.
797
+ maxLocalConns: Math.max(opts?.maxLocalConns ?? 0, total),
798
+ maxGlobalConns: Math.max(opts?.maxGlobalConns ?? 0, total),
799
+ uptimeStartedAt,
800
+ };
801
+ }
802
+
803
+ /**
804
+ * Reference in-memory {@link ServerStats} backed by the live `Map`s the
805
+ * {@link InMemoryRuntime} (and tests) already maintain.
806
+ *
807
+ * The constructor takes *references* to the runtime's mutable maps; later
808
+ * mutations are visible on the next {@link getStats} call without
809
+ * re-binding. Used by the local CLI, by integration tests, and as the
810
+ * contract spec the CF / AWS backends mirror.
811
+ *
812
+ * `uptimeStartedAt` is supplied at construction (typically the runtime's
813
+ * boot time). `maxLocalConns` / `maxGlobalConns` are optional high-water
814
+ * marks; when omitted the live count is reported as the max so `265`/`266`
815
+ * never regress below the current connection total.
816
+ */
817
+ export class InMemoryStats implements ServerStats {
818
+ private readonly connections: ReadonlyMap<ConnId, ConnectionState>;
819
+ private readonly channels: ReadonlyMap<string, ChannelState>;
820
+ private readonly uptimeStartedAt: number;
821
+ private readonly maxLocalConns: number | undefined;
822
+ private readonly maxGlobalConns: number | undefined;
823
+
824
+ constructor(opts: {
825
+ connections: ReadonlyMap<ConnId, ConnectionState>;
826
+ channels: ReadonlyMap<string, ChannelState>;
827
+ uptimeStartedAt: number;
828
+ clock?: Clock;
829
+ maxLocalConns?: number;
830
+ maxGlobalConns?: number;
831
+ }) {
832
+ this.connections = opts.connections;
833
+ this.channels = opts.channels;
834
+ this.uptimeStartedAt = opts.uptimeStartedAt;
835
+ this.maxLocalConns = opts.maxLocalConns;
836
+ this.maxGlobalConns = opts.maxGlobalConns;
837
+ }
838
+
839
+ async getStats(): Promise<ServerStatsSnapshot> {
840
+ return computeStatsSnapshot(
841
+ this.connections.values(),
842
+ this.channels.size,
843
+ this.uptimeStartedAt,
844
+ {
845
+ ...(this.maxLocalConns !== undefined ? { maxLocalConns: this.maxLocalConns } : {}),
846
+ ...(this.maxGlobalConns !== undefined ? { maxGlobalConns: this.maxGlobalConns } : {}),
847
+ },
848
+ );
849
+ }
850
+ }
851
+
673
852
  // ============================================================================
674
853
  // Observability — structured logger
675
854
  // ============================================================================
@@ -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,
@@ -61,10 +91,8 @@ export const Numerics = {
61
91
  RPL_NAMREPLY: 353,
62
92
  RPL_ENDOFNAMES: 366,
63
93
 
64
- // Links / ban / whowas. RPL_LINKS/RPL_ENDOFLINKS retained for the deferred
65
- // LINKS verb (S2S server list PLAN non-goal; see actor default-route triage).
66
- RPL_LINKS: 364,
67
- RPL_ENDOFLINKS: 365,
94
+ // Ban / whowas. (The S2S `LINKS` verb and its `RPL_LINKS`/`RPL_ENDOFLINKS`
95
+ // numerics were dropped — S2S is a PLAN non-goal.)
68
96
  RPL_BANLIST: 367,
69
97
  RPL_ENDOFBANLIST: 368,
70
98
  RPL_ENDOFWHOWAS: 369,
@@ -76,11 +104,12 @@ export const Numerics = {
76
104
  RPL_MOTDSTART: 375,
77
105
  RPL_ENDOFMOTD: 376,
78
106
 
79
- // Oper. RPL_REHASHING retained for the deferred REHASH verb (no runtime
80
- // config-reload path in serverless; see actor default-route triage).
107
+ // Oper. RPL_REHASHING is emitted by the oper-gated `REHASH` reducer when a
108
+ // config reload is requested (the actor re-invokes the adapter's loader).
109
+ // (The RFC 2812 `SERVICE` verb and its `RPL_YOURESERVICE` numeric were
110
+ // dropped — obsolete, never widely implemented.)
81
111
  RPL_YOUREOPER: 381,
82
112
  RPL_REHASHING: 382,
83
- RPL_YOURESERVICE: 383,
84
113
  RPL_TIME: 391,
85
114
 
86
115
  // SASL (IRCv3 sasl-3.2)
@@ -102,7 +131,8 @@ export const Numerics = {
102
131
  ERR_TOOMANYCHANNELS: 405,
103
132
  ERR_WASNOSUCHNICK: 406,
104
133
  ERR_TOOMANYTARGETS: 407,
105
- ERR_NOSUCHSERVICE: 408, // retained: deferred SERVICE verb (RFC 2812 obsolete)
134
+ // `ERR_NOSUCHSERVICE` (408) was dropped with the obsolete RFC 2812 `SERVICE`
135
+ // verb.
106
136
  ERR_INVALIDCAPCMD: 410,
107
137
  ERR_NORECIPIENT: 411,
108
138
  ERR_NOTEXTTOSEND: 412,
@@ -121,8 +151,8 @@ export const Numerics = {
121
151
  ERR_NOTONCHANNEL: 442,
122
152
  ERR_USERONCHANNEL: 443,
123
153
  ERR_NOLOGIN: 444,
124
- ERR_SUMMONDISABLED: 445, // retained: deferred SUMMON verb (RFC 2812 obsolete)
125
- ERR_USERSDISABLED: 446, // retained: deferred USERS verb (RFC 2812 obsolete)
154
+ // `ERR_SUMMONDISABLED` (445) / `ERR_USERSDISABLED` (446) were dropped with
155
+ // the obsolete RFC 2812 `SUMMON`/`USERS` verbs.
126
156
  ERR_NOTREGISTERED: 451,
127
157
  ERR_NEEDMOREPARAMS: 461,
128
158
  ERR_ALREADYREGISTRED: 462,
@@ -140,7 +170,8 @@ export const Numerics = {
140
170
  ERR_BANLISTFULL: 478,
141
171
  ERR_NOPRIVILEGES: 481,
142
172
  ERR_CHANOPRIVSNEEDED: 482,
143
- ERR_CANTKILLSERVER: 483, // retained: deferred SQUIT verb (S2S, PLAN non-goal)
173
+ // `ERR_CANTKILLSERVER` (483) was dropped with the S2S `SQUIT` verb S2S is
174
+ // a PLAN non-goal.
144
175
  ERR_NOOPERHOST: 491,
145
176
  ERR_UMODEUNKNOWNFLAG: 501,
146
177
  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 512-byte line limit on `line.text`.
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(line: RawLine, caps: ReadonlySet<string>): RawLine {
80
- const budget = MAX_LINE_BYTES - TRAILING_CRLF_LEN;
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('@');
@@ -143,3 +155,8 @@ function clientTagKey(entry: string): string {
143
155
  const eq = entry.indexOf('=');
144
156
  return eq === -1 ? entry : entry.slice(0, eq);
145
157
  }
158
+
159
+ /** True when `mode` is an IRCv3 WebSocket spec framing mode (not legacy/absent). */
160
+ function isSpecMode(mode: WsFrameMode | undefined): boolean {
161
+ return mode === 'spec-text' || mode === 'spec-binary';
162
+ }
@@ -48,7 +48,7 @@ export interface OperCred {
48
48
  * Per-deployment server configuration. Adapters load this from a KV store
49
49
  * (CF) or Secrets Manager / SSM (AWS); the core only reads it.
50
50
  *
51
- * This is the minimum the Phase 1 reducers need; later tickets (SASL,
51
+ * This is the minimum the Phase 1 reducers need; later phases (SASL,
52
52
  * MOTD provider, channel-prefix policy) extend it.
53
53
  */
54
54
  export interface ServerConfig {
@@ -101,6 +101,13 @@ export interface ServerConfig {
101
101
  nickLen: number;
102
102
  channelLen: number;
103
103
  topicLen: number;
104
+ /**
105
+ * Maximum realname (GECOS) length enforced by `SETNAME`. Optional because
106
+ * registration-time `USER` does not enforce a cap; the parsed config always
107
+ * supplies a value (schema default), but inline `ServerConfig` literals in
108
+ * tests may omit it — `SETNAME` then falls back to its built-in default.
109
+ */
110
+ readonly realnameLen?: number | undefined;
104
111
  quitMessage: string;
105
112
  /**
106
113
  * Per-connection message-rate flood control. `undefined` → the actor
@@ -0,0 +1,132 @@
1
+ /**
2
+ * WebSocket IRC-message framing and the IRCv3 510-byte message budget.
3
+ *
4
+ * The IRCv3 WebSocket spec
5
+ * (https://ircv3.net/specs/websockets.html) changes two things about how a
6
+ * WebSocket text/binary message maps to an IRC message, relative to the
7
+ * legacy framing a raw-TCP-tolerant adapter uses:
8
+ *
9
+ * 1. **One IRC message per WebSocket message.** A frame never carries
10
+ * several `\r\n`-joined messages, so framing MUST NOT split on an
11
+ * embedded line break. A single optional trailing CR-LF (or bare LF)
12
+ * is stripped and the remainder is treated as exactly one IRC line.
13
+ *
14
+ * 2. **No trailing CR-LF on the wire.** The 512-byte IRC line limit
15
+ * therefore applies to the message content only, giving a hard cap of
16
+ * {@link MAX_WS_MESSAGE_BYTES} (510) bytes per WebSocket message.
17
+ *
18
+ * The legacy framing (`WsFrameMode === 'legacy'`) preserves the original
19
+ * adapter behaviour: split the whole frame on `/\r?\n/` so older clients
20
+ * that concatenate messages into one frame keep working. That is the same
21
+ * `splitFrameLines` logic the transport layer shipped before subprotocol
22
+ * negotiation existed.
23
+ *
24
+ * Everything here is pure and platform-agnostic so it runs unchanged on
25
+ * every WebSocket runtime (Node `ws`, Cloudflare Durable Objects, Lambda).
26
+ */
27
+
28
+ import type { WsSubprotocol } from './ws-subprotocol.js';
29
+
30
+ /**
31
+ * How a WebSocket message is framed into IRC lines.
32
+ *
33
+ * - `spec-text` — `text.ircv3.net`: one IRC message per frame, trailing
34
+ * CR-LF stripped, never split.
35
+ * - `spec-binary` — `binary.ircv3.net`: same framing as `spec-text`.
36
+ * - `legacy` — no IRCv3 subprotocol negotiated: split the frame on
37
+ * `/\r?\n/` (the original adapter behaviour).
38
+ */
39
+ export type WsFrameMode = 'spec-text' | 'spec-binary' | 'legacy';
40
+
41
+ /**
42
+ * Maximum number of bytes allowed in a single IRCv3 WebSocket message.
43
+ *
44
+ * The IRC line limit is 512 bytes *including* the trailing CR-LF. WebSocket
45
+ * messages omit the trailing CR-LF, so the content budget is `512 - 2 = 510`.
46
+ * Inbound messages exceeding this MUST be rejected.
47
+ */
48
+ export const MAX_WS_MESSAGE_BYTES = 510;
49
+
50
+ /**
51
+ * Maps a negotiated IRCv3 subprotocol onto the matching {@link WsFrameMode}.
52
+ * `undefined` (no subprotocol) maps to the legacy-tolerant framing.
53
+ */
54
+ export function wsFrameModeFor(subproto: WsSubprotocol | undefined): WsFrameMode {
55
+ if (subproto === 'text.ircv3.net') return 'spec-text';
56
+ if (subproto === 'binary.ircv3.net') return 'spec-binary';
57
+ return 'legacy';
58
+ }
59
+
60
+ /**
61
+ * Strips a single trailing `\r\n` (or bare `\n`) from `s`. Leaves every
62
+ * other character — including leading or embedded terminators — untouched.
63
+ * Used by the spec-mode framing to yield one bare IRC line per frame.
64
+ */
65
+ export function stripTrailingCrLf(s: string): string {
66
+ if (s.endsWith('\r\n')) return s.slice(0, -2);
67
+ if (s.endsWith('\n')) return s.slice(0, -1);
68
+ return s;
69
+ }
70
+
71
+ /**
72
+ * Splits a WebSocket text frame into IRC lines using the legacy-tolerant
73
+ * contract: split the whole frame on `/\r?\n/`, returning every part
74
+ * (including a possible trailing empty string). The empty frame yields no
75
+ * lines.
76
+ *
77
+ * This is the exact behaviour the transport layer shipped before IRCv3
78
+ * subprotocol negotiation; spec-mode framing ({@link frameToLines}) does
79
+ * NOT use it.
80
+ */
81
+ export function splitFrameLines(text: string): string[] {
82
+ if (text.length === 0) return [];
83
+ return text.split(/\r?\n/u);
84
+ }
85
+
86
+ /**
87
+ * Frames `frame` into IRC lines according to `mode`.
88
+ *
89
+ * - `spec-text` / `spec-binary`: one IRC message per frame. A single
90
+ * trailing CR-LF (or bare LF) is stripped and the remainder is returned
91
+ * as the sole element. The frame is never split on an embedded line
92
+ * break. An empty frame (or a frame that is only a terminator) yields
93
+ * no lines.
94
+ * - `legacy`: delegates to {@link splitFrameLines} (split on `/\r?\n/`).
95
+ */
96
+ export function frameToLines(frame: string, mode: WsFrameMode): string[] {
97
+ if (mode === 'legacy') return splitFrameLines(frame);
98
+ const line = stripTrailingCrLf(frame);
99
+ if (line.length === 0) return [];
100
+ return [line];
101
+ }
102
+
103
+ /**
104
+ * Returns `true` when `frame` fits within the IRCv3 WebSocket 510-byte
105
+ * message budget, `false` when it exceeds it and MUST be rejected by the
106
+ * inbound path. Measured in UTF-16 code units, consistent with how
107
+ * {@link enforceLineLimit} and the input parser cap line length across this
108
+ * package (every supported runtime exposes `.length`).
109
+ */
110
+ export function isWithinWsByteBudget(frame: string): boolean {
111
+ return frame.length <= MAX_WS_MESSAGE_BYTES;
112
+ }
113
+
114
+ /**
115
+ * Replaces every unpaired UTF-16 surrogate in `str` with U+FFFD (the
116
+ * Unicode replacement character).
117
+ *
118
+ * `text.ircv3.net` frames are UTF-8 text. A lone surrogate (half of a
119
+ * surrogate pair) has no valid UTF-8 encoding, so per the Unicode
120
+ * recommendation it is substituted with U+FFFD before the frame is handed
121
+ * to the parser. Valid surrogate pairs (supplementary-plane code points)
122
+ * are preserved unchanged, as is all BMP text.
123
+ */
124
+ export function sanitizeForTextMode(str: string): string {
125
+ // A lone high surrogate (0xD800–0xDBFF) NOT followed by a low surrogate,
126
+ // and a lone low surrogate (0xDC00–0xDFFF) NOT preceded by a high
127
+ // surrogate. Both are replaced with U+FFFD.
128
+ return str.replace(
129
+ /(?:[\uD800-\uDBFF](?![\uDC00-\uDFFF]))|(?:(?<![\uD800-\uDBFF])[\uDC00-\uDFFF])/gu,
130
+ '\uFFFD',
131
+ );
132
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * WebSocket subprotocol negotiation for IRC connections (IRCv3).
3
+ *
4
+ * The IRCv3 WebSocket specification defines two subprotocols a client may
5
+ * offer in its opening handshake via the `Sec-WebSocket-Protocol` header:
6
+ *
7
+ * - `text.ircv3.net` — UTF-8 text frames, one IRC message per WebSocket
8
+ * message, no trailing CR-LF.
9
+ * - `binary.ircv3.net` — binary frames, one IRC message per WebSocket
10
+ * message, no trailing CR-LF.
11
+ *
12
+ * The header is a comma-separated list offered in client-preference order.
13
+ * These pure helpers parse the header and select the single subprotocol the
14
+ * server should agree to (the first supported entry, honouring the client's
15
+ * preference). They are dependency-free so they run unchanged on every
16
+ * runtime (Node, Workers, Lambda).
17
+ */
18
+
19
+ /** The IRCv3 binary WebSocket subprotocol. */
20
+ export const WS_SUBPROTO_BINARY = 'binary.ircv3.net';
21
+
22
+ /** The IRCv3 text WebSocket subprotocol. */
23
+ export const WS_SUBPROTO_TEXT = 'text.ircv3.net';
24
+
25
+ /** A supported IRCv3 WebSocket subprotocol name. */
26
+ export type WsSubprotocol = typeof WS_SUBPROTO_TEXT | typeof WS_SUBPROTO_BINARY;
27
+
28
+ /**
29
+ * Parses the `Sec-WebSocket-Protocol` header value into an ordered list of
30
+ * offered protocols (client-preference order). Whitespace around each item is
31
+ * trimmed and empty entries (stray/leading/trailing commas) are dropped.
32
+ * Duplicate entries are preserved in their original order.
33
+ *
34
+ * `null`, `undefined`, or an empty string yield an empty array.
35
+ *
36
+ * @param header The raw `Sec-WebSocket-Protocol` header value, if present.
37
+ */
38
+ export function parseSecWsProtocolOffers(header: string | null | undefined): string[] {
39
+ if (header === null || header === undefined || header === '') {
40
+ return [];
41
+ }
42
+ const offers: string[] = [];
43
+ for (const raw of header.split(',')) {
44
+ const item = raw.trim();
45
+ if (item !== '') {
46
+ offers.push(item);
47
+ }
48
+ }
49
+ return offers;
50
+ }
51
+
52
+ /**
53
+ * Selects the supported subprotocol to agree to from a list of client offers
54
+ * (in client-preference order). Returns the first offer that matches a
55
+ * supported subprotocol, or `null` when none match.
56
+ *
57
+ * @param offers The ordered list of offered protocols.
58
+ */
59
+ export function selectSubprotocol(offers: readonly string[]): WsSubprotocol | null {
60
+ for (const offer of offers) {
61
+ if (offer === WS_SUBPROTO_TEXT || offer === WS_SUBPROTO_BINARY) {
62
+ return offer as WsSubprotocol;
63
+ }
64
+ }
65
+ return null;
66
+ }
@@ -211,6 +211,24 @@ describe('AdmissionStats — release', () => {
211
211
  const stats = makeStats(clock);
212
212
  expect(() => stats.release('unknown')).not.toThrow();
213
213
  });
214
+
215
+ it('deletes the per-IP and per-user keys when the last admission is released', () => {
216
+ // Covers the `next <= 0` delete branch in `release`: a single recorded
217
+ // admission, when released, must clear the keys entirely rather than
218
+ // leaving a zero entry in the maps.
219
+ const clock = new FakeClock(1_000);
220
+ const stats = makeStats(clock);
221
+ const d = decideAdmission('5.6.7.8', 'carol', stats, BASE_CONFIG);
222
+ if (!d.ok) throw new Error('precondition: d should admit');
223
+ stats.recordAdmission('5.6.7.8', 'carol', d.recordId);
224
+
225
+ stats.release(d.recordId);
226
+
227
+ // A fresh admission from the same IP/user must be allowed (counts are
228
+ // back to zero because the keys were deleted, not just decremented).
229
+ const after = decideAdmission('5.6.7.8', 'carol', stats, BASE_CONFIG);
230
+ expect(after.ok).toBe(true);
231
+ });
214
232
  });
215
233
 
216
234
  describe('AdmissionStats — determinism', () => {