serverless-ircd 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +435 -0
  6. package/README.md +206 -27
  7. package/apps/aws-stack/README.md +37 -3
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +106 -13
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +49 -3
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +138 -28
  27. package/apps/local-cli/tests/e2e.test.ts +113 -29
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  56. package/packages/aws-adapter/src/handlers/default.ts +66 -5
  57. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
  59. package/packages/aws-adapter/src/index.ts +2 -0
  60. package/packages/aws-adapter/src/serialize.ts +40 -2
  61. package/packages/aws-adapter/src/stats.ts +80 -0
  62. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  63. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  64. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  65. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  66. package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
  67. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  68. package/packages/aws-adapter/tests/connect.test.ts +100 -4
  69. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  70. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  72. package/packages/aws-adapter/tests/handlers.test.ts +238 -4
  73. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  74. package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
  75. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  76. package/packages/cf-adapter/package.json +6 -1
  77. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  78. package/packages/cf-adapter/src/channel-do.ts +2 -2
  79. package/packages/cf-adapter/src/connection-do.ts +185 -54
  80. package/packages/cf-adapter/src/env.ts +25 -6
  81. package/packages/cf-adapter/src/index.ts +2 -0
  82. package/packages/cf-adapter/src/registry-do.ts +22 -3
  83. package/packages/cf-adapter/src/serialize.ts +25 -4
  84. package/packages/cf-adapter/src/sharding.ts +1 -2
  85. package/packages/cf-adapter/src/stats.ts +65 -0
  86. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  87. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  88. package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
  89. package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
  90. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  91. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  92. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  93. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  94. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  95. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  96. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  97. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  98. package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
  99. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  100. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  101. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  102. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  103. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  104. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  105. package/packages/cf-adapter/vitest.config.ts +1 -1
  106. package/packages/cf-adapter/wrangler.test.toml +7 -0
  107. package/packages/in-memory-runtime/package.json +1 -1
  108. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  109. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  110. package/packages/irc-core/package.json +1 -1
  111. package/packages/irc-core/src/admission.ts +16 -15
  112. package/packages/irc-core/src/caps/capabilities.ts +38 -4
  113. package/packages/irc-core/src/caps/index.ts +1 -0
  114. package/packages/irc-core/src/caps/sts.ts +84 -0
  115. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  116. package/packages/irc-core/src/commands/away.ts +9 -3
  117. package/packages/irc-core/src/commands/cap.ts +23 -5
  118. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  119. package/packages/irc-core/src/commands/index.ts +42 -0
  120. package/packages/irc-core/src/commands/invite.ts +2 -4
  121. package/packages/irc-core/src/commands/isupport.ts +59 -2
  122. package/packages/irc-core/src/commands/kick.ts +2 -4
  123. package/packages/irc-core/src/commands/kill.ts +127 -0
  124. package/packages/irc-core/src/commands/list.ts +1 -1
  125. package/packages/irc-core/src/commands/lusers.ts +204 -0
  126. package/packages/irc-core/src/commands/mode.ts +12 -9
  127. package/packages/irc-core/src/commands/monitor.ts +327 -0
  128. package/packages/irc-core/src/commands/multiline.ts +256 -0
  129. package/packages/irc-core/src/commands/names.ts +3 -5
  130. package/packages/irc-core/src/commands/part.ts +2 -4
  131. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  132. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  133. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  134. package/packages/irc-core/src/commands/registration.ts +8 -0
  135. package/packages/irc-core/src/commands/rehash.ts +119 -0
  136. package/packages/irc-core/src/commands/sasl.ts +24 -1
  137. package/packages/irc-core/src/commands/setname.ts +109 -0
  138. package/packages/irc-core/src/commands/stats.ts +152 -0
  139. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  140. package/packages/irc-core/src/commands/topic.ts +2 -4
  141. package/packages/irc-core/src/commands/trace.ts +137 -0
  142. package/packages/irc-core/src/commands/wallops.ts +118 -0
  143. package/packages/irc-core/src/commands/whois.ts +5 -0
  144. package/packages/irc-core/src/config.ts +72 -10
  145. package/packages/irc-core/src/effects.ts +41 -1
  146. package/packages/irc-core/src/index.ts +2 -0
  147. package/packages/irc-core/src/ports.ts +568 -0
  148. package/packages/irc-core/src/protocol/index.ts +14 -0
  149. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  150. package/packages/irc-core/src/protocol/numerics.ts +57 -11
  151. package/packages/irc-core/src/protocol/outbound.ts +36 -4
  152. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  153. package/packages/irc-core/src/state/connection.ts +32 -1
  154. package/packages/irc-core/src/types.ts +120 -1
  155. package/packages/irc-core/src/ws-framing.ts +132 -0
  156. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  157. package/packages/irc-core/stryker.commands.conf.json +1 -2
  158. package/packages/irc-core/tests/admission.test.ts +18 -0
  159. package/packages/irc-core/tests/away-store.test.ts +73 -0
  160. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  161. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  162. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  163. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  164. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  165. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  166. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  167. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  168. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  169. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  170. package/packages/irc-core/tests/commands/mode.test.ts +129 -0
  171. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  172. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  173. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  174. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  175. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  176. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  177. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  178. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  179. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  180. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  181. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  182. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  183. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  184. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  185. package/packages/irc-core/tests/config.test.ts +103 -13
  186. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  187. package/packages/irc-core/tests/effects.test.ts +14 -0
  188. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  189. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  190. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  191. package/packages/irc-core/tests/numerics.test.ts +90 -0
  192. package/packages/irc-core/tests/outbound.test.ts +51 -0
  193. package/packages/irc-core/tests/ports.test.ts +22 -0
  194. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  195. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  196. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  197. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  198. package/packages/irc-core/tests/types.test.ts +27 -0
  199. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  200. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  201. package/packages/irc-server/package.json +1 -1
  202. package/packages/irc-server/src/actor.ts +699 -19
  203. package/packages/irc-server/src/dispatch.ts +109 -16
  204. package/packages/irc-server/src/routing.ts +3 -0
  205. package/packages/irc-server/src/runtime.ts +31 -0
  206. package/packages/irc-server/src/transport.ts +10 -7
  207. package/packages/irc-server/tests/actor.test.ts +2523 -42
  208. package/packages/irc-server/tests/dispatch.test.ts +300 -2
  209. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  210. package/packages/irc-server/tests/routing.test.ts +1 -0
  211. package/packages/irc-server/tests/runtime.test.ts +7 -0
  212. package/packages/irc-test-support/package.json +1 -1
  213. package/packages/irc-test-support/src/index.ts +6 -0
  214. package/packages/irc-test-support/src/scenarios.ts +9 -1
  215. package/packages/irc-test-support/src/test-config.ts +54 -0
  216. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  217. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/tools/ci-hardening/package.json +1 -1
  220. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  221. package/tools/load-test/package.json +33 -0
  222. package/tools/load-test/src/client.ts +351 -0
  223. package/tools/load-test/src/config.ts +313 -0
  224. package/tools/load-test/src/harness.ts +116 -0
  225. package/tools/load-test/src/main.ts +120 -0
  226. package/tools/load-test/src/metrics.ts +168 -0
  227. package/tools/load-test/src/report.ts +106 -0
  228. package/tools/load-test/tests/client.test.ts +212 -0
  229. package/tools/load-test/tests/config.test.ts +152 -0
  230. package/tools/load-test/tests/framing.test.ts +37 -0
  231. package/tools/load-test/tests/harness.test.ts +165 -0
  232. package/tools/load-test/tests/metrics.test.ts +174 -0
  233. package/tools/load-test/tests/report.test.ts +161 -0
  234. package/tools/load-test/tests/smoke.test.ts +67 -0
  235. package/tools/load-test/tsconfig.build.json +12 -0
  236. package/tools/load-test/tsconfig.test.json +10 -0
  237. package/tools/load-test/vitest.config.ts +29 -0
  238. package/tools/package.json +6 -1
  239. package/tools/seed-cf-accounts.ts +4 -1
  240. package/tools/tcp-ws-forwarder/package.json +1 -1
  241. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  242. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  243. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -8,6 +8,11 @@
