serverless-ircd 0.3.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 (223) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +349 -18
  8. package/README.md +132 -30
  9. package/apps/aws-stack/README.md +6 -5
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +150 -10
  13. package/apps/aws-stack/tests/stack.test.ts +145 -4
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +149 -24
  38. package/apps/local-cli/tests/e2e.test.ts +1 -1
  39. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  40. package/package.json +14 -10
  41. package/packages/aws-adapter/package.json +3 -3
  42. package/packages/aws-adapter/src/account-store.ts +1 -1
  43. package/packages/aws-adapter/src/admission.ts +74 -0
  44. package/packages/aws-adapter/src/aws-runtime.ts +75 -4
  45. package/packages/aws-adapter/src/config-loader.ts +32 -0
  46. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  47. package/packages/aws-adapter/src/handlers/connect.ts +96 -9
  48. package/packages/aws-adapter/src/handlers/default.ts +98 -7
  49. package/packages/aws-adapter/src/handlers/index.ts +109 -4
  50. package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
  51. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  52. package/packages/aws-adapter/src/index.ts +8 -0
  53. package/packages/aws-adapter/src/serialize.ts +11 -1
  54. package/packages/aws-adapter/src/stats.ts +80 -0
  55. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  56. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  57. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  58. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  59. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  60. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  61. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  62. package/packages/aws-adapter/tests/connect.test.ts +174 -0
  63. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  64. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  65. package/packages/aws-adapter/tests/handlers.test.ts +302 -53
  66. package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
  67. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  68. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +5 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +68 -5
  73. package/packages/cf-adapter/src/channel-do.ts +2 -2
  74. package/packages/cf-adapter/src/config-loader.ts +33 -0
  75. package/packages/cf-adapter/src/connection-do.ts +278 -85
  76. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  77. package/packages/cf-adapter/src/env.ts +54 -11
  78. package/packages/cf-adapter/src/index.ts +11 -8
  79. package/packages/cf-adapter/src/registry-do.ts +22 -3
  80. package/packages/cf-adapter/src/sharding.ts +1 -2
  81. package/packages/cf-adapter/src/stats.ts +65 -0
  82. package/packages/cf-adapter/tests/cf-harness.ts +12 -2
  83. package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
  84. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  85. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  86. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  87. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  88. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  89. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  90. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  91. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  92. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  93. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  94. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  95. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  96. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  97. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  98. package/packages/cf-adapter/tests/worker/main.ts +15 -8
  99. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  100. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  101. package/packages/cf-adapter/wrangler.test.toml +15 -0
  102. package/packages/in-memory-runtime/package.json +1 -1
  103. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  104. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  105. package/packages/irc-core/package.json +6 -1
  106. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  107. package/packages/irc-core/src/admission.ts +16 -15
  108. package/packages/irc-core/src/caps/capabilities.ts +24 -3
  109. package/packages/irc-core/src/cloak.ts +1 -1
  110. package/packages/irc-core/src/commands/cap.ts +8 -1
  111. package/packages/irc-core/src/commands/index.ts +17 -0
  112. package/packages/irc-core/src/commands/invite.ts +2 -4
  113. package/packages/irc-core/src/commands/ison.ts +61 -0
  114. package/packages/irc-core/src/commands/isupport.ts +6 -2
  115. package/packages/irc-core/src/commands/kick.ts +2 -4
  116. package/packages/irc-core/src/commands/kill.ts +127 -0
  117. package/packages/irc-core/src/commands/list.ts +1 -1
  118. package/packages/irc-core/src/commands/lusers.ts +204 -0
  119. package/packages/irc-core/src/commands/mode.ts +4 -8
  120. package/packages/irc-core/src/commands/names.ts +3 -5
  121. package/packages/irc-core/src/commands/part.ts +2 -4
  122. package/packages/irc-core/src/commands/quit.ts +12 -0
  123. package/packages/irc-core/src/commands/registration.ts +18 -12
  124. package/packages/irc-core/src/commands/rehash.ts +119 -0
  125. package/packages/irc-core/src/commands/sasl.ts +72 -9
  126. package/packages/irc-core/src/commands/server-info.ts +129 -0
  127. package/packages/irc-core/src/commands/setname.ts +109 -0
  128. package/packages/irc-core/src/commands/stats.ts +152 -0
  129. package/packages/irc-core/src/commands/topic.ts +2 -4
  130. package/packages/irc-core/src/commands/trace.ts +137 -0
  131. package/packages/irc-core/src/commands/userhost.ts +84 -0
  132. package/packages/irc-core/src/commands/wallops.ts +118 -0
  133. package/packages/irc-core/src/commands/whowas.ts +113 -0
  134. package/packages/irc-core/src/config.ts +65 -0
  135. package/packages/irc-core/src/credential-hashing.ts +124 -0
  136. package/packages/irc-core/src/effects.ts +33 -30
  137. package/packages/irc-core/src/index.ts +3 -0
  138. package/packages/irc-core/src/ports.ts +360 -12
  139. package/packages/irc-core/src/protocol/numerics.ts +48 -11
  140. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  141. package/packages/irc-core/src/types.ts +46 -2
  142. package/packages/irc-core/src/ws-framing.ts +132 -0
  143. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  144. package/packages/irc-core/tests/account-store.test.ts +45 -2
  145. package/packages/irc-core/tests/admission.test.ts +18 -0
  146. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  147. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  148. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  149. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  150. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  151. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  152. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  153. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  154. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  155. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  156. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  157. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  158. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  159. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  160. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  161. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  162. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  163. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  164. package/packages/irc-core/tests/config.test.ts +95 -1
  165. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  166. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  167. package/packages/irc-core/tests/effects.test.ts +14 -27
  168. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  169. package/packages/irc-core/tests/numerics.test.ts +102 -0
  170. package/packages/irc-core/tests/outbound.test.ts +51 -0
  171. package/packages/irc-core/tests/ports.test.ts +22 -0
  172. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  173. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  174. package/packages/irc-core/tests/types.test.ts +35 -1
  175. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  176. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  177. package/packages/irc-core/tsconfig.build.json +1 -1
  178. package/packages/irc-core/tsconfig.test.json +1 -1
  179. package/packages/irc-server/package.json +1 -1
  180. package/packages/irc-server/src/actor.ts +393 -16
  181. package/packages/irc-server/src/dispatch.ts +1 -3
  182. package/packages/irc-server/src/index.ts +10 -2
  183. package/packages/irc-server/src/routing.ts +15 -0
  184. package/packages/irc-server/src/runtime.ts +31 -0
  185. package/packages/irc-server/src/transport.ts +104 -0
  186. package/packages/irc-server/tests/actor.test.ts +1489 -4
  187. package/packages/irc-server/tests/dispatch.test.ts +37 -17
  188. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  189. package/packages/irc-server/tests/routing.test.ts +5 -0
  190. package/packages/irc-server/tests/runtime.test.ts +7 -0
  191. package/packages/irc-server/tests/transport.test.ts +230 -0
  192. package/packages/irc-test-support/package.json +1 -1
  193. package/packages/irc-test-support/src/harness.ts +44 -9
  194. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  195. package/packages/irc-test-support/src/index.ts +3 -0
  196. package/packages/irc-test-support/src/scenarios.ts +141 -3
  197. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  198. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  199. package/pnpm-workspace.yaml +10 -1
  200. package/tools/ci-hardening/package.json +1 -1
  201. package/tools/package.json +9 -0
  202. package/tools/seed-aws-accounts.ts +5 -6
  203. package/tools/seed-cf-accounts.ts +107 -0
  204. package/tools/tcp-ws-forwarder/package.json +1 -1
  205. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  206. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  207. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
  208. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  209. package/docs/ADR-002-location-of-authority.md +0 -82
  210. package/docs/ADR-003-durable-object-sharding.md +0 -93
  211. package/docs/ADR-004-dynamodb-schema.md +0 -96
  212. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  213. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  214. package/docs/ADR-007-deterministic-ports.md +0 -82
  215. package/docs/ADR-008-monorepo-tooling.md +0 -60
  216. package/docs/AWS-Adapter-Architecture.md +0 -496
  217. package/docs/AWS-Deployment.md +0 -1186
  218. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  219. package/docs/Home.md +0 -11
  220. package/docs/Observability.md +0 -87
  221. package/docs/PlanIRCv3Websocket.md +0 -489
  222. package/docs/PlanWebClient.md +0 -451
  223. package/docs/Release-Process.md +0 -443
