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
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ WS_SUBPROTO_BINARY,
4
+ WS_SUBPROTO_TEXT,
5
+ type WsSubprotocol,
6
+ parseSecWsProtocolOffers,
7
+ selectSubprotocol,
8
+ } from '../src/ws-subprotocol';
9
+
10
+ // ============================================================================
11
+ // constants & type
12
+ // ============================================================================
13
+
14
+ describe('WS subprotocol constants', () => {
15
+ it('exposes the IRCv3 text subprotocol name', () => {
16
+ expect(WS_SUBPROTO_TEXT).toBe('text.ircv3.net');
17
+ });
18
+
19
+ it('exposes the IRCv3 binary subprotocol name', () => {
20
+ expect(WS_SUBPROTO_BINARY).toBe('binary.ircv3.net');
21
+ });
22
+
23
+ it('the WsSubprotocol type resolves to the two literal names', () => {
24
+ const a: WsSubprotocol = WS_SUBPROTO_TEXT;
25
+ const b: WsSubprotocol = WS_SUBPROTO_BINARY;
26
+ expect([a, b]).toEqual([WS_SUBPROTO_TEXT, WS_SUBPROTO_BINARY]);
27
+ });
28
+ });
29
+
30
+ // ============================================================================
31
+ // parseSecWsProtocolOffers — header parsing
32
+ // ============================================================================
33
+
34
+ describe('parseSecWsProtocolOffers', () => {
35
+ it('splits a comma-separated list into ordered offers', () => {
36
+ expect(parseSecWsProtocolOffers('text.ircv3.net, binary.ircv3.net')).toEqual([
37
+ 'text.ircv3.net',
38
+ 'binary.ircv3.net',
39
+ ]);
40
+ });
41
+
42
+ it('trims whitespace around each item', () => {
43
+ expect(parseSecWsProtocolOffers(' text.ircv3.net , binary.ircv3.net ')).toEqual([
44
+ 'text.ircv3.net',
45
+ 'binary.ircv3.net',
46
+ ]);
47
+ });
48
+
49
+ it('preserves client-preference order', () => {
50
+ expect(parseSecWsProtocolOffers('binary.ircv3.net, text.ircv3.net')).toEqual([
51
+ 'binary.ircv3.net',
52
+ 'text.ircv3.net',
53
+ ]);
54
+ });
55
+
56
+ it('returns an empty array for null', () => {
57
+ expect(parseSecWsProtocolOffers(null)).toEqual([]);
58
+ });
59
+
60
+ it('returns an empty array for undefined', () => {
61
+ expect(parseSecWsProtocolOffers(undefined)).toEqual([]);
62
+ });
63
+
64
+ it('returns an empty array for an empty string', () => {
65
+ expect(parseSecWsProtocolOffers('')).toEqual([]);
66
+ });
67
+
68
+ it('drops empty entries produced by stray commas', () => {
69
+ expect(parseSecWsProtocolOffers('text.ircv3.net,, ,binary.ircv3.net')).toEqual([
70
+ 'text.ircv3.net',
71
+ 'binary.ircv3.net',
72
+ ]);
73
+ });
74
+
75
+ it('preserves duplicate entries in order', () => {
76
+ expect(parseSecWsProtocolOffers('text.ircv3.net, text.ircv3.net')).toEqual([
77
+ 'text.ircv3.net',
78
+ 'text.ircv3.net',
79
+ ]);
80
+ });
81
+ });
82
+
83
+ // ============================================================================
84
+ // selectSubprotocol — preference-ordered selection
85
+ // ============================================================================
86
+
87
+ describe('selectSubprotocol', () => {
88
+ it('returns the first supported offer in client-preference order (text first)', () => {
89
+ expect(selectSubprotocol(['text.ircv3.net', 'binary.ircv3.net'])).toBe('text.ircv3.net');
90
+ });
91
+
92
+ it('honours client preference when binary is offered first', () => {
93
+ expect(selectSubprotocol(['binary.ircv3.net', 'text.ircv3.net'])).toBe('binary.ircv3.net');
94
+ });
95
+
96
+ it('returns null when only unsupported protocols are offered', () => {
97
+ expect(selectSubprotocol(['foo', 'bar'])).toBeNull();
98
+ });
99
+
100
+ it('returns null for an empty offer list', () => {
101
+ expect(selectSubprotocol([])).toBeNull();
102
+ });
103
+
104
+ it('skips unsupported offers and selects the first supported one', () => {
105
+ expect(selectSubprotocol(['foo', 'binary.ircv3.net'])).toBe('binary.ircv3.net');
106
+ });
107
+
108
+ it('returns the first occurrence for duplicate supported offers', () => {
109
+ expect(selectSubprotocol(['text.ircv3.net', 'text.ircv3.net'])).toBe('text.ircv3.net');
110
+ });
111
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-server",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": true,
5
5
  "description": "Orchestration layer: IrcRuntime port, dispatch interpreter, ConnectionActor",
6
6
  "license": "BSD-3-Clause",
@@ -46,15 +46,23 @@ import {
46
46
  Numerics,
47
47
  type RawLine,
48
48
  type ServerConfig,
49
+ type ServerStats,
49
50
  buildCtx,
50
51
  chathistoryReducer,
51
52
  handleJoinZero,
52
53
  isChannelTarget,
53
54
  isonReducer,
55
+ killReducer,
54
56
  listReducer,
57
+ lusersReducer,
55
58
  parse,
59
+ rehashLine,
60
+ rehashReducer,
61
+ statsReducer,
56
62
  toSnapshot,
63
+ traceReducer,
57
64
  userhostReducer,
65
+ wallopsReducer,
58
66
  whoReducer,
59
67
  whoisReducer,
60
68
  whowasReducer,
@@ -100,6 +108,13 @@ export interface ConnectionActorOptions {
100
108
  runtime: IrcRuntime;
101
109
  channels: ActorChannelAccess;
102
110
  serverConfig: ServerConfig;
111
+ /**
112
+ * Short label identifying the adapter's bound config source (e.g.
113
+ * `"KV"`, `"Secrets Manager"`, `"config file"`). Surfaced in the
114
+ * `382 RPL_REHASHING` reply's trailing text so the oper can see where the
115
+ * reload was attempted from. Defaults to `"server config"` when omitted.
116
+ */
117
+ readonly configSource?: string;
103
118
  clock: Clock;
104
119
  ids: IdFactory;
105
120
  motd?: MotdProvider;
@@ -138,6 +153,17 @@ export interface ConnectionActorOptions {
138
153
  * preserves the no-store behaviour: `WHOWAS <nick>` emits `406`+`369`.
139
154
  */
140
155
  readonly history?: NickHistoryStore;
156
+ /**
157
+ * Network-statistics source for the `LUSERS` and `STATS` commands
158
+ * (absent when no stats backend is wired). When bound, the actor
159
+ * awaits `stats.getStats()` once per LUSERS/STATS invocation and hands
160
+ * the resulting snapshot to the pure reducers — same pattern as
161
+ * `listChannels` → `listReducer`. Omitted (→ a zero-count fallback)
162
+ * preserves graceful behaviour for deployments/tests that have not
163
+ * opted in: `LUSERS` reports zero users/channels, `STATS u` reports
164
+ * zero uptime.
165
+ */
166
+ readonly stats?: ServerStats;
141
167
  /**
142
168
  * Structured-logger port. When omitted the actor runs
143
169
  * silently — observability is purely opt-in. When supplied, every
@@ -166,7 +192,8 @@ export class ConnectionActor {
166
192
  private readonly state: ConnectionState;
167
193
  private readonly runtime: IrcRuntime;
168
194
  private readonly channels: ActorChannelAccess;
169
- private readonly serverConfig: ServerConfig;
195
+ private serverConfig: ServerConfig;
196
+ private readonly configSource: string;
170
197
  private readonly clock: Clock;
171
198
  private readonly ids: IdFactory;
172
199
  private readonly motd: MotdProvider;
@@ -174,6 +201,7 @@ export class ConnectionActor {
174
201
  private readonly accounts: AccountStore | undefined;
175
202
  private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
176
203
  private readonly history: NickHistoryStore | undefined;
204
+ private readonly stats: ServerStats | undefined;
177
205
  private readonly reducers: RoutedReducers;
178
206
  /**
179
207
  * Line-framing seam. Turns inbound input (a WS text frame or a raw TCP
@@ -200,6 +228,7 @@ export class ConnectionActor {
200
228
  this.runtime = opts.runtime;
201
229
  this.channels = opts.channels;
202
230
  this.serverConfig = opts.serverConfig;
231
+ this.configSource = opts.configSource ?? 'server config';
203
232
  this.clock = opts.clock;
204
233
  this.ids = opts.ids;
205
234
  this.motd = opts.motd ?? EmptyMotdProvider;
@@ -207,6 +236,7 @@ export class ConnectionActor {
207
236
  this.accounts = opts.accounts;
208
237
  this.mtlsIdentity = opts.mtlsIdentity;
209
238
  this.history = opts.history;
239
+ this.stats = opts.stats;
210
240
  this.logger = opts.logger ?? NoopLoggerInstance;
211
241
  this.pinnedTraceId = opts.traceId;
212
242
  // Bind the connection id once so every record the actor emits carries
@@ -337,12 +367,16 @@ export class ConnectionActor {
337
367
  return this.reducers.motd(this.state, msg, ctx).effects;
338
368
  case 'OPER':
339
369
  return this.reducers.oper(this.state, msg, ctx).effects;
370
+ case 'REHASH':
371
+ return this.routeRehash(msg, ctx);
340
372
  case 'CAP':
341
373
  return this.reducers.cap(this.state, msg, ctx).effects;
342
374
  case 'AUTHENTICATE':
343
375
  return this.reducers.authenticate(this.state, msg, ctx).effects;
344
376
  case 'AWAY':
345
377
  return this.reducers.away(this.state, msg, ctx).effects;
378
+ case 'SETNAME':
379
+ return this.reducers.setname(this.state, msg, ctx).effects;
346
380
 
347
381
  // -------- Server-info query commands --------
348
382
  case 'VERSION':
@@ -382,6 +416,16 @@ export class ConnectionActor {
382
416
  return this.routeWhois(msg, ctx);
383
417
  case 'WHOWAS':
384
418
  return this.routeWhowas(msg, ctx);
419
+ case 'KILL':
420
+ return this.routeKill(msg, ctx);
421
+ case 'TRACE':
422
+ return this.routeTrace(msg, ctx);
423
+ case 'WALLOPS':
424
+ return this.routeWallops(msg, ctx);
425
+ case 'LUSERS':
426
+ return this.routeLusers(msg, ctx);
427
+ case 'STATS':
428
+ return this.routeStats(msg, ctx);
385
429
  case 'CHATHISTORY':
386
430
  return this.routeChathistory(msg, ctx);
387
431
 
@@ -418,14 +462,17 @@ export class ConnectionActor {
418
462
  default:
419
463
  // Remaining standard IRC verbs not yet implemented. They fall through
420
464
  // to a graceful `421 ERR_UNKNOWNCOMMAND`. Triage of remaining verbs:
421
- // - Deferred (serverless-incompatible / S2S / needs new infra):
422
- // LUSERS (needs cross-adapter getStats), KILL (oper disconnect),
423
- // REHASH (no runtime config reload), CONNECT/SQUIT (S2S, PLAN
424
- // non-goal), TRACE (complex routing info), STATS (stats backend),
425
- // WALLOPS (oper broadcast mechanism), SETNAME (CHGHOST plumbing),
426
- // LINKS (S2S server list).
427
- // - Obsolete (RFC 2812, never widely implemented): SERVICE, SUMMON,
428
- // USERS.
465
+ // - Deferred (serverless-incompatible / needs new infra):
466
+ // (none LUSERS/STATS are now implemented).
467
+ // - Deliberately dropped (S2S PLAN non-goal):
468
+ // CONNECT, SQUIT, LINKS. There is no server mesh to connect,
469
+ // split, or list; these stay 421. Their reserved numerics
470
+ // (RPL_LINKS/RPL_ENDOFLINKS/ERR_CANTKILLSERVER) were removed.
471
+ // - Deliberately dropped (RFC 2812 obsolete, never widely
472
+ // implemented): SERVICE, SUMMON, USERS. These
473
+ // stay 421; their reserved numerics
474
+ // (RPL_YOURESERVICE/ERR_NOSUCHSERVICE/ERR_SUMMONDISABLED/
475
+ // ERR_USERSDISABLED) were removed.
429
476
  return [this.unknownCommandEffect(msg.command)];
430
477
  }
431
478
  }
@@ -438,6 +485,40 @@ export class ConnectionActor {
438
485
  return listReducer(snapshots, msg, ctx).effects;
439
486
  }
440
487
 
488
+ /**
489
+ * Routes `REHASH` (oper-gated config reload). The pure reducer enforces the
490
+ * oper gate and emits `382 RPL_REHASHING`; when the gate passes the actor
491
+ * re-invokes the runtime's bound config loader and swaps its live
492
+ * `serverConfig` reference so subsequent commands observe the refreshed
493
+ * value (rotated oper creds, new MOTD source, etc.).
494
+ *
495
+ * On reload failure the previous config stays in effect and a graceful
496
+ * error-suffixed `382` follows — the oper is NOT disconnected (per the
497
+ * serverless reload contract: a bad store read must never sever a live
498
+ * connection).
499
+ */
500
+ private async routeRehash(
501
+ msg: IrcMessage,
502
+ ctx: ReturnType<typeof buildCtx>,
503
+ ): Promise<EffectType[]> {
504
+ const result = rehashReducer(this.state, msg, ctx, this.configSource);
505
+ if (!result.shouldReload) {
506
+ return result.effects;
507
+ }
508
+ const effects = [...result.effects];
509
+ try {
510
+ this.serverConfig = await this.runtime.reloadConfig();
511
+ } catch (err) {
512
+ const reason = err instanceof Error ? err.message : String(err);
513
+ this.logger.warn('rehash.reload-failed', { reason });
514
+ const nick = this.state.nick ?? '*';
515
+ effects.push(
516
+ Effect.send(this.state.id, [rehashLine(ctx.serverName, nick, this.configSource, reason)]),
517
+ );
518
+ }
519
+ return effects;
520
+ }
521
+
441
522
  private async routeWho(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): Promise<EffectType[]> {
442
523
  const target = msg.params[0] ?? '';
443
524
  if (target.length === 0) {
@@ -474,6 +555,78 @@ export class ConnectionActor {
474
555
  return whoisReducer(target, channels, msg, ctx).effects;
475
556
  }
476
557
 
558
+ /**
559
+ * Routes `KILL <nick> <comment>` to the kill reducer. The actor resolves
560
+ * the target via the nick registry + a live connection fetch (mirroring
561
+ * {@link routeWhois}); no channel is created as a side effect. The
562
+ * reducer performs the oper gate and, on success, emits a
563
+ * {@link DisconnectEffect} for the target — the runtime's `disconnect`
564
+ * closes the target transport and fans QUIT out to shared-channel peers
565
+ * (peer fanout is the runtime's responsibility, not the reducer's).
566
+ */
567
+ private async routeKill(
568
+ msg: IrcMessage,
569
+ ctx: ReturnType<typeof buildCtx>,
570
+ ): Promise<EffectType[]> {
571
+ const targetNick = msg.params[0] ?? '';
572
+ let target: ConnectionState | undefined;
573
+ if (targetNick.length > 0) {
574
+ const connId = await this.runtime.lookupNick(targetNick);
575
+ if (connId !== null) {
576
+ target = (await this.runtime.getConnection(connId)) ?? undefined;
577
+ }
578
+ }
579
+ return killReducer(target, msg, ctx).effects;
580
+ }
581
+
582
+ /**
583
+ * Routes `TRACE [<target>]` to the trace reducer. Single-server
584
+ * deployment: when `<target>` is absent or equals the local server name
585
+ * the reducer emits only the `262 RPL_ENDOFTRACE` terminator; when
586
+ * `<target>` resolves to a nick the reducer emits `205`/`204` per-conn
587
+ * detail (oper requester only) plus `262`; any other `<target>` is
588
+ * treated as a non-local server and yields `402 ERR_NOSUCHSERVER`.
589
+ *
590
+ * The actor resolves the target via `lookupNick` + `getConnection`
591
+ * (mirroring {@link routeWhois}). The local-server check is
592
+ * case-insensitive against `serverConfig.serverName`. Multi-hop S2S
593
+ * tracing is a PLAN non-goal.
594
+ */
595
+ private async routeTrace(
596
+ msg: IrcMessage,
597
+ ctx: ReturnType<typeof buildCtx>,
598
+ ): Promise<EffectType[]> {
599
+ const rawTarget = msg.params[0] ?? '';
600
+ if (rawTarget.length === 0) {
601
+ return traceReducer(undefined, msg, ctx).effects;
602
+ }
603
+ // Local server name (case-insensitive) → collapse to the no-target path.
604
+ if (rawTarget.toLowerCase() === ctx.serverName.toLowerCase()) {
605
+ return traceReducer(undefined, msg, ctx).effects;
606
+ }
607
+ // Try to resolve as a nick.
608
+ const connId = await this.runtime.lookupNick(rawTarget);
609
+ if (connId !== null) {
610
+ const target = (await this.runtime.getConnection(connId)) ?? undefined;
611
+ return traceReducer(target, msg, ctx).effects;
612
+ }
613
+ // Unresolved: treat as a remote server → 402 ERR_NOSUCHSERVER.
614
+ return traceReducer(undefined, msg, ctx, rawTarget).effects;
615
+ }
616
+
617
+ /**
618
+ * Routes `WALLOPS :<message>` to the wallops reducer. The pure reducer
619
+ * enforces the oper gate (`481`) and the non-empty-message check
620
+ * (`412`); on success it emits a single {@link BroadcastWallopsEffect}
621
+ * that the dispatch layer interprets via the runtime's
622
+ * `broadcastWallops` method — a global fan-out to every `+w`
623
+ * connection (not channel-scoped). The originator is always excluded
624
+ * (skip-self, matching NOTICE/PRIVMSG broadcast semantics).
625
+ */
626
+ private routeWallops(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): EffectType[] {
627
+ return wallopsReducer(this.state, msg, ctx).effects;
628
+ }
629
+
477
630
  /**
478
631
  * Routes `WHOWAS <nick>{,<nick>} [<count>]` to the whowas reducer. The
479
632
  * command is query-only: it reads the bound nick-history store
@@ -488,6 +641,70 @@ export class ConnectionActor {
488
641
  return whowasReducer(this.history, msg, ctx).effects;
489
642
  }
490
643
 
644
+ /**
645
+ * Routes `LUSERS [<mask> [<server>]]` to the lusers reducer. Awaits the
646
+ * bound {@link ServerStats} backend (or falls back to a zero-count
647
+ * snapshot when no backend is wired) and hands the result to the pure
648
+ * reducer, which emits the `251`–`255`/`265`/`266` numerics.
649
+ *
650
+ * Single-server deployment: a `<server>` argument that does not match
651
+ * the local server name (case-insensitive) is treated as a remote
652
+ * server and yields `402 ERR_NOSUCHSERVER`. S2S aggregation is a PLAN
653
+ * non-goal.
654
+ */
655
+ private async routeLusers(
656
+ msg: IrcMessage,
657
+ ctx: ReturnType<typeof buildCtx>,
658
+ ): Promise<EffectType[]> {
659
+ const serverTarget = serverTargetOf(msg.params[1] ?? '', ctx.serverName);
660
+ const snapshot = await this.fetchStats();
661
+ return lusersReducer(snapshot, msg, ctx, serverTarget).effects;
662
+ }
663
+
664
+ /**
665
+ * Routes `STATS <query> [<server>]` to the stats reducer. Awaits the
666
+ * bound {@link ServerStats} backend (or zero-count fallback) and hands
667
+ * the result to the pure reducer. The reducer handles the implemented
668
+ * query letters (`u`, `l`), the unknown-letter charybdis-style empty
669
+ * body, and the `219` terminator; non-local `<server>` arguments
670
+ * produce `402 ERR_NOSUCHSERVER`.
671
+ */
672
+ private async routeStats(
673
+ msg: IrcMessage,
674
+ ctx: ReturnType<typeof buildCtx>,
675
+ ): Promise<EffectType[]> {
676
+ const serverTarget = serverTargetOf(msg.params[1] ?? '', ctx.serverName);
677
+ const snapshot = await this.fetchStats();
678
+ return statsReducer(snapshot, msg, ctx, serverTarget).effects;
679
+ }
680
+
681
+ /**
682
+ * Fetches the current {@link ServerStatsSnapshot} from the bound backend.
683
+ * When no backend is wired, returns a zero-count snapshot so LUSERS/STATS
684
+ * still emit a well-formed reply rather than a 421/empty stream — the
685
+ * uptime anchor defaults to the actor's wall clock so `STATS u` reports
686
+ * zero uptime instead of a negative duration.
687
+ */
688
+ private async fetchStats(): Promise<import('@serverless-ircd/irc-core').ServerStatsSnapshot> {
689
+ if (this.stats !== undefined) {
690
+ return this.stats.getStats();
691
+ }
692
+ const now = this.clock.now();
693
+ return {
694
+ users: 0,
695
+ invisible: 0,
696
+ opers: 0,
697
+ unknownConnections: 0,
698
+ channels: 0,
699
+ servers: 1,
700
+ localConns: 0,
701
+ globalConns: 0,
702
+ maxLocalConns: 0,
703
+ maxGlobalConns: 0,
704
+ uptimeStartedAt: now,
705
+ };
706
+ }
707
+
491
708
  /**
492
709
  * Routes `USERHOST <nick>{ <nick>}` to the userhost reducer. Resolves each
493
710
  * requested nick via the nick registry + connection lookup, passing the
@@ -500,10 +717,8 @@ export class ConnectionActor {
500
717
  ctx: ReturnType<typeof buildCtx>,
501
718
  ): Promise<EffectType[]> {
502
719
  const cap = ctx.serverConfig.maxTargetsPerCommand;
503
- const nicks = msg.params.slice(0, cap);
504
720
  const resolved = new Map<string, ConnSnapshot | null>();
505
- for (const nick of nicks) {
506
- if (nick.length === 0) continue;
721
+ for (const nick of msg.params.slice(0, cap)) {
507
722
  const connId = await this.runtime.lookupNick(nick);
508
723
  if (connId === null) {
509
724
  resolved.set(nick, null);
@@ -526,13 +741,14 @@ export class ConnectionActor {
526
741
  ctx: ReturnType<typeof buildCtx>,
527
742
  ): Promise<EffectType[]> {
528
743
  const cap = ctx.serverConfig.maxTargetsPerCommand;
529
- const nicks = msg.params.slice(0, cap);
530
744
  const online = new Map<string, string>();
531
- for (const nick of nicks) {
532
- if (nick.length === 0) continue;
745
+ for (const nick of msg.params.slice(0, cap)) {
533
746
  const connId = await this.runtime.lookupNick(nick);
534
747
  if (connId === null) continue;
535
748
  const conn = await this.runtime.getConnection(connId);
749
+ // `conn` can be null in the race where lookupNick resolved but the
750
+ // connection has since torn down (the nick map lags the connection
751
+ // map). Skip those — they are no longer online.
536
752
  if (conn?.nick !== undefined) {
537
753
  online.set(conn.nick.toLowerCase(), conn.nick);
538
754
  }
@@ -699,6 +915,20 @@ function splitCsv(param: string): string[] {
699
915
  return param.split(',').filter((p) => p.length > 0);
700
916
  }
701
917
 
918
+ /**
919
+ * Resolves the `<server>` parameter shared by `LUSERS` / `STATS` (and any
920
+ * future single-server-aware query verb). Returns `undefined` when the
921
+ * argument is absent OR names the local server (case-insensitive) — the
922
+ * caller then proceeds with the local-server code path. Returns the raw
923
+ * argument verbatim when it names a non-local server, which the reducer
924
+ * surfaces as `402 ERR_NOSUCHSERVER`.
925
+ */
926
+ function serverTargetOf(rawServer: string, localServerName: string): string | undefined {
927
+ if (rawServer.length === 0) return undefined;
928
+ if (rawServer.toLowerCase() === localServerName.toLowerCase()) return undefined;
929
+ return rawServer;
930
+ }
931
+
702
932
  /**
703
933
  * Extracts the channel names a reducer will read for the supplied command.
704
934
  * Used by `prefetchChannelState` to refresh only the channels the upcoming
@@ -708,8 +938,11 @@ function splitCsv(param: string): string[] {
708
938
  * Commands not listed here either have no channel target (PING, NICK, …)
709
939
  * or read channel membership from `ctx.connection.joinedChannels` directly
710
940
  * (QUIT), so they don't need a prefetch.
941
+ *
942
+ * Exported so the per-command dispatch table can be unit-tested without
943
+ * standing up a full {@link ConnectionActor} + runtime.
711
944
  */
712
- function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
945
+ export function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
713
946
  const cmd = msg.command;
714
947
  switch (cmd) {
715
948
  case 'JOIN':
@@ -33,6 +33,7 @@ const handlers: { [K in Effect['tag']]: Handler<Extract<Effect, { tag: K }>> } =
33
33
  ChangeNick: (e, r) => r.changeNick(e.conn, e.oldNick, e.newNick).then(noop),
34
34
  ReleaseNick: (e, r) => r.releaseNick(e.nick),
35
35
  ApplyChannelDelta: (e, r) => r.applyChannelDelta(e.chan, e.delta),
36
+ BroadcastWallops: (e, r) => r.broadcastWallops(e.lines, e.except),
36
37
  };
37
38
 
38
39
  function noop(): void {}
@@ -52,6 +52,7 @@ import {
52
52
  privmsgChannelReducer,
53
53
  privmsgUserReducer,
54
54
  quitReducer,
55
+ setnameReducer,
55
56
  tagmsgChannelReducer,
56
57
  tagmsgUserReducer,
57
58
  timeReducer,
@@ -80,6 +81,7 @@ export interface RoutedReducers {
80
81
  readonly cap: Reducer<ConnectionState>;
81
82
  readonly authenticate: Reducer<ConnectionState>;
82
83
  readonly away: Reducer<ConnectionState>;
84
+ readonly setname: Reducer<ConnectionState>;
83
85
  readonly version: Reducer<ConnectionState>;
84
86
  readonly time: Reducer<ConnectionState>;
85
87
  readonly admin: Reducer<ConnectionState>;
@@ -150,6 +152,7 @@ export function buildRoutedReducers(serverConfig: ServerConfig): RoutedReducers
150
152
  cap: wrap(capReducer),
151
153
  authenticate: wrap(authenticateReducer),
152
154
  away: wrap(awayReducer),
155
+ setname: wrap(setnameReducer),
153
156
  version: wrap(versionReducer),
154
157
  time: wrap(timeReducer),
155
158
  admin: wrap(adminReducer),
@@ -17,6 +17,7 @@ import type {
17
17
  ConnectionState,
18
18
  Nick,
19
19
  RawLine,
20
+ ServerConfig,
20
21
  } from '@serverless-ircd/irc-core';
21
22
 
22
23
  export interface IrcRuntime {
@@ -35,6 +36,24 @@ export interface IrcRuntime {
35
36
  lines: RawLine[],
36
37
  notFoundLines?: RawLine[],
37
38
  ): Promise<void>;
39
+ /**
40
+ * Global cross-connection fanout for `WALLOPS`. Delivers `lines` to every
41
+ * live connection whose `+w` user mode is set, skipping `except` (the
42
+ * originator, so an oper never receives their own wallops). Unlike
43
+ * {@link broadcast} this is NOT channel-scoped — it reaches connections
44
+ * with no shared channel membership.
45
+ *
46
+ * Each adapter applies its own cost cap and enumeration strategy:
47
+ * - in-memory: scans its connection map, filters `+w`.
48
+ * - Cloudflare: walks the sharded registry to enumerate connIds, then
49
+ * fetches each state to check `+w`. Capped to bound the fan-out RPC
50
+ * count; document a ceiling for very large fleets.
51
+ * - AWS: `Scan`s the `Connections` table and `PostToConnection`s each
52
+ * `+w` row. DynamoDB `Scan` reads scale with table size, so this is
53
+ * expensive at high connection counts — callers should bound the
54
+ * oper's WALLOPS rate.
55
+ */
56
+ broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void>;
38
57
 
39
58
  // nickname registry
40
59
  reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }>;
@@ -58,4 +77,16 @@ export interface IrcRuntime {
58
77
  getChannelSnapshot(chan: ChanName): Promise<ChanSnapshot | null>;
59
78
  getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>>;
60
79
  listChannels(): Promise<ChanSnapshot[]>;
80
+ /**
81
+ * Re-fetches the deployment's {@link ServerConfig} from the adapter's bound
82
+ * source (CF: KV/secret env; AWS: Secrets Manager/SSM env; local-cli: the
83
+ * config file/options) and returns the fresh value. The caller (the
84
+ * ConnectionActor's `REHASH` route) swaps its live config reference with
85
+ * the result; on rejection the previous config stays in effect.
86
+ *
87
+ * Modelled as a request/response runtime method — like `lookupNick` /
88
+ * `getConnection` — rather than a fire-and-forget `Effect`, because the
89
+ * refreshed value must come back to the actor.
90
+ */
91
+ reloadConfig(): Promise<ServerConfig>;
61
92
  }
@@ -15,6 +15,10 @@
15
15
  * parser/reducer/dispatch pipeline.
16
16
  */
17
17
 
18
+ import { splitFrameLines } from '@serverless-ircd/irc-core';
19
+
20
+ export { splitFrameLines };
21
+
18
22
  /**
19
23
  * The line-framing contract a {@link ConnectionActor} consumes. `feed`
20
24
  * returns the complete IRC lines terminated within `chunk`; a stateful
@@ -25,14 +29,13 @@ export interface Transport {
25
29
  }
26
30
 
27
31
  /**
28
- * Splits a WebSocket text frame into IRC lines. Tolerates `\r\n`, bare `\n`,
29
- * and a missing trailing terminator (a WS frame is a complete unit). Returns
30
- * each line bare (no CRLF). This is the legacy-tolerant WS contract.
32
+ * Splits a WebSocket text frame into IRC lines. Re-exported from
33
+ * `@serverless-ircd/irc-core` (`ws-framing.ts`), which owns the canonical
34
+ * legacy-tolerant framing so the IRCv3 spec-mode framing and this legacy
35
+ * behaviour never diverge. Tolerates `\r\n`, bare `\n`, and a missing
36
+ * trailing terminator (a WS frame is a complete unit). Returns each line
37
+ * bare (no CRLF). This is the legacy-tolerant WS contract.
31
38
  */
32
- export function splitFrameLines(text: string): string[] {
33
- if (text.length === 0) return [];
34
- return text.split(/\r?\n/u);
35
- }
36
39
 
37
40
  /**
38
41
  * Stateless WS-frame transport: one {@link feed} call per inbound text frame.