8
8
  * • a `ws.WebSocketServer` on {@link StartServerOptions.port} — the
9
9
  * original WebSocket transport (one IRC message per text frame, or N
10
10
  * `\r\n`-joined messages per frame). Programmatic test clients use this.
11
+ * When a client negotiates an IRCv3 subprotocol
12
+ * (`text.ircv3.net` / `binary.ircv3.net`) the connection switches to
13
+ * spec framing: one IRC message per WebSocket message with no trailing
14
+ * CR-LF, a 510-byte inbound budget, and (for text) lone-surrogate
15
+ * sanitization. Un-negotiated clients keep the legacy framing.
11
16
  * • an optional `node:net` TCP server on {@link StartServerOptions.tcpPort}
12
17
  * — the RFC-style byte stream that real IRC clients (WeeChat, HexChat,
13
18
  * IRCCloud) speak. The TCP path uses {@link LineScanner} to reassemble
@@ -41,9 +46,19 @@ import {
41
46
  type MessageStore,
42
47
  type NickHistoryStore,
43
48
  type RawLine,
49
+ type ServerConfig,
50
+ WS_SUBPROTO_BINARY,
51
+ WS_SUBPROTO_TEXT,
52
+ type WsFrameMode,
53
+ type WsSubprotocol,
44
54
  createConnection,
55
+ frameToLines,
56
+ isWithinWsByteBudget,
57
+ sanitizeForTextMode,
58
+ selectSubprotocol,
59
+ wsFrameModeFor,
45
60
  } from '@serverless-ircd/irc-core';