@@ -30,6 +30,7 @@ import {
30
30
  type ChanName,
31
31
  type ChannelState,
32
32
  type Clock,
33
+ type ConnSnapshot,
33
34
  type ConnectionState,
34
35
  Effect,
35
36
  type Effect as EffectType,
@@ -39,23 +40,38 @@ import {
39
40
  type Logger,
40
41
  type MessageStore,
41
42
  type MotdProvider,
43
+ type MtlsIdentityProvider,
44
+ type NickHistoryStore,
42
45
  NoopLoggerInstance,
43
46
  Numerics,
44
47
  type RawLine,
45
48
  type ServerConfig,
49
+ type ServerStats,
46
50
  buildCtx,
47
51
  chathistoryReducer,
48
52
  handleJoinZero,
49
53
  isChannelTarget,
54
+ isonReducer,
55
+ killReducer,
50
56
  listReducer,
57
+ lusersReducer,
51
58
  parse,
59
+ rehashLine,
60
+ rehashReducer,
61
+ statsReducer,
62
+ toSnapshot,
63
+ traceReducer,
64
+ userhostReducer,
65
+ wallopsReducer,
52
66
  whoReducer,
53
67
  whoisReducer,
68
+ whowasReducer,
54
69
  } from '@serverless-ircd/irc-core';
55
70
  import { dispatch } from './dispatch.js';
56
71
  import type { DispatchOptions } from './dispatch.js';
57
72
  import { type RoutedReducers, buildRoutedReducers } from './routing.js';
58
73
  import type { IrcRuntime } from './runtime.js';
74
+ import { type Transport, WsTextFrameTransport } from './transport.js';
59
75
 
60
76
  /**
61
77
  * Channel-state access for the actor.
@@ -92,6 +108,13 @@ export interface ConnectionActorOptions {
92
108
  runtime: IrcRuntime;
93
109
  channels: ActorChannelAccess;
94
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;
95
118
  clock: Clock;
96
119
  ids: IdFactory;
97
120
  motd?: MotdProvider;
@@ -114,6 +137,33 @@ export interface ConnectionActorOptions {
114
137
  * can vouch for the credentials.
115
138
  */
116
139
  readonly accounts?: AccountStore;
140
+ /**
141
+ * mTLS client-cert identity source for SASL EXTERNAL (absent when mTLS
142
+ * is not configured). When bound, `ctx.mtlsIdentity` is defined so the
143
+ * `AUTHENTICATE EXTERNAL` reducer can resolve the connection's verified
144
+ * certificate subject. Omitted (→ `ctx.mtlsIdentity` undefined) means
145
+ * EXTERNAL is rejected with `904 ERR_SASLFAIL`.
146
+ */
147
+ readonly mtlsIdentity?: MtlsIdentityProvider;
148
+ /**
149
+ * Nick-history source for the `WHOWAS` command (absent when WHOWAS
150
+ * history retention is disabled). When bound, `ctx.history` is defined
151
+ * so the QUIT and NICK-change reducers record sign-off entries and the
152
+ * WHOWAS reducer replays them. Omitted (→ `ctx.history` undefined)
153
+ * preserves the no-store behaviour: `WHOWAS <nick>` emits `406`+`369`.
154
+ */
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;
117
167
  /**
118
168
  * Structured-logger port. When omitted the actor runs
119
169
  * silently — observability is purely opt-in. When supplied, every
@@ -127,19 +177,39 @@ export interface ConnectionActorOptions {
127
177
  * still uniquely identifiable in logs.
128
178
  */
129
179
  readonly traceId?: string;
180
+ /**
181
+ * Line-framing seam (PLAN §9, ADR-009). Turns inbound transport input
182
+ * into complete IRC lines. Defaults to a {@link WsTextFrameTransport}
183
+ * (one message per WebSocket text frame, the serverless default). A raw
184
+ * TCP+TLS adapter injects a {@link TcpByteStreamTransport} for stateful
185
+ * `\r\n` byte-stream framing across chunks. The parser/reducer/dispatch
186
+ * pipeline is shared unchanged across both transports.
187
+ */
188
+ readonly transport?: Transport;
130
189
  }
