serverless-ircd 0.1.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 (165) hide show
  1. package/.github/workflows/ci.yml +47 -0
  2. package/.github/workflows/deploy-cf.yml +97 -0
  3. package/LICENSE +29 -0
  4. package/README.md +323 -0
  5. package/apps/cf-worker/package.json +37 -0
  6. package/apps/cf-worker/scripts/smoke.mjs +175 -0
  7. package/apps/cf-worker/src/worker.ts +59 -0
  8. package/apps/cf-worker/tests/smoke.test.ts +150 -0
  9. package/apps/cf-worker/tsconfig.build.json +17 -0
  10. package/apps/cf-worker/tsconfig.test.json +18 -0
  11. package/apps/cf-worker/vitest.config.ts +31 -0
  12. package/apps/cf-worker/wrangler.test.toml +33 -0
  13. package/apps/cf-worker/wrangler.toml +98 -0
  14. package/apps/local-cli/package.json +35 -0
  15. package/apps/local-cli/src/line-scanner.ts +60 -0
  16. package/apps/local-cli/src/main.ts +145 -0
  17. package/apps/local-cli/src/motd-file.ts +45 -0
  18. package/apps/local-cli/src/server.ts +409 -0
  19. package/apps/local-cli/tests/e2e.test.ts +346 -0
  20. package/apps/local-cli/tests/id-factory.test.ts +25 -0
  21. package/apps/local-cli/tests/line-scanner.test.ts +81 -0
  22. package/apps/local-cli/tests/motd-file.test.ts +71 -0
  23. package/apps/local-cli/tests/tcp.test.ts +358 -0
  24. package/apps/local-cli/tsconfig.build.json +17 -0
  25. package/apps/local-cli/tsconfig.test.json +15 -0
  26. package/apps/local-cli/vitest.config.ts +24 -0
  27. package/biome.json +52 -0
  28. package/package.json +35 -0
  29. package/packages/cf-adapter/package.json +38 -0
  30. package/packages/cf-adapter/src/cf-runtime.ts +308 -0
  31. package/packages/cf-adapter/src/channel-do.ts +374 -0
  32. package/packages/cf-adapter/src/connection-do.ts +542 -0
  33. package/packages/cf-adapter/src/env.ts +67 -0
  34. package/packages/cf-adapter/src/index.ts +38 -0
  35. package/packages/cf-adapter/src/registry-do.ts +130 -0
  36. package/packages/cf-adapter/src/serialize.ts +142 -0
  37. package/packages/cf-adapter/src/sharding.ts +101 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +264 -0
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
  41. package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
  42. package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
  43. package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
  44. package/packages/cf-adapter/tests/integration/harness.ts +102 -0
  45. package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
  46. package/packages/cf-adapter/tests/integration/index.ts +17 -0
  47. package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
  48. package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
  49. package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
  50. package/packages/cf-adapter/tests/sharding.test.ts +100 -0
  51. package/packages/cf-adapter/tests/worker/main.ts +33 -0
  52. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
  53. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
  54. package/packages/cf-adapter/tsconfig.build.json +16 -0
  55. package/packages/cf-adapter/tsconfig.test.json +18 -0
  56. package/packages/cf-adapter/vitest.config.ts +32 -0
  57. package/packages/cf-adapter/wrangler.test.toml +50 -0
  58. package/packages/in-memory-runtime/package.json +29 -0
  59. package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
  60. package/packages/in-memory-runtime/src/index.ts +9 -0
  61. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
  62. package/packages/in-memory-runtime/tsconfig.build.json +16 -0
  63. package/packages/in-memory-runtime/tsconfig.test.json +14 -0
  64. package/packages/in-memory-runtime/vitest.config.ts +21 -0
  65. package/packages/irc-core/package.json +29 -0
  66. package/packages/irc-core/src/caps/capabilities.ts +96 -0
  67. package/packages/irc-core/src/caps/index.ts +1 -0
  68. package/packages/irc-core/src/commands/away.ts +82 -0
  69. package/packages/irc-core/src/commands/cap.ts +257 -0
  70. package/packages/irc-core/src/commands/chghost.ts +30 -0
  71. package/packages/irc-core/src/commands/index.ts +40 -0
  72. package/packages/irc-core/src/commands/invite.ts +156 -0
  73. package/packages/irc-core/src/commands/isupport.ts +133 -0
  74. package/packages/irc-core/src/commands/join.ts +309 -0
  75. package/packages/irc-core/src/commands/kick.ts +162 -0
  76. package/packages/irc-core/src/commands/list.ts +126 -0
  77. package/packages/irc-core/src/commands/mode.ts +655 -0
  78. package/packages/irc-core/src/commands/motd.ts +146 -0
  79. package/packages/irc-core/src/commands/names.ts +164 -0
  80. package/packages/irc-core/src/commands/part.ts +118 -0
  81. package/packages/irc-core/src/commands/ping.ts +47 -0
  82. package/packages/irc-core/src/commands/privmsg.ts +256 -0
  83. package/packages/irc-core/src/commands/quit.ts +70 -0
  84. package/packages/irc-core/src/commands/registration.ts +251 -0
  85. package/packages/irc-core/src/commands/topic.ts +171 -0
  86. package/packages/irc-core/src/commands/who.ts +169 -0
  87. package/packages/irc-core/src/commands/whois.ts +165 -0
  88. package/packages/irc-core/src/effects.ts +184 -0
  89. package/packages/irc-core/src/index.ts +20 -0
  90. package/packages/irc-core/src/ports.ts +153 -0
  91. package/packages/irc-core/src/protocol/batch.ts +85 -0
  92. package/packages/irc-core/src/protocol/index.ts +18 -0
  93. package/packages/irc-core/src/protocol/messages.ts +36 -0
  94. package/packages/irc-core/src/protocol/numerics.ts +155 -0
  95. package/packages/irc-core/src/protocol/outbound.ts +145 -0
  96. package/packages/irc-core/src/protocol/parser.ts +175 -0
  97. package/packages/irc-core/src/protocol/serializer.ts +71 -0
  98. package/packages/irc-core/src/state/channel.ts +293 -0
  99. package/packages/irc-core/src/state/connection.ts +153 -0
  100. package/packages/irc-core/src/state/index.ts +25 -0
  101. package/packages/irc-core/src/state/registry.ts +22 -0
  102. package/packages/irc-core/src/types.ts +83 -0
  103. package/packages/irc-core/tests/batch.test.ts +79 -0
  104. package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
  105. package/packages/irc-core/tests/commands/away.test.ts +233 -0
  106. package/packages/irc-core/tests/commands/batch.test.ts +178 -0
  107. package/packages/irc-core/tests/commands/cap.test.ts +499 -0
  108. package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
  109. package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
  110. package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
  111. package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
  112. package/packages/irc-core/tests/commands/invite.test.ts +321 -0
  113. package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
  114. package/packages/irc-core/tests/commands/join.test.ts +687 -0
  115. package/packages/irc-core/tests/commands/kick.test.ts +316 -0
  116. package/packages/irc-core/tests/commands/list.test.ts +452 -0
  117. package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
  118. package/packages/irc-core/tests/commands/motd.test.ts +322 -0
  119. package/packages/irc-core/tests/commands/names.test.ts +342 -0
  120. package/packages/irc-core/tests/commands/part.test.ts +265 -0
  121. package/packages/irc-core/tests/commands/ping.test.ts +144 -0
  122. package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
  123. package/packages/irc-core/tests/commands/quit.test.ts +220 -0
  124. package/packages/irc-core/tests/commands/registration.test.ts +599 -0
  125. package/packages/irc-core/tests/commands/topic.test.ts +337 -0
  126. package/packages/irc-core/tests/commands/who.test.ts +441 -0
  127. package/packages/irc-core/tests/commands/whois.test.ts +422 -0
  128. package/packages/irc-core/tests/effects.test.ts +147 -0
  129. package/packages/irc-core/tests/message-tags.test.ts +182 -0
  130. package/packages/irc-core/tests/numerics.test.ts +98 -0
  131. package/packages/irc-core/tests/outbound.test.ts +232 -0
  132. package/packages/irc-core/tests/parser.test.ts +162 -0
  133. package/packages/irc-core/tests/ports.test.ts +101 -0
  134. package/packages/irc-core/tests/serializer.test.ts +164 -0
  135. package/packages/irc-core/tests/state/channel.test.ts +351 -0
  136. package/packages/irc-core/tests/state/connection.test.ts +122 -0
  137. package/packages/irc-core/tests/state/registry.test.ts +20 -0
  138. package/packages/irc-core/tests/types.test.ts +127 -0
  139. package/packages/irc-core/tsconfig.build.json +12 -0
  140. package/packages/irc-core/tsconfig.test.json +10 -0
  141. package/packages/irc-core/vitest.config.ts +21 -0
  142. package/packages/irc-server/package.json +28 -0
  143. package/packages/irc-server/src/actor.ts +449 -0
  144. package/packages/irc-server/src/dispatch.ts +120 -0
  145. package/packages/irc-server/src/index.ts +11 -0
  146. package/packages/irc-server/src/runtime.ts +61 -0
  147. package/packages/irc-server/tests/actor.test.ts +816 -0
  148. package/packages/irc-server/tests/dispatch.test.ts +512 -0
  149. package/packages/irc-server/tests/runtime.test.ts +52 -0
  150. package/packages/irc-server/tsconfig.build.json +13 -0
  151. package/packages/irc-server/tsconfig.test.json +11 -0
  152. package/packages/irc-server/vitest.config.ts +21 -0
  153. package/pnpm-workspace.yaml +4 -0
  154. package/tools/tcp-ws-forwarder/package.json +32 -0
  155. package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
  156. package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
  157. package/tools/tcp-ws-forwarder/src/main.ts +114 -0
  158. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
  159. package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
  160. package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
  161. package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
  162. package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
  163. package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
  164. package/tsconfig.base.json +25 -0
  165. package/turbo.json +29 -0
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `apps/local-cli` entry point.
4
+ *
5
+ * Boots a local IRC server backed by the in-memory runtime. By default it
6
+ * binds TWO listeners on adjacent ports so the harness serves both use
7
+ * cases from a single command:
8
+ *
9
+ * • a WebSocket listener (PLAN §3 — the serverless transport) on `--port`
10
+ * for programmatic test fixtures,
11
+ * • an RFC-style TCP listener on `--tcp-port` so real IRC clients
12
+ * (WeeChat, HexChat, IRCCloud, …) can dial in directly.
13
+ *
14
+ * Both listeners share one runtime, so a TCP client and a WS client can
15
+ * see each other (cross-transport channel broadcast). Pass `--no-tcp` to
16
+ * run WebSocket-only; the WebSocket port / hostname / MOTD are tunable via
17
+ * flags (a fuller config-loader lands in a later ticket).
18
+ *
19
+ * Usage:
20
+ * pnpm --filter local-cli start -- --port 6667 --tcp-port 6668 --host 127.0.0.1
21
+ * pnpm --filter local-cli start -- --motd-file ./motd.txt
22
+ */
23
+
24
+ import { loadMotdFile } from './motd-file.js';
25
+ import { startLocalServer } from './server.js';
26
+
27
+ interface CliArgs {
28
+ port: number;
29
+ hostname: string;
30
+ /** undefined when the TCP listener is disabled via `--no-tcp`. */
31
+ tcpPort: number | undefined;
32
+ /** undefined when --motd-file is not given; built-in default MOTD applies. */
33
+ motdFile: string | undefined;
34
+ }
35
+
36
+ const DEFAULT_PORT = 6667;
37
+
38
+ function parseArgs(argv: string[]): CliArgs {
39
+ const out: CliArgs = {
40
+ port: DEFAULT_PORT,
41
+ hostname: '127.0.0.1',
42
+ tcpPort: undefined,
43
+ motdFile: undefined,
44
+ };
45
+ let tcpDisabled = false;
46
+ let tcpPortOverride: number | undefined;
47
+ for (let i = 0; i < argv.length; i++) {
48
+ const a = argv[i];
49
+ const next = argv[i + 1];
50
+ if (a === '--port' && next !== undefined) {
51
+ out.port = Number.parseInt(next, 10);
52
+ i++;
53
+ } else if (a === '--host' && next !== undefined) {
54
+ out.hostname = next;
55
+ i++;
56
+ } else if (a === '--tcp-port' && next !== undefined) {
57
+ tcpPortOverride = Number.parseInt(next, 10);
58
+ i++;
59
+ } else if (a === '--motd-file' && next !== undefined) {
60
+ out.motdFile = next;
61
+ i++;
62
+ } else if (a === '--no-tcp') {
63
+ tcpDisabled = true;
64
+ } else if (a === '--help' || a === '-h') {
65
+ process.stdout.write(
66
+ [
67
+ 'Usage: local-cli --port <n> --host <addr> [--tcp-port <n> | --no-tcp] [--motd-file <path>]',
68
+ '',
69
+ 'Options:',
70
+ ' --port <n> WebSocket port to bind (default 6667).',
71
+ ' --host <addr> Hostname / interface to bind (default 127.0.0.1).',
72
+ ' --tcp-port <n> TCP port for RFC-style IRC clients. Defaults to',
73
+ ' <port> + 1 (so 6668) unless --no-tcp is given.',
74
+ ' --no-tcp Disable the TCP listener (WebSocket only).',
75
+ ' --motd-file <path> Read MOTD lines from this file (one per line).',
76
+ ' Optional; overrides the built-in default MOTD.',
77
+ ' -h, --help Show this help and exit.',
78
+ '',
79
+ ].join('\n'),
80
+ );
81
+ process.exit(0);
82
+ } else {
83
+ process.stderr.write(`unknown argument: ${a}\n`);
84
+ process.exit(2);
85
+ }
86
+ }
87
+ if (!tcpDisabled) {
88
+ out.tcpPort = tcpPortOverride ?? out.port + 1;
89
+ }
90
+ return out;
91
+ }
92
+
93
+ async function main(): Promise<void> {
94
+ const args = parseArgs(process.argv.slice(2));
95
+ // --motd-file is optional; missing-file errors throw synchronously with a
96
+ // motd-prefixed message, surfaced as a fatal by the outer catch.
97
+ const motdLines = args.motdFile !== undefined ? loadMotdFile(args.motdFile) : undefined;
98
+ const server = await startLocalServer({
99
+ port: args.port,
100
+ hostname: args.hostname,
101
+ tcpPort: args.tcpPort,
102
+ ...(motdLines !== undefined ? { motdLines } : {}),
103
+ });
104
+
105
+ process.stdout.write(
106
+ `${JSON.stringify({
107
+ ts: new Date().toISOString(),
108
+ level: 'info',
109
+ msg: 'local-cli listening',
110
+ wsUrl: `ws://${server.hostname}:${server.port}/`,
111
+ port: server.port,
112
+ tcpPort: server.tcpPort ?? null,
113
+ hostname: server.hostname,
114
+ })}\n`,
115
+ );
116
+ // Mentioned in --help; kept as a second line for human readers connecting
117
+ // WeeChat/HexChat/etc. to the TCP listener.
118
+ if (server.tcpPort !== undefined) {
119
+ process.stdout.write(
120
+ ` irc clients connect via TCP: /connect ${server.hostname}/${server.tcpPort}\n`,
121
+ );
122
+ }
123
+
124
+ const shutdown = (signal: string): void => {
125
+ process.stdout.write(
126
+ `${JSON.stringify({ ts: new Date().toISOString(), level: 'info', msg: 'shutdown', signal })}\n`,
127
+ );
128
+ server
129
+ .close()
130
+ .then(() => process.exit(0))
131
+ .catch((err) => {
132
+ process.stderr.write(`shutdown error: ${String(err)}\n`);
133
+ process.exit(1);
134
+ });
135
+ };
136
+ process.on('SIGINT', () => shutdown('SIGINT'));
137
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
138
+ }
139
+
140
+ main().catch((err) => {
141
+ process.stderr.write(
142
+ `fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`,
143
+ );
144
+ process.exit(1);
145
+ });
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ /**
4
+ * Parses the text contents of a MOTD file into the logical IRC MOTD lines
5
+ * the server should advertise.
6
+ *
7
+ * Framing rules mirror the local-cli TCP `LineScanner` (PLAN §9): both
8
+ * CRLF (`\r\n`) and bare LF (`\n`) terminate a line; the trailing CR of a
9
+ * CRLF pair is stripped, and a lone CR mid-text is treated as data. A
10
+ * single trailing newline does not synthesize a final empty line, but
11
+ * interior blank lines are preserved so ASCII-art MOTDs keep their shape.
12
+ *
13
+ * An empty (or all-newline) input yields an empty array, which the IRC
14
+ * `MOTD` reducer maps to `422 ERR_NOMOTD`.
15
+ */
16
+ export function parseMotdFileContent(content: string): string[] {
17
+ if (content.length === 0) return [];
18
+ // Split on LF; strip a trailing CR from each piece (CRLF tolerance).
19
+ const pieces = content
20
+ .split('\n')
21
+ .map((line) => (line.length > 0 && line[line.length - 1] === '\r' ? line.slice(0, -1) : line));
22
+ // Drop the synthesized trailing empty element when the input ends with LF.
23
+ if (pieces[pieces.length - 1] === '') pieces.pop();
24
+ return pieces;
25
+ }
26
+
27
+ /**
28
+ * Reads a MOTD file from disk synchronously and returns its parsed lines.
29
+ *
30
+ * Synchronous I/O is intentional: `main.ts` resolves CLI args and any
31
+ * derived configuration (including the MOTD) before invoking
32
+ * `startLocalServer`. Failures (missing file, unreadable) are surfaced as
33
+ * an `Error` whose message mentions `motd` so the fatal handler in
34
+ * `main.ts` produces a recognisable diagnostic.
35
+ */
36
+ export function loadMotdFile(path: string): string[] {
37
+ let raw: string;
38
+ try {
39
+ raw = readFileSync(path, 'utf8');
40
+ } catch (err) {
41
+ const detail = err instanceof Error ? err.message : String(err);
42
+ throw new Error(`motd: cannot read --motd-file at ${path}: ${detail}`);
43
+ }
44
+ return parseMotdFileContent(raw);
45
+ }
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Local IRC server wiring.
3
+ *
4
+ * PLAN §2 + §3 (Phase 7 dual-transport) — the local CLI adapter is the
5
+ * manual-test harness and the e2e fixture target. It binds TWO listeners
6
+ * sharing one {@link InMemoryRuntime}:
7
+ *
8
+ * • a `ws.WebSocketServer` on {@link StartServerOptions.port} — the
9
+ * original WebSocket transport (one IRC message per text frame, or N
10
+ * `\r\n`-joined messages per frame). Programmatic test clients use this.
11
+ * • an optional `node:net` TCP server on {@link StartServerOptions.tcpPort}
12
+ * — the RFC-style byte stream that real IRC clients (WeeChat, HexChat,
13
+ * IRCCloud) speak. The TCP path uses {@link LineScanner} to reassemble
14
+ * IRC lines across chunk boundaries.
15
+ *
16
+ * Both transports allocate a fresh {@link ConnectionState} per connection,
17
+ * attach a {@link ConnectionActor} running against the SAME shared runtime,
18
+ * and route inbound bytes → `actor.receiveTextFrame`. Because nick/channel
19
+ * state lives in the runtime, a TCP client and a WS client can see each
20
+ * other (cross-transport channel broadcast).
21
+ *
22
+ * This is `apps/local-cli` rather than `packages/` because the transport
23
+ * wiring is platform-specific: the CF and AWS adapters wire the same actor
24
+ * + runtime to Durable Object hibernation sockets and APIGW respectively.
25
+ */
26
+
27
+ import { randomUUID } from 'node:crypto';
28
+ import { type Server, type Socket, createServer } from 'node:net';
29
+ import { InMemoryRuntime } from '@serverless-ircd/in-memory-runtime';
30
+ import {
31
+ type ChanName,
32
+ type ConnectionState,
33
+ type RawLine,
34
+ createConnection,
35
+ } from '@serverless-ircd/irc-core';
36
+ import { ConnectionActor } from '@serverless-ircd/irc-server';
37
+ import { WebSocket, WebSocketServer } from 'ws';
38
+ import { LineScanner } from './line-scanner.js';
39
+
40
+ /** Default server config; `startLocalServer` accepts overrides. */
41
+ export interface LocalServerConfig {
42
+ serverName?: string;
43
+ networkName?: string;
44
+ motdLines?: string[];
45
+ maxChannelsPerUser?: number;
46
+ maxTargetsPerCommand?: number;
47
+ maxListEntries?: number;
48
+ nickLen?: number;
49
+ channelLen?: number;
50
+ topicLen?: number;
51
+ quitMessage?: string;
52
+ }
53
+
54
+ export interface StartServerOptions extends LocalServerConfig {
55
+ port: number;
56
+ hostname?: string;
57
+ /**
58
+ * Optional TCP port. When provided, also starts an RFC-style TCP IRC
59
+ * listener that real IRC clients can dial directly. Use 0 for an
60
+ * ephemeral port (the actual port is exposed on the returned
61
+ * {@link LocalServer}). When omitted, only the WebSocket listener runs.
62
+ */
63
+ tcpPort?: number | undefined;
64
+ }
65
+
66
+ /** A running local IRC server. Call {@link close} to shut it down. */
67
+ export interface LocalServer {
68
+ readonly port: number;
69
+ readonly hostname: string;
70
+ /**
71
+ * The actual TCP port, when {@link StartServerOptions.tcpPort} was
72
+ * provided; `undefined` when the TCP transport is disabled.
73
+ */
74
+ readonly tcpPort?: number | undefined;
75
+ readonly runtime: InMemoryRuntime;
76
+ close(): Promise<void>;
77
+ /**
78
+ * Test-only seam: live server-side WebSockets. Exposed so tests can
79
+ * synthesize socket-level events (`emit('error', ...)`) that are otherwise
80
+ * unreachable without a real protocol fault. Production code MUST NOT use.
81
+ */
82
+ readonly testSockets: ReadonlySet<WebSocket>;
83
+ /**
84
+ * Test-only seam: live server-side TCP sockets when the TCP listener is
85
+ * enabled; `undefined` otherwise. Same purpose as {@link testSockets}.
86
+ */
87
+ readonly testTcpSockets?: ReadonlySet<Socket> | undefined;
88
+ }
89
+
90
+ interface ConnectionBindings {
91
+ state: ConnectionState;
92
+ actor: ConnectionActor;
93
+ }
94
+
95
+ const DEFAULT_CONFIG: Required<LocalServerConfig> = {
96
+ serverName: 'irc.example.com',
97
+ networkName: 'ExampleNet',
98
+ motdLines: ['Welcome to the local-cli ServerlessIRCd instance.'],
99
+ maxChannelsPerUser: 30,
100
+ maxTargetsPerCommand: 10,
101
+ maxListEntries: 50,
102
+ nickLen: 30,
103
+ channelLen: 50,
104
+ topicLen: 390,
105
+ quitMessage: 'Client Quit',
106
+ };
107
+
108
+ /**
109
+ * Shared IdFactory. Hoisted to module scope so v8's function-coverage counts
110
+ * the three generators once at module load, not per-connection. No reducer in
111
+ * the current codebase actually invokes `batchId` / `nonce` / `sessionId`;
112
+ * they are forward-looking seams for SASL / BATCH / context-restoration
113
+ * tickets and are exercised by irc-core's own unit tests via test fakes.
114
+ *
115
+ * Exported so `apps/local-cli` can ship a focused unit test asserting each
116
+ * generator returns a fresh UUID-shaped string (defending against future
117
+ * refactorings that might collapse them into constant strings).
118
+ */
119
+ export const DEFAULT_ID_FACTORY = {
120
+ batchId: () => randomUUID(),
121
+ nonce: () => randomUUID(),
122
+ sessionId: () => randomUUID(),
123
+ };
124
+
125
+ /**
126
+ * Starts a local IRC server. Resolves once BOTH the WebSocket listener and
127
+ * (if requested) the TCP listener are ready. Use port 0 to bind ephemeral
128
+ * ports (the actual ports are exposed on the returned {@link LocalServer}).
129
+ *
130
+ * If the TCP listener fails to bind after the WebSocket listener succeeded,
131
+ * the WebSocket listener is torn down before the promise rejects so callers
132
+ * don't leak a half-started server.
133
+ */
134
+ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer> {
135
+ const cfg: Required<LocalServerConfig> = { ...DEFAULT_CONFIG, ...stripUndefined(opts) };
136
+ const hostname = opts.hostname ?? '127.0.0.1';
137
+
138
+ const runtime = new InMemoryRuntime({ clock: { now: () => Date.now() } });
139
+
140
+ const wss = new WebSocketServer({ port: opts.port, host: hostname });
141
+ const wsConnections = new Map<WebSocket, ConnectionBindings>();
142
+
143
+ wss.on('connection', (ws) => {
144
+ const { state, actor } = attachConnection(runtime, cfg, {
145
+ sendText: (text) => {
146
+ if (ws.readyState === WebSocket.OPEN) ws.send(text);
147
+ },
148
+ closeTransport: () => ws.close(),
149
+ });
150
+ wsConnections.set(ws, { state, actor });
151
+
152
+ ws.on('message', (data) => {
153
+ const text = data.toString('utf8');
154
+ // Fire-and-forget: the actor's reducer pipeline is synchronous through
155
+ // to dispatch, and dispatch awaits each runtime call in order. Errors
156
+ // propagate to the catch here and are logged (never crash the server).
157
+ actor.receiveTextFrame(text).catch((err) => {
158
+ logError('actor failure', err);
159
+ });
160
+ });
161
+
162
+ // 'close' and 'error' both end in the same cleanup: drop the connection
163
+ // map entry and unregister the conn from the runtime so it stops
164
+ // receiving broadcasts.
165
+ const cleanup = (): void => {
166
+ wsConnections.delete(ws);
167
+ runtime.unregisterConnection(state.id);
168
+ };
169
+ ws.on('close', cleanup);
170
+ ws.on('error', (err) => {
171
+ logError('socket error', err);
172
+ cleanup();
173
+ });
174
+ });
175
+
176
+ return new Promise<LocalServer>((resolve, reject) => {
177
+ const onWsError = (err: Error): void => {
178
+ wss.removeListener('listening', onWsListening);
179
+ reject(err);
180
+ };
181
+ const onWsListening = (): void => {
182
+ wss.removeListener('error', onWsError);
183
+ const addr = wss.address();
184
+ // c8 ignore next 1 - defensive fallback for non-IP transports; with a
185
+ // TCP port `wss.address()` always returns an AddressInfo object.
186
+ const wsPort = typeof addr === 'object' && addr !== null ? addr.port : opts.port;
187
+
188
+ // If no TCP listener was requested, resolve immediately with just WS.
189
+ if (opts.tcpPort === undefined) {
190
+ finishResolve({ wsPort, tcpPort: undefined, tcpServer: undefined });
191
+ return;
192
+ }
193
+
194
+ // Spin up the TCP listener and wait for it to be ready. If it fails,
195
+ // tear down the WS server first so callers don't leak a half-started
196
+ // server.
197
+ const tcpConnections = new Map<Socket, ConnectionBindings>();
198
+ const tcpSockets = new Set<Socket>();
199
+ const tcpServer = createServer((socket) => {
200
+ tcpSockets.add(socket);
201
+ const scanner = new LineScanner();
202
+ const { state, actor } = attachConnection(runtime, cfg, {
203
+ sendText: (text) => {
204
+ if (socket.writable) socket.write(text);
205
+ },
206
+ closeTransport: () => {
207
+ // `destroy()` over `end()` so a half-closed peer can't hold the
208
+ // socket open past the actor's QUIT/disconnect decision. WS
209
+ // callers go through `ws.close()`; TCP callers go through here.
210
+ socket.destroy();
211
+ },
212
+ });
213
+ tcpConnections.set(socket, { state, actor });
214
+
215
+ socket.on('data', (chunk: Buffer) => {
216
+ for (const line of scanner.push(chunk)) {
217
+ if (line.length === 0) continue;
218
+ actor.receiveTextFrame(line).catch((err) => {
219
+ logError('actor failure', err);
220
+ });
221
+ }
222
+ });
223
+
224
+ const cleanup = (): void => {
225
+ tcpConnections.delete(socket);
226
+ tcpSockets.delete(socket);
227
+ runtime.unregisterConnection(state.id);
228
+ };
229
+ socket.on('close', cleanup);
230
+ socket.on('error', (err) => {
231
+ logError('tcp socket error', err);
232
+ cleanup();
233
+ });
234
+ });
235
+
236
+ tcpServer.once('error', (err) => {
237
+ // Tear down the WS server first; reject with the TCP error so the
238
+ // caller sees the actual bind failure.
239
+ teardownWss(wss, wsConnections).finally(() => reject(err));
240
+ });
241
+ tcpServer.once('listening', () => {
242
+ const tcpAddr = tcpServer.address();
243
+ // c8 ignore next 1 - defensive: a TCP listener always yields AddressInfo.
244
+ const tcpPort =
245
+ typeof tcpAddr === 'object' && tcpAddr !== null ? tcpAddr.port : opts.tcpPort;
246
+ finishResolve({ wsPort, tcpPort, tcpServer, tcpConnections, tcpSockets });
247
+ });
248
+ tcpServer.listen(opts.tcpPort, hostname);
249
+ };
250
+
251
+ const finishResolve = (args: {
252
+ wsPort: number;
253
+ tcpPort: number | undefined;
254
+ tcpServer: Server | undefined;
255
+ tcpConnections?: Map<Socket, ConnectionBindings>;
256
+ tcpSockets?: Set<Socket>;
257
+ }): void => {
258
+ const { wsPort, tcpPort, tcpServer, tcpConnections, tcpSockets } = args;
259
+ resolve({
260
+ port: wsPort,
261
+ tcpPort,
262
+ hostname,
263
+ runtime,
264
+ testSockets: wss.clients,
265
+ testTcpSockets: tcpSockets,
266
+ close: () =>
267
+ closeEverything({
268
+ wss,
269
+ wsConnections,
270
+ tcpServer,
271
+ tcpConnections,
272
+ }),
273
+ });
274
+ };
275
+
276
+ wss.once('listening', onWsListening);
277
+ wss.once('error', onWsError);
278
+ });
279
+ }
280
+
281
+ /**
282
+ * Allocates a fresh {@link ConnectionState} + {@link ConnectionActor} against
283
+ * the shared runtime, and registers the connection's send/disconnect
284
+ * transport callbacks. Used by both the WS and TCP listeners so they share
285
+ * identical actor lifecycle wiring — only the byte-moving callbacks differ.
286
+ */
287
+ function attachConnection(
288
+ runtime: InMemoryRuntime,
289
+ cfg: Required<LocalServerConfig>,
290
+ transport: { sendText: (text: string) => void; closeTransport: () => void },
291
+ ): ConnectionBindings {
292
+ const id = randomUUID();
293
+ const state = createConnection({ id, connectedSince: Date.now() });
294
+
295
+ const actor = new ConnectionActor({
296
+ state,
297
+ runtime,
298
+ channels: runtime,
299
+ serverConfig: {
300
+ serverName: cfg.serverName,
301
+ networkName: cfg.networkName,
302
+ maxChannelsPerUser: cfg.maxChannelsPerUser,
303
+ maxTargetsPerCommand: cfg.maxTargetsPerCommand,
304
+ maxListEntries: cfg.maxListEntries,
305
+ nickLen: cfg.nickLen,
306
+ channelLen: cfg.channelLen,
307
+ topicLen: cfg.topicLen,
308
+ quitMessage: cfg.quitMessage,
309
+ },
310
+ clock: { now: () => Date.now() },
311
+ ids: DEFAULT_ID_FACTORY,
312
+ motd: { lines: () => cfg.motdLines },
313
+ });
314
+
315
+ runtime.registerConnection(state, {
316
+ send: (lines: RawLine[]) => {
317
+ const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
318
+ transport.sendText(text);
319
+ },
320
+ disconnect: (reason?: string) => {
321
+ // If a reason was supplied, send the RFC-style ERROR notice before
322
+ // closing. (QUIT does not supply one; future paths like flood
323
+ // control will.)
324
+ if (reason !== undefined) {
325
+ transport.sendText(`ERROR :Closing link: (${reason})\r\n`);
326
+ }
327
+ transport.closeTransport();
328
+ },
329
+ });
330
+
331
+ return { state, actor };
332
+ }
333
+
334
+ /** Tears down the WS server and all live WS connections. */
335
+ function teardownWss(
336
+ wss: WebSocketServer,
337
+ connections: Map<WebSocket, ConnectionBindings>,
338
+ ): Promise<void> {
339
+ for (const ws of connections.keys()) {
340
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
341
+ ws.close();
342
+ }
343
+ }
344
+ connections.clear();
345
+ return new Promise<void>((res) => {
346
+ wss.close(() => res());
347
+ });
348
+ }
349
+
350
+ /** Idempotent shutdown of every started listener and all live connections. */
351
+ function closeEverything(args: {
352
+ wss: WebSocketServer;
353
+ wsConnections: Map<WebSocket, ConnectionBindings>;
354
+ tcpServer: Server | undefined;
355
+ tcpConnections?: Map<Socket, ConnectionBindings> | undefined;
356
+ }): Promise<void> {
357
+ const { wss, wsConnections, tcpServer, tcpConnections } = args;
358
+
359
+ // Tear down WS connections + server first.
360
+ const wsClosed = teardownWss(wss, wsConnections);
361
+
362
+ // Tear down TCP connections + server in parallel (if enabled).
363
+ const tcpClosed =
364
+ tcpServer === undefined
365
+ ? Promise.resolve()
366
+ : new Promise<void>((res) => {
367
+ if (tcpConnections !== undefined) {
368
+ for (const socket of tcpConnections.keys()) {
369
+ // Defensive: reachable only if a socket was already destroyed
370
+ // (e.g. via a reducer-driven disconnect racing the close sweep)
371
+ // but hasn't yet been removed from `tcpConnections`. The cleanup
372
+ // handler always removes on 'close'/'error', so this branch is a
373
+ // no-op in steady state.
374
+ // c8 ignore next 1
375
+ if (!socket.destroyed) socket.destroy();
376
+ }
377
+ tcpConnections.clear();
378
+ }
379
+ tcpServer.close(() => res());
380
+ });
381
+
382
+ return Promise.all([wsClosed, tcpClosed]).then(() => undefined);
383
+ }
384
+
385
+ function stripUndefined<T extends object>(obj: T): Partial<T> {
386
+ const out: Partial<T> = {};
387
+ for (const key of Object.keys(obj) as Array<keyof T>) {
388
+ const v = obj[key];
389
+ if (v !== undefined) out[key] = v;
390
+ }
391
+ return out;
392
+ }
393
+
394
+ function logError(msg: string, err: unknown): void {
395
+ // Structured JSON per acceptance criteria.
396
+ const payload = { ts: new Date().toISOString(), level: 'error', msg, err: stringifyErr(err) };
397
+ // eslint-disable-next-line no-console
398
+ console.error(JSON.stringify(payload));
399
+ }
400
+
401
+ function stringifyErr(err: unknown): unknown {
402
+ if (err instanceof Error) {
403
+ return { name: err.name, message: err.message, stack: err.stack };
404
+ }
405
+ return err;
406
+ }
407
+
408
+ // Suppress "ChanName imported but unused" — kept for type re-export ergonomics.
409
+ export type { ChanName, ConnectionState };