46
- import { ConnectionActor } from '@serverless-ircd/irc-server';
61
+ import { ConnectionActor, type Transport } from '@serverless-ircd/irc-server';
47
62
  import { WebSocket, WebSocketServer } from 'ws';
48
63
  import { loadServerConfigFromCliArgs } from './config-loader.js';
49
64
  import { LineScanner } from './line-scanner.js';
@@ -73,6 +88,28 @@ type ResolvedServerConfig = Omit<
73
88
  | 'saslAccounts'
74
89
  >;
75
90
 
91
+ /**
92
+ * Builds the reducer-facing {@link ServerConfig} from the resolved local-cli
93
+ * config. Shared by {@link attachConnection} (initial config) and the
94
+ * `InMemoryRuntime` reload source (REHASH) so a reload returns the same
95
+ * shape the actor was bootstrapped with — keeping the two paths consistent.
96
+ */
97
+ function serverConfigFromResolved(cfg: ResolvedServerConfig): ServerConfig {
98
+ return {
99
+ serverName: cfg.serverName,
100
+ networkName: cfg.networkName,
101
+ maxChannelsPerUser: cfg.maxChannelsPerUser,
102
+ maxTargetsPerCommand: cfg.maxTargetsPerCommand,
103
+ maxListEntries: cfg.maxListEntries,
104
+ nickLen: cfg.nickLen,
105
+ channelLen: cfg.channelLen,
106
+ topicLen: cfg.topicLen,
107
+ quitMessage: cfg.quitMessage,
108
+ ...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
109
+ ...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
110
+ };
111
+ }
112
+
76
113
  /** Default per-IP / per-user caps used when callers don't override. */
77
114
  const DEFAULT_MAX_CONNECTIONS_PER_IP = 10;
78
115
  const DEFAULT_MAX_CONNECTIONS_PER_USER = 5;
@@ -198,9 +235,9 @@ const DEFAULT_CONFIG: Omit<
198
235
  | 'perIpConnectionRate'
199
236
  | 'historyMaxPerChannel'
200
237
  | 'saslAccounts'