131
190
 
132
191
  export class ConnectionActor {
133
192
  private readonly state: ConnectionState;
134
193
  private readonly runtime: IrcRuntime;
135
194
  private readonly channels: ActorChannelAccess;
136
- private readonly serverConfig: ServerConfig;
195
+ private serverConfig: ServerConfig;
196
+ private readonly configSource: string;
137
197
  private readonly clock: Clock;
138
198
  private readonly ids: IdFactory;
139
199
  private readonly motd: MotdProvider;
140
200
  private readonly messages: MessageStore | undefined;
141
201
  private readonly accounts: AccountStore | undefined;
202
+ private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
203
+ private readonly history: NickHistoryStore | undefined;
204
+ private readonly stats: ServerStats | undefined;
142
205
  private readonly reducers: RoutedReducers;
206
+ /**
207
+ * Line-framing seam. Turns inbound input (a WS text frame or a raw TCP
208
+ * chunk) into complete IRC lines. Defaulted to a WS-frame transport for
209
+ * the serverless default; a TCP+TLS adapter injects the stateful
210
+ * byte-stream transport at construction.
211
+ */
212
+ private readonly transport: Transport;
143
213
  /**
144
214
  * Bound logger. Defaults to a no-op so the actor pipeline has no
145
215
  * observable side effects when callers do not opt in.
@@ -158,11 +228,15 @@ export class ConnectionActor {
158
228
  this.runtime = opts.runtime;
159
229
  this.channels = opts.channels;
160
230
  this.serverConfig = opts.serverConfig;
231
+ this.configSource = opts.configSource ?? 'server config';
161
232
  this.clock = opts.clock;
162
233
  this.ids = opts.ids;
163
234
  this.motd = opts.motd ?? EmptyMotdProvider;
164
235
  this.messages = opts.messages;
165
236
  this.accounts = opts.accounts;
237
+ this.mtlsIdentity = opts.mtlsIdentity;
238
+ this.history = opts.history;
239
+ this.stats = opts.stats;
166
240
  this.logger = opts.logger ?? NoopLoggerInstance;
167
241
  this.pinnedTraceId = opts.traceId;
168
242
  // Bind the connection id once so every record the actor emits carries
@@ -176,15 +250,19 @@ export class ConnectionActor {
176
250
  // reducer when flood control is disabled) so a flooding client is bounded
177
251
  // regardless of which authority owns the command's state.
178
252
  this.reducers = buildRoutedReducers(opts.serverConfig);
253
+ this.transport = opts.transport ?? new WsTextFrameTransport();
179
254
  }
180
255
 
181
256
  /**
182
- * Processes one WebSocket text frame. Splits on `\r\n` (tolerating bare
183
- * `\n`) and dispatches each non-empty line in order. A parse failure on
184
- * one line emits a `421` and processing continues with the next line.
257
+ * Transport-agnostic entry point: feeds `chunk` through the bound
258
+ * {@link Transport} and dispatches each complete, non-empty line in order.
259
+ * A WS adapter passes one text frame per call; a raw TCP+TLS adapter
260
+ * passes one arbitrary byte chunk per call and the transport buffers any
261
+ * partial trailing line across calls. A parse failure on one line emits a
262
+ * `421` and processing continues with the next line.
185
263
  */
186
- async receiveTextFrame(text: string): Promise<void> {
187
- const lines = splitFrameLines(text);
264
+ async receive(chunk: string): Promise<void> {
265
+ const lines = this.transport.feed(chunk);
188
266
  if (lines.length === 0) return;
189
267
  const traceId = this.pinnedTraceId ?? this.ids.traceId();
190
268
  const log = this.logger.child({ traceId });
@@ -194,6 +272,17 @@ export class ConnectionActor {
194
272
  }
195
273
  }
196
274
 
275
+ /**
276
+ * Processes one WebSocket text frame. Equivalent to {@link receive} — kept
277
+ * as the named entry point every WS adapter already calls. Splits on
278
+ * `\r\n` (tolerating bare `\n`) via the default WS-frame transport and
279
+ * dispatches each non-empty line in order. A parse failure on one line
280
+ * emits a `421` and processing continues with the next line.
281
+ */
282
+ async receiveTextFrame(text: string): Promise<void> {
283
+ await this.receive(text);
284
+ }
285
+
197
286
  private async processLine(line: string, log: Logger, traceId: string): Promise<void> {
198
287
  let msg: IrcMessage;
199
288
  let parseFailed = false;
@@ -256,6 +345,8 @@ export class ConnectionActor {
256
345
  connection: this.state,
257
346
  ...(this.messages !== undefined ? { messages: this.messages } : {}),
258
347
  ...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
348
+ ...(this.mtlsIdentity !== undefined ? { mtlsIdentity: this.mtlsIdentity } : {}),
349
+ ...(this.history !== undefined ? { history: this.history } : {}),
259
350
  });
260
351
 
261
352
  switch (msg.command) {
@@ -276,12 +367,30 @@ export class ConnectionActor {
276
367
  return this.reducers.motd(this.state, msg, ctx).effects;
277
368
  case 'OPER':
278
369
  return this.reducers.oper(this.state, msg, ctx).effects;
370
+ case 'REHASH':
371
+ return this.routeRehash(msg, ctx);
279
372
  case 'CAP':
280
373
  return this.reducers.cap(this.state, msg, ctx).effects;
281
374
  case 'AUTHENTICATE':
282
375
  return this.reducers.authenticate(this.state, msg, ctx).effects;
283
376
  case 'AWAY':
284
377
  return this.reducers.away(this.state, msg, ctx).effects;
378
+ case 'SETNAME':
379
+ return this.reducers.setname(this.state, msg, ctx).effects;
380
+
381
+ // -------- Server-info query commands --------
382
+ case 'VERSION':
383
+ return this.reducers.version(this.state, msg, ctx).effects;
384
+ case 'TIME':
385
+ return this.reducers.time(this.state, msg, ctx).effects;
386
+ case 'ADMIN':
387
+ return this.reducers.admin(this.state, msg, ctx).effects;
388
+ case 'INFO':
389
+ return this.reducers.info(this.state, msg, ctx).effects;
390
+ case 'USERHOST':
391
+ return this.routeUserhost(msg, ctx);
392
+ case 'ISON':
393
+ return this.routeIson(msg, ctx);
285
394
 
286
395
  // -------- Channel-authority commands --------
287
396
  case 'JOIN':
@@ -305,6 +414,18 @@ export class ConnectionActor {
305
414
  return this.routeWho(msg, ctx);
306
415
  case 'WHOIS':
307
416
  return this.routeWhois(msg, ctx);
417
+ case 'WHOWAS':
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);
308
429
  case 'CHATHISTORY':
309
430
  return this.routeChathistory(msg, ctx);
310
431
 
@@ -339,6 +460,19 @@ export class ConnectionActor {
339
460
  : this.reducers.userMode(this.state, msg, ctx).effects;
340
461
 
341
462
  default:
463
+ // Remaining standard IRC verbs not yet implemented. They fall through
464
+ // to a graceful `421 ERR_UNKNOWNCOMMAND`. Triage of remaining verbs:
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.
342
476
  return [this.unknownCommandEffect(msg.command)];
343
477
  }
344
478
  }
@@ -351,6 +485,40 @@ export class ConnectionActor {
351
485
  return listReducer(snapshots, msg, ctx).effects;
352
486
  }
353
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
+
354
522
  private async routeWho(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): Promise<EffectType[]> {
355
523
  const target = msg.params[0] ?? '';
356
524
  if (target.length === 0) {
@@ -387,6 +555,207 @@ export class ConnectionActor {
387
555
  return whoisReducer(target, channels, msg, ctx).effects;
388
556
  }
389
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
+
630
+ /**
631
+ * Routes `WHOWAS <nick>{,<nick>} [<count>]` to the whowas reducer. The
632
+ * command is query-only: it reads the bound nick-history store
633
+ * (`this.history`, absent when no store is wired) and emits
634
+ * `314`/`369`/`406` numerics. No runtime lookups are needed — the store
635
+ * is synchronous and self-contained.
636
+ */
637
+ private async routeWhowas(
638
+ msg: IrcMessage,
639
+ ctx: ReturnType<typeof buildCtx>,
640
+ ): Promise<EffectType[]> {
641
+ return whowasReducer(this.history, msg, ctx).effects;
642
+ }
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
+
708
+ /**
709
+ * Routes `USERHOST <nick>{ <nick>}` to the userhost reducer. Resolves each
710
+ * requested nick via the nick registry + connection lookup, passing the
711
+ * resulting snapshots (or `null` for offline nicks) to the pure formatter.
712
+ * The number of queried nicks is capped at
713
+ * `serverConfig.maxTargetsPerCommand` by the reducer.
714
+ */
715
+ private async routeUserhost(
716
+ msg: IrcMessage,
717
+ ctx: ReturnType<typeof buildCtx>,
718
+ ): Promise<EffectType[]> {
719
+ const cap = ctx.serverConfig.maxTargetsPerCommand;
720
+ const resolved = new Map<string, ConnSnapshot | null>();
721
+ for (const nick of msg.params.slice(0, cap)) {
722
+ const connId = await this.runtime.lookupNick(nick);
723
+ if (connId === null) {
724
+ resolved.set(nick, null);
725
+ continue;
726
+ }
727
+ const conn = await this.runtime.getConnection(connId);
728
+ resolved.set(nick, conn === null ? null : toSnapshot(conn));
729
+ }
730
+ return userhostReducer(resolved, msg, ctx).effects;
731
+ }
732
+
733
+ /**
734
+ * Routes `ISON <nick>{ <nick>}` to the ison reducer. Resolves each requested
735
+ * nick via the nick registry; for online nicks, fetches the connection to
736
+ * recover the registered (display) spelling. Passes a folded→display map to
737
+ * the pure formatter.
738
+ */
739
+ private async routeIson(
740
+ msg: IrcMessage,
741
+ ctx: ReturnType<typeof buildCtx>,
742
+ ): Promise<EffectType[]> {
743
+ const cap = ctx.serverConfig.maxTargetsPerCommand;
744
+ const online = new Map<string, string>();
745
+ for (const nick of msg.params.slice(0, cap)) {
746
+ const connId = await this.runtime.lookupNick(nick);
747
+ if (connId === null) continue;
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.
752
+ if (conn?.nick !== undefined) {
753
+ online.set(conn.nick.toLowerCase(), conn.nick);
754
+ }
755
+ }
756
+ return isonReducer(online, msg, ctx).effects;
757
+ }
758
+
390
759
  /**
391
760
  * Routes `CHATHISTORY <sub> <target> [<args>...]` to the chathistory
392
761
  * reducer. The command is query-only and must NOT create a channel as a
@@ -531,15 +900,6 @@ export class ConnectionActor {
531
900
  }
532
901
  }
533
902
 
534
- /**
535
- * Splits a WebSocket text frame into IRC lines. Tolerates `\r\n`, bare `\n`,
536
- * and missing trailing terminators. Returns each line bare (no CRLF).
537
- */
538
- export function splitFrameLines(text: string): string[] {
539
- if (text.length === 0) return [];
540
- return text.split(/\r?\n/u);
541
- }
542
-
543
903
  /** Returns the first whitespace-delimited token of `line`, or `''`. */
544
904
  function firstToken(line: string): string {
545
905
  const m = line.match(/^\S+/u);
@@ -555,6 +915,20 @@ function splitCsv(param: string): string[] {
555
915
  return param.split(',').filter((p) => p.length > 0);
556
916
  }
557
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
+
558
932
  /**
559
933
  * Extracts the channel names a reducer will read for the supplied command.
560
934
  * Used by `prefetchChannelState` to refresh only the channels the upcoming
@@ -564,8 +938,11 @@ function splitCsv(param: string): string[] {
564
938
  * Commands not listed here either have no channel target (PING, NICK, …)
565
939
  * or read channel membership from `ctx.connection.joinedChannels` directly
566
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.
567
944
  */
568
- function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
945
+ export function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
569
946
  const cmd = msg.command;
570
947
  switch (cmd) {
571
948
  case 'JOIN':
@@ -33,9 +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
- LookupNick: (e, r) => r.lookupNick(e.nick).then(noop),
37
- GetConnectionInfo: (e, r) => r.getConnectionInfo(e.conn).then(noop),
38
- GetChannelSnapshot: (e, r) => r.getChannelSnapshot(e.chan).then(noop),
36
+ BroadcastWallops: (e, r) => r.broadcastWallops(e.lines, e.except),
39
37
  };
40
38
 
41
39
  function noop(): void {}
@@ -3,11 +3,19 @@
3
3
  *
4
4
  * Orchestration layer: defines the {@link IrcRuntime} port, the {@link dispatch}
5
5
  * interpreter that runs an `Effect[]` against it, and the {@link ConnectionActor}
6
- * that turns a WebSocket text frame into reducer invocations + dispatch.
6
+ * that turns inbound transport input (a WebSocket text frame or a raw TCP byte
7
+ * chunk) into reducer invocations + dispatch via the transport-agnostic
8
+ * {@link Transport} framing seam.
7
9
  */
8
10
  export type { IrcRuntime } from './runtime.js';
9
11
  export { dispatch } from './dispatch.js';
10
- export { ConnectionActor, splitFrameLines } from './actor.js';
12
+ export { ConnectionActor } from './actor.js';
11
13
  export type { ActorChannelAccess, ConnectionActorOptions } from './actor.js';
14
+ export {
15
+ splitFrameLines,
16
+ WsTextFrameTransport,
17
+ TcpByteStreamTransport,
18
+ } from './transport.js';
19
+ export type { Transport } from './transport.js';
12
20
  export { buildRoutedReducers, resolveFloodControl } from './routing.js';
13
21
  export type { RoutedReducers } from './routing.js';
@@ -29,11 +29,13 @@ import {
29
29
  type FloodControlConfig,
30
30
  type Reducer,
31
31
  type ServerConfig,
32
+ adminReducer,
32
33
  authenticateReducer,
33
34
  awayReducer,
34
35
  capReducer,
35
36
  channelModeReducer,
36
37
  defaultCommandCost,
38
+ infoReducer,
37
39
  inviteReducer,
38
40
  joinReducer,
39
41
  kickReducer,
@@ -50,11 +52,14 @@ import {
50
52
  privmsgChannelReducer,
51
53
  privmsgUserReducer,
52
54
  quitReducer,
55
+ setnameReducer,
53
56
  tagmsgChannelReducer,
54
57
  tagmsgUserReducer,
58
+ timeReducer,
55
59
  topicReducer,
56
60
  userModeReducer,
57
61
  userReducer,
62
+ versionReducer,
58
63
  wrapWithFloodControl,
59
64
  } from '@serverless-ircd/irc-core';
60
65
 
@@ -76,6 +81,11 @@ export interface RoutedReducers {
76
81
  readonly cap: Reducer<ConnectionState>;
77
82
  readonly authenticate: Reducer<ConnectionState>;
78
83
  readonly away: Reducer<ConnectionState>;
84
+ readonly setname: Reducer<ConnectionState>;
85
+ readonly version: Reducer<ConnectionState>;
86
+ readonly time: Reducer<ConnectionState>;
87
+ readonly admin: Reducer<ConnectionState>;
88
+ readonly info: Reducer<ConnectionState>;
79
89
  // Channel-authority reducers.
80
90
  readonly join: Reducer<ChannelState>;
81
91
  readonly part: Reducer<ChannelState>;
@@ -142,6 +152,11 @@ export function buildRoutedReducers(serverConfig: ServerConfig): RoutedReducers
142
152
  cap: wrap(capReducer),
143
153
  authenticate: wrap(authenticateReducer),
144
154
  away: wrap(awayReducer),
155
+ setname: wrap(setnameReducer),
156
+ version: wrap(versionReducer),
157
+ time: wrap(timeReducer),
158
+ admin: wrap(adminReducer),
159
+ info: wrap(infoReducer),
145
160
  join: wrap(joinReducer),
146
161
  part: wrap(partReducer),
147
162
  topic: wrap(topicReducer),