238
+ | 'serverName'
239
+ | 'networkName'
201
240
  > = {
202
- serverName: 'irc.example.com',
203
- networkName: 'ExampleNet',
204
241
  motdLines: ['Welcome to the local-cli ServerlessIRCd instance.'],
205
242
  maxChannelsPerUser: 30,
206
243
  maxTargetsPerCommand: 10,
@@ -216,7 +253,7 @@ const DEFAULT_CONFIG: Omit<
216
253
  * the three generators once at module load, not per-connection. No reducer in
217
254
  * the current codebase actually invokes `batchId` / `nonce` / `sessionId`;
218
255
  * they are forward-looking seams for SASL / BATCH / context-restoration
219
- * tickets and are exercised by irc-core's own unit tests via test fakes.
256
+ * and are exercised by irc-core's own unit tests via test fakes.
220
257
  *
221
258
  * Exported so `apps/local-cli` can ship a focused unit test asserting each
222
259
  * generator returns a fresh UUID-shaped string (defending against future
@@ -242,14 +279,16 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
242
279
  // Validate the user-supplied config through the shared ServerConfigSchema
243
280
  // (sourced from `irc-core`). Both adapters consume the same schema; here
244
281
  // we discard the parsed result and only retain the side-effect of
245
- // throwing on a malformed config at boot. The actor still consumes the
246
- // `LocalServerConfig` projection so existing callers (and the e2e
247
- // harness) keep their permissive defaults.
282
+ // throwing on a malformed config at boot. `serverName` and `networkName`
283
+ // are required by the schema passing them through as `undefined` when
284
+ // the caller omits them lets the loader fail fast with a readable error
285
+ // pointing at the missing knob, so the server never boots advertising a
286
+ // placeholder identity.
248
287
  let parsed: ReturnType<typeof loadServerConfigFromCliArgs>;
249
288
  try {
250
289
  parsed = loadServerConfigFromCliArgs({
251
- serverName: opts.serverName ?? DEFAULT_CONFIG.serverName,
252
- networkName: opts.networkName ?? DEFAULT_CONFIG.networkName,
290
+ serverName: opts.serverName,
291
+ networkName: opts.networkName,
253
292
  ...(opts.motdLines !== undefined ? { motdLines: opts.motdLines } : {}),
254
293
  ...(opts.maxChannelsPerUser !== undefined
255
294
  ? { maxChannelsPerUser: opts.maxChannelsPerUser }
@@ -312,6 +351,10 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
312
351
  const runtime = new InMemoryRuntime({
313
352
  clock: { now: () => Date.now() },
314
353
  ...(admission !== undefined ? { admission } : {}),
354
+ // REHASH reload source: re-derive the live ServerConfig from the
355
+ // resolved config so an oper's `REHASH` re-fetches the latest values
356
+ // (rotated oper creds, MOTD, limits) without restarting the process.
357
+ configLoader: async () => serverConfigFromResolved(cfg),
315
358
  });
316
359
 
317
360
  // Per-process chat-history store: one ring buffer shared by every
@@ -333,8 +376,23 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
333
376
  ? new InMemoryAccountStore(cfg.saslAccounts)
334
377
  : undefined;
335
378
 
336
- const wss = new WebSocketServer({ port: opts.port, host: hostname });
379
+ const wss = new WebSocketServer({
380
+ port: opts.port,
381
+ host: hostname,
382
+ // IRCv3 WebSocket subprotocol negotiation: select the first supported
383
+ // offer in client-preference order (core selector), or fall through to
384
+ // legacy framing when none match. The `ws` library only invokes this
385
+ // when the client offered protocols; returning the selected name echoes
386
+ // it, returning false completes the handshake with no
387
+ // Sec-WebSocket-Protocol header (legacy) — it never rejects.
388
+ handleProtocols: (protocols: Set<string>): string | false =>
389
+ selectSubprotocol([...protocols]) ?? false,
390
+ });
337
391
  const wsConnections = new Map<WebSocket, ConnectionBindings>();
392
+ // Per-connection negotiated IRCv3 subprotocol registry (null = legacy
393
+ // framing). Mirrors the selection made in `handleProtocols`; the resolved
394
+ // frame mode is also captured in the per-connection handlers below.
395
+ const wsModes = new Map<WebSocket, WsSubprotocol | null>();
338
396
 
339
397
  wss.on('connection', (ws, req) => {
340
398
  // Admission gate: refuse the connection BEFORE we attach an actor / emit
@@ -350,18 +408,50 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
350
408
  const admissionRecordId = decision.recordId;
351
409
  runtime.commitAdmission(ip, undefined, admissionRecordId);
352
410
 
411
+ // Resolve the negotiated IRCv3 subprotocol (echoed by handleProtocols)
412
+ // and derive the frame mode. `ws.protocol` is '' when none negotiated.
413
+ const subproto: WsSubprotocol | null =
414
+ ws.protocol === WS_SUBPROTO_TEXT || ws.protocol === WS_SUBPROTO_BINARY
415
+ ? (ws.protocol as WsSubprotocol)
416
+ : null;
417
+ wsModes.set(ws, subproto);
418
+ const frameMode = wsFrameModeFor(subproto ?? undefined);
419
+
353
420
  const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
354
421
  sendText: (text) => {
355
- if (ws.readyState === WebSocket.OPEN) ws.send(text);
422
+ if (ws.readyState !== WebSocket.OPEN) return;
423
+ // binary.ircv3.net connections exchange binary WebSocket frames;
424
+ // text/legacy connections send UTF-8 text frames.
425
+ ws.send(frameMode === 'spec-binary' ? Buffer.from(text, 'utf8') : text);
356
426
  },
357
427
  closeTransport: () => ws.close(),
358
428
  admissionRecordId,
359
429
  sourceHost: ip,
430
+ frameMode,
431
+ // Spec-mode framing: one IRC message per WS message (strip a single
432
+ // trailing CR-LF, never split on an embedded line break). Legacy
433
+ // connections fall through to the actor's default WsTextFrameTransport.
434
+ ...(frameMode === 'legacy'
435
+ ? {}
436
+ : { actorTransport: { feed: (chunk: string) => frameToLines(chunk, frameMode) } }),
360
437
  });
361
438
  wsConnections.set(ws, { state, actor });
362
439
 
363
440
  ws.on('message', (data) => {
364
441
  const text = data.toString('utf8');
442
+ // Spec-mode inbound: enforce the IRCv3 510-byte message budget and
443
+ // sanitize lone surrogates for text frames before framing. Legacy
444
+ // frames pass through unchanged (the 512-byte line cap is enforced in
445
+ // the parser, as before).
446
+ if (frameMode === 'spec-text' || frameMode === 'spec-binary') {
447
+ if (!isWithinWsByteBudget(text)) {
448
+ ws.close(1009, 'Message Too Big');
449
+ return;
450
+ }
451
+ const inbound = frameMode === 'spec-text' ? sanitizeForTextMode(text) : text;
452
+ actor.receiveTextFrame(inbound).catch((err) => logError('actor failure', err));
453
+ return;
454
+ }
365
455
  // Fire-and-forget: the actor's reducer pipeline is synchronous through
366
456
  // to dispatch, and dispatch awaits each runtime call in order. Errors
367
457
  // propagate to the catch here and are logged (never crash the server).
@@ -376,6 +466,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
376
466
  // admission record (if any) so per-IP / per-user counters stay accurate.
377
467
  const cleanup = (): void => {
378
468
  wsConnections.delete(ws);
469
+ wsModes.delete(ws);
379
470
  runtime.unregisterConnection(state.id);
380
471
  };
381
472
  ws.on('close', cleanup);
@@ -527,6 +618,20 @@ function attachConnection(
527
618
  closeTransport: () => void;
528
619
  admissionRecordId?: string;
529
620
  sourceHost?: string;
621
+ /**
622
+ * Negotiated IRCv3 WebSocket frame mode for this connection. Spec modes
623
+ * (`spec-text` / `spec-binary`) switch outbound delivery to one WS
624
+ * message per IRC line with no trailing CR-LF (and surrogate
625
+ * sanitization for text). `undefined` / `legacy` keeps the original
626
+ * `\r\n`-joined batch. TCP connections never set this.
627
+ */
628
+ frameMode?: WsFrameMode;
629
+ /**
630
+ * Inbound line-framing seam forwarded to the actor's `Transport`. Only
631
+ * set for spec-mode WebSocket connections (one IRC line per frame);
632
+ * when omitted the actor defaults to its WS-text-frame transport.
633
+ */
634
+ actorTransport?: Transport;
530
635
  },
531
636
  ): ConnectionBindings {
532
637
  const id = randomUUID();
@@ -547,19 +652,8 @@ function attachConnection(
547
652
  state,
548
653
  runtime,
549
654
  channels: runtime,
550
- serverConfig: {
551
- serverName: cfg.serverName,
552
- networkName: cfg.networkName,
553
- maxChannelsPerUser: cfg.maxChannelsPerUser,
554
- maxTargetsPerCommand: cfg.maxTargetsPerCommand,
555
- maxListEntries: cfg.maxListEntries,
556
- nickLen: cfg.nickLen,
557
- channelLen: cfg.channelLen,
558
- topicLen: cfg.topicLen,
559
- quitMessage: cfg.quitMessage,
560
- ...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
561
- ...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
562
- },
655
+ serverConfig: serverConfigFromResolved(cfg),
656
+ configSource: 'config file',
563
657
  clock: { now: () => Date.now() },
564
658
  ids: DEFAULT_ID_FACTORY,
565
659
  motd: { lines: () => cfg.motdLines },
@@ -567,21 +661,37 @@ function attachConnection(
567
661
  ...(accounts !== undefined ? { accounts } : {}),
568
662
  history,
569
663
  logger,
664
+ ...(transport.actorTransport !== undefined ? { transport: transport.actorTransport } : {}),
570
665
  });
571
666
 
572
667
  runtime.registerConnection(
573
668
  state,
574
669
  {
575
670
  send: (lines: RawLine[]) => {
576
- const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
577
- transport.sendText(text);
671
+ const fm = transport.frameMode;
672
+ if (fm === 'spec-text' || fm === 'spec-binary') {
673
+ // Spec mode: one WebSocket message per IRC line, no trailing
674
+ // CR-LF. Sanitize lone surrogates for text frames (they have no
675
+ // valid UTF-8 encoding); binary frames pass through verbatim.
676
+ for (const l of lines) {
677
+ transport.sendText(fm === 'spec-text' ? sanitizeForTextMode(l.text) : l.text);
678
+ }
679
+ } else {
680
+ const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
681
+ transport.sendText(text);
682
+ }
578
683
  },
579
684
  disconnect: (reason?: string) => {
580
685
  // If a reason was supplied, send the RFC-style ERROR notice before
581
686
  // closing. (QUIT does not supply one; future paths like flood
582
- // control will.)
687
+ // control will.) In spec mode the notice is its own message with no
688
+ // trailing CR-LF.
583
689
  if (reason !== undefined) {
584
- transport.sendText(`ERROR :Closing link: (${reason})\r\n`);
690
+ const line = `ERROR :Closing link: (${reason})`;
691
+ const fm = transport.frameMode;
692
+ if (fm === 'spec-text') transport.sendText(sanitizeForTextMode(line));
693
+ else if (fm === 'spec-binary') transport.sendText(line);
694
+ else transport.sendText(`${line}\r\n`);
585
695
  }
586
696
  transport.closeTransport();
587
697
  },
@@ -80,7 +80,12 @@ describe('local-cli e2e', () => {
80
80
  let server: LocalServer;
81
81
 
82
82
  beforeAll(async () => {
83
- server = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
83
+ server = await startLocalServer({
84
+ port: 0,
85
+ hostname: '127.0.0.1',
86
+ serverName: 'irc.local.example.com',
87
+ networkName: 'LocalNet',
88
+ });
84
89
  });
85
90
 
86
91
  afterAll(async () => {
@@ -103,15 +108,15 @@ describe('local-cli e2e', () => {
103
108
 
104
109
  await alice.send('NICK alice');
105
110
  await alice.send('USER alice 0 * :Alice');
106
- await alice.waitFor((l) => l.startsWith(':irc.example.com 001 '));
111
+ await alice.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
107
112
 
108
113
  await bob.send('NICK bob');
109
114
  await bob.send('USER bob 0 * :Bob');
110
- await bob.waitFor((l) => l.startsWith(':irc.example.com 001 '));
115
+ await bob.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
111
116
 
112
117
  await alice.send('JOIN #e2e');
113
118
  await alice.waitFor((l) => l.includes('JOIN #e2e'));
114
- await alice.waitFor((l) => l.startsWith(':irc.example.com 353 '));
119
+ await alice.waitFor((l) => l.startsWith(':irc.local.example.com 353 '));
115
120
 
116
121
  await bob.send('JOIN #e2e');
117
122
  // Bob sees his own JOIN + NAMES.
@@ -130,7 +135,7 @@ describe('local-cli e2e', () => {
130
135
  await client.opened();
131
136
  await client.send('NICK charlie');
132
137
  await client.send('USER charlie 0 * :Charlie');
133
- await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
138
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
134
139
 
135
140
  await client.send('PING :tok-12345');
136
141
  await client.waitFor((l) => l === 'PONG :tok-12345');
@@ -143,7 +148,7 @@ describe('local-cli e2e', () => {
143
148
  await client.opened();
144
149
  await client.send('NICK dave');
145
150
  await client.send('USER dave 0 * :Dave');
146
- await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
151
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
147
152
 
148
153
  await client.send('BOGUSCMD');
149
154
  await client.waitFor((l) => l.includes('421') && l.includes('BOGUSCMD'));
@@ -156,7 +161,7 @@ describe('local-cli e2e', () => {
156
161
  await client.opened();
157
162
  await client.send('NICK eve');
158
163
  await client.send('USER eve 0 * :Eve');
159
- await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
164
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
160
165
 
161
166
  await client.send('QUIT :bye');
162
167
  // The disconnect effect should close the socket.
@@ -166,18 +171,42 @@ describe('local-cli e2e', () => {
166
171
  await next.opened();
167
172
  await next.send('NICK eve');
168
173
  await next.send('USER eve 0 * :Eve2');
169
- await next.waitFor((l) => l.startsWith(':irc.example.com 001 '), 2000);
174
+ await next.waitFor((l) => l.startsWith(':irc.local.example.com 001 '), 2000);
170
175
  await next.close();
171
176
  });
172
177
  });
173
178
 
174
179
  describe('local-cli error paths', () => {
175
180
  it('rejects startLocalServer when the port is already in use', async () => {
176
- const first = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
177
- await expect(startLocalServer({ port: first.port, hostname: '127.0.0.1' })).rejects.toThrow();
181
+ const first = await startLocalServer({
182
+ port: 0,
183
+ hostname: '127.0.0.1',
184
+ serverName: 'irc.local.example.com',
185
+ networkName: 'LocalNet',
186
+ });
187
+ await expect(
188
+ startLocalServer({
189
+ port: first.port,
190
+ hostname: '127.0.0.1',
191
+ serverName: 'irc.local.example.com',
192
+ networkName: 'LocalNet',
193
+ }),
194
+ ).rejects.toThrow();
178
195
  await first.close();
179
196
  });
180
197
 
198
+ it('rejects when serverName is omitted so the placeholder never leaks', async () => {
199
+ await expect(startLocalServer({ port: 0, hostname: '127.0.0.1' })).rejects.toThrowError(
200
+ /serverName/u,
201
+ );
202
+ });
203
+
204
+ it('rejects when networkName is omitted', async () => {
205
+ await expect(
206
+ startLocalServer({ port: 0, hostname: '127.0.0.1', serverName: 'irc.local.example.com' }),
207
+ ).rejects.toThrowError(/networkName/u);
208
+ });
209
+
181
210
  it('validates the supplied config through the shared ServerConfigSchema at boot', async () => {
182
211
  // nickLen=0 is rejected by the schema (must be a positive integer).
183
212
  await expect(
@@ -192,7 +221,12 @@ describe('local-cli error paths', () => {
192
221
  });
193
222
 
194
223
  it('logs and recovers from a per-socket error without crashing the server', async () => {
195
- const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
224
+ const srv = await startLocalServer({
225
+ port: 0,
226
+ hostname: '127.0.0.1',
227
+ serverName: 'irc.local.example.com',
228
+ networkName: 'LocalNet',
229
+ });
196
230
  const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
197
231
  await client.opened();
198
232
  // Synthesize a socket-level error on the SERVER-side ws via the
@@ -218,20 +252,22 @@ describe('local-cli error paths', () => {
218
252
  const srv = await startLocalServer({
219
253
  port: 0,
220
254
  hostname: '127.0.0.1',
255
+ serverName: 'irc.local.example.com',
256
+ networkName: 'LocalNet',
221
257
  motdLines: ['first line of motd', 'second line of motd'],
222
258
  });
223
259
  const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
224
260
  await client.opened();
225
261
  await client.send('NICK karl');
226
262
  await client.send('USER karl 0 * :Karl');
227
- await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
263
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
228
264
  // Drain registration burst so the explicit MOTD responses below aren't
229
265
  // confused with the placeholder MOTD emitted at registration.
230
266
  client.received.length = 0;
231
267
  await client.send('MOTD');
232
268
  // Wait for end-of-MOTD so all 372 lines are buffered in `received`.
233
- await client.waitFor((l) => l.startsWith(':irc.example.com 376 karl'));
234
- const motdBody = client.received.filter((l) => l.startsWith(':irc.example.com 372 karl'));
269
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 376 karl'));
270
+ const motdBody = client.received.filter((l) => l.startsWith(':irc.local.example.com 372 karl'));
235
271
  expect(motdBody.some((l) => l.includes('first line of motd'))).toBe(true);
236
272
  expect(motdBody.some((l) => l.includes('second line of motd'))).toBe(true);
237
273
  await client.close();
@@ -239,7 +275,11 @@ describe('local-cli error paths', () => {
239
275
  });
240
276
 
241
277
  it('defaults hostname to 127.0.0.1 when omitted', async () => {
242
- const srv = await startLocalServer({ port: 0 });
278
+ const srv = await startLocalServer({
279
+ port: 0,
280
+ serverName: 'irc.local.example.com',
281
+ networkName: 'LocalNet',
282
+ });
243
283
  expect(srv.hostname).toBe('127.0.0.1');
244
284
  // Sanity-check that the server is actually reachable on the default.
245
285
  const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
@@ -249,7 +289,12 @@ describe('local-cli error paths', () => {
249
289
  });
250
290
 
251
291
  it('close() forcibly closes any still-open client sockets', async () => {
252
- const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
292
+ const srv = await startLocalServer({
293
+ port: 0,
294
+ hostname: '127.0.0.1',
295
+ serverName: 'irc.local.example.com',
296
+ networkName: 'LocalNet',
297
+ });
253
298
  const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
254
299
  await client.opened();
255
300
  // Close the server WITHOUT first closing the client — exercises the
@@ -260,14 +305,24 @@ describe('local-cli error paths', () => {
260
305
  });
261
306
 
262
307
  it('close() is a no-op when no connections are live', async () => {
263
- const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
308
+ const srv = await startLocalServer({
309
+ port: 0,
310
+ hostname: '127.0.0.1',
311
+ serverName: 'irc.local.example.com',
312
+ networkName: 'LocalNet',
313
+ });
264
314
  await srv.close();
265
315
  // Closing again should not throw (idempotent).
266
316
  await expect(srv.close()).resolves.toBeUndefined();
267
317
  });
268
318
 
269
319
  it('logs an actor failure without crashing the server (Error path)', async () => {
270
- const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
320
+ const srv = await startLocalServer({
321
+ port: 0,
322
+ hostname: '127.0.0.1',
323
+ serverName: 'irc.local.example.com',
324
+ networkName: 'LocalNet',
325
+ });
271
326
  // Force the runtime's `send` to throw a real Error so the actor's
272
327
  // reducer pipeline rejects with an Error instance — exercises
273
328
  // `stringifyErr`'s `instanceof Error` branch and `logError`.
@@ -291,14 +346,19 @@ describe('local-cli error paths', () => {
291
346
  await next.opened();
292
347
  await next.send('NICK grace');
293
348
  await next.send('USER grace 0 * :Grace');
294
- await next.waitFor((l) => l.startsWith(':irc.example.com 001 '), 2000);
349
+ await next.waitFor((l) => l.startsWith(':irc.local.example.com 001 '), 2000);
295
350
  await next.close();
296
351
  await client.close();
297
352
  await srv.close();
298
353
  });
299
354
 
300
355
  it('logs a non-Error throw from the actor pipeline (defensive path)', async () => {
301
- const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
356
+ const srv = await startLocalServer({
357
+ port: 0,
358
+ hostname: '127.0.0.1',
359
+ serverName: 'irc.local.example.com',
360
+ networkName: 'LocalNet',
361
+ });
302
362
  const original = srv.runtime.send.bind(srv.runtime);
303
363
  srv.runtime.send = async () => {
304
364
  // Throw a non-Error value to exercise stringifyErr's else branch.
@@ -316,12 +376,17 @@ describe('local-cli error paths', () => {
316
376
  });
317
377
 
318
378
  it('cleans up when a client terminates abruptly (server-side error path)', async () => {
319
- const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
379
+ const srv = await startLocalServer({
380
+ port: 0,
381
+ hostname: '127.0.0.1',
382
+ serverName: 'irc.local.example.com',
383
+ networkName: 'LocalNet',
384
+ });
320
385
  const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
321
386
  await client.opened();
322
387
  await client.send('NICK ivan');
323
388
  await client.send('USER ivan 0 * :Ivan');
324
- await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
389
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
325
390
  const connId = await srv.runtime.lookupNick('ivan');
326
391
  expect(connId).not.toBeNull();
327
392
  expect(srv.runtime.hasConnection(connId as string)).toBe(true);
@@ -330,7 +395,7 @@ describe('local-cli error paths', () => {
330
395
  // 'error' followed by 'close', and the cleanup handler unregisters the
331
396
  // connection from the runtime. (The nick itself is released by the
332
397
  // QUIT reducer on graceful shutdown; abrupt termination leaks the nick
333
- // pending a future gone-connection sweep — 041.)
398
+ // pending a future gone-connection sweep.)
334
399
  client.ws.terminate();
335
400
  await new Promise((r) => setTimeout(r, 100));
336
401
  expect(srv.runtime.hasConnection(connId as string)).toBe(false);
@@ -338,12 +403,17 @@ describe('local-cli error paths', () => {
338
403
  });
339
404
 
340
405
  it('delivers an ERROR notice when a reducer supplies a disconnect reason', async () => {
341
- const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
406
+ const srv = await startLocalServer({
407
+ port: 0,
408
+ hostname: '127.0.0.1',
409
+ serverName: 'irc.local.example.com',
410
+ networkName: 'LocalNet',
411
+ });
342
412
  const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
343
413
  await client.opened();
344
414
  await client.send('NICK heidi');
345
415
  await client.send('USER heidi 0 * :Heidi');
346
- await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
416
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
347
417
 
348
418
  // Call the runtime's disconnect directly with a reason (simulates a
349
419
  // future flood-control / oper-kill effect). The runtime takes a ConnId;
@@ -370,12 +440,17 @@ describe('local-cli chathistory end-to-end', () => {
370
440
  client.send(`NICK ${nick}\r\n`);
371
441
  client.send(`USER ${nick} 0 * :${nick}\r\n`);
372
442
  client.send('CAP END\r\n');
373
- await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
443
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
374
444
  return client;
375
445
  }
376
446
 
377
447
  it('records a PRIVMSG and replays it via CHATHISTORY LATEST in a BATCH', async () => {
378
- const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
448
+ const srv = await startLocalServer({
449
+ port: 0,
450
+ hostname: '127.0.0.1',
451
+ serverName: 'irc.local.example.com',
452
+ networkName: 'LocalNet',
453
+ });
379
454
  const alice = await registerWithCaps(srv, 'ch-alice');
380
455
 
381
456
  alice.send('JOIN #chathist-e2e\r\n');
@@ -395,7 +470,12 @@ describe('local-cli chathistory end-to-end', () => {
395
470
  });
396
471
 
397
472
  it('prepends a chathistory BATCH on rejoin after messages were sent', async () => {
398
- const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
473
+ const srv = await startLocalServer({
474
+ port: 0,
475
+ hostname: '127.0.0.1',
476
+ serverName: 'irc.local.example.com',
477
+ networkName: 'LocalNet',
478
+ });
399
479
 
400
480
  // Seed history as alice: join + send a message that bob has missed.
401
481
  const alice = await registerWithCaps(srv, 'ch-seed');
@@ -429,6 +509,8 @@ describe('local-cli SASL PLAIN end-to-end', () => {
429
509
  const srv = await startLocalServer({
430
510
  port: 0,
431
511
  hostname: '127.0.0.1',
512
+ serverName: 'irc.local.example.com',
513
+ networkName: 'LocalNet',
432
514
  saslAccounts: [{ username: 'sasl-alice', password: 's3cret' }],
433
515
  });
434
516
 
@@ -445,7 +527,7 @@ describe('local-cli SASL PLAIN end-to-end', () => {
445
527
  client.send('NICK sasl-alice\r\n');
446
528
  client.send('USER sasl-alice 0 * :Sasl Alice\r\n');
447
529
  client.send('CAP END\r\n');
448
- await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
530
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
449
531
  await client.close();
450
532
  await srv.close();
451
533
  });
@@ -454,6 +536,8 @@ describe('local-cli SASL PLAIN end-to-end', () => {
454
536
  const srv = await startLocalServer({
455
537
  port: 0,
456
538
  hostname: '127.0.0.1',
539
+ serverName: 'irc.local.example.com',
540
+ networkName: 'LocalNet',
457
541
  saslAccounts: [{ username: 'sasl-bob', password: 'right' }],
458
542
  });
459
543