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.
- package/.github/workflows/ci.yml +47 -0
- package/.github/workflows/deploy-cf.yml +97 -0
- package/LICENSE +29 -0
- package/README.md +323 -0
- package/apps/cf-worker/package.json +37 -0
- package/apps/cf-worker/scripts/smoke.mjs +175 -0
- package/apps/cf-worker/src/worker.ts +59 -0
- package/apps/cf-worker/tests/smoke.test.ts +150 -0
- package/apps/cf-worker/tsconfig.build.json +17 -0
- package/apps/cf-worker/tsconfig.test.json +18 -0
- package/apps/cf-worker/vitest.config.ts +31 -0
- package/apps/cf-worker/wrangler.test.toml +33 -0
- package/apps/cf-worker/wrangler.toml +98 -0
- package/apps/local-cli/package.json +35 -0
- package/apps/local-cli/src/line-scanner.ts +60 -0
- package/apps/local-cli/src/main.ts +145 -0
- package/apps/local-cli/src/motd-file.ts +45 -0
- package/apps/local-cli/src/server.ts +409 -0
- package/apps/local-cli/tests/e2e.test.ts +346 -0
- package/apps/local-cli/tests/id-factory.test.ts +25 -0
- package/apps/local-cli/tests/line-scanner.test.ts +81 -0
- package/apps/local-cli/tests/motd-file.test.ts +71 -0
- package/apps/local-cli/tests/tcp.test.ts +358 -0
- package/apps/local-cli/tsconfig.build.json +17 -0
- package/apps/local-cli/tsconfig.test.json +15 -0
- package/apps/local-cli/vitest.config.ts +24 -0
- package/biome.json +52 -0
- package/package.json +35 -0
- package/packages/cf-adapter/package.json +38 -0
- package/packages/cf-adapter/src/cf-runtime.ts +308 -0
- package/packages/cf-adapter/src/channel-do.ts +374 -0
- package/packages/cf-adapter/src/connection-do.ts +542 -0
- package/packages/cf-adapter/src/env.ts +67 -0
- package/packages/cf-adapter/src/index.ts +38 -0
- package/packages/cf-adapter/src/registry-do.ts +130 -0
- package/packages/cf-adapter/src/serialize.ts +142 -0
- package/packages/cf-adapter/src/sharding.ts +101 -0
- package/packages/cf-adapter/tests/cf-harness.ts +264 -0
- package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
- package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
- package/packages/cf-adapter/tests/integration/harness.ts +102 -0
- package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
- package/packages/cf-adapter/tests/integration/index.ts +17 -0
- package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
- package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
- package/packages/cf-adapter/tests/sharding.test.ts +100 -0
- package/packages/cf-adapter/tests/worker/main.ts +33 -0
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
- package/packages/cf-adapter/tsconfig.build.json +16 -0
- package/packages/cf-adapter/tsconfig.test.json +18 -0
- package/packages/cf-adapter/vitest.config.ts +32 -0
- package/packages/cf-adapter/wrangler.test.toml +50 -0
- package/packages/in-memory-runtime/package.json +29 -0
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
- package/packages/in-memory-runtime/src/index.ts +9 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
- package/packages/in-memory-runtime/tsconfig.build.json +16 -0
- package/packages/in-memory-runtime/tsconfig.test.json +14 -0
- package/packages/in-memory-runtime/vitest.config.ts +21 -0
- package/packages/irc-core/package.json +29 -0
- package/packages/irc-core/src/caps/capabilities.ts +96 -0
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/commands/away.ts +82 -0
- package/packages/irc-core/src/commands/cap.ts +257 -0
- package/packages/irc-core/src/commands/chghost.ts +30 -0
- package/packages/irc-core/src/commands/index.ts +40 -0
- package/packages/irc-core/src/commands/invite.ts +156 -0
- package/packages/irc-core/src/commands/isupport.ts +133 -0
- package/packages/irc-core/src/commands/join.ts +309 -0
- package/packages/irc-core/src/commands/kick.ts +162 -0
- package/packages/irc-core/src/commands/list.ts +126 -0
- package/packages/irc-core/src/commands/mode.ts +655 -0
- package/packages/irc-core/src/commands/motd.ts +146 -0
- package/packages/irc-core/src/commands/names.ts +164 -0
- package/packages/irc-core/src/commands/part.ts +118 -0
- package/packages/irc-core/src/commands/ping.ts +47 -0
- package/packages/irc-core/src/commands/privmsg.ts +256 -0
- package/packages/irc-core/src/commands/quit.ts +70 -0
- package/packages/irc-core/src/commands/registration.ts +251 -0
- package/packages/irc-core/src/commands/topic.ts +171 -0
- package/packages/irc-core/src/commands/who.ts +169 -0
- package/packages/irc-core/src/commands/whois.ts +165 -0
- package/packages/irc-core/src/effects.ts +184 -0
- package/packages/irc-core/src/index.ts +20 -0
- package/packages/irc-core/src/ports.ts +153 -0
- package/packages/irc-core/src/protocol/batch.ts +85 -0
- package/packages/irc-core/src/protocol/index.ts +18 -0
- package/packages/irc-core/src/protocol/messages.ts +36 -0
- package/packages/irc-core/src/protocol/numerics.ts +155 -0
- package/packages/irc-core/src/protocol/outbound.ts +145 -0
- package/packages/irc-core/src/protocol/parser.ts +175 -0
- package/packages/irc-core/src/protocol/serializer.ts +71 -0
- package/packages/irc-core/src/state/channel.ts +293 -0
- package/packages/irc-core/src/state/connection.ts +153 -0
- package/packages/irc-core/src/state/index.ts +25 -0
- package/packages/irc-core/src/state/registry.ts +22 -0
- package/packages/irc-core/src/types.ts +83 -0
- package/packages/irc-core/tests/batch.test.ts +79 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
- package/packages/irc-core/tests/commands/away.test.ts +233 -0
- package/packages/irc-core/tests/commands/batch.test.ts +178 -0
- package/packages/irc-core/tests/commands/cap.test.ts +499 -0
- package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
- package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
- package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
- package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
- package/packages/irc-core/tests/commands/invite.test.ts +321 -0
- package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
- package/packages/irc-core/tests/commands/join.test.ts +687 -0
- package/packages/irc-core/tests/commands/kick.test.ts +316 -0
- package/packages/irc-core/tests/commands/list.test.ts +452 -0
- package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
- package/packages/irc-core/tests/commands/motd.test.ts +322 -0
- package/packages/irc-core/tests/commands/names.test.ts +342 -0
- package/packages/irc-core/tests/commands/part.test.ts +265 -0
- package/packages/irc-core/tests/commands/ping.test.ts +144 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
- package/packages/irc-core/tests/commands/quit.test.ts +220 -0
- package/packages/irc-core/tests/commands/registration.test.ts +599 -0
- package/packages/irc-core/tests/commands/topic.test.ts +337 -0
- package/packages/irc-core/tests/commands/who.test.ts +441 -0
- package/packages/irc-core/tests/commands/whois.test.ts +422 -0
- package/packages/irc-core/tests/effects.test.ts +147 -0
- package/packages/irc-core/tests/message-tags.test.ts +182 -0
- package/packages/irc-core/tests/numerics.test.ts +98 -0
- package/packages/irc-core/tests/outbound.test.ts +232 -0
- package/packages/irc-core/tests/parser.test.ts +162 -0
- package/packages/irc-core/tests/ports.test.ts +101 -0
- package/packages/irc-core/tests/serializer.test.ts +164 -0
- package/packages/irc-core/tests/state/channel.test.ts +351 -0
- package/packages/irc-core/tests/state/connection.test.ts +122 -0
- package/packages/irc-core/tests/state/registry.test.ts +20 -0
- package/packages/irc-core/tests/types.test.ts +127 -0
- package/packages/irc-core/tsconfig.build.json +12 -0
- package/packages/irc-core/tsconfig.test.json +10 -0
- package/packages/irc-core/vitest.config.ts +21 -0
- package/packages/irc-server/package.json +28 -0
- package/packages/irc-server/src/actor.ts +449 -0
- package/packages/irc-server/src/dispatch.ts +120 -0
- package/packages/irc-server/src/index.ts +11 -0
- package/packages/irc-server/src/runtime.ts +61 -0
- package/packages/irc-server/tests/actor.test.ts +816 -0
- package/packages/irc-server/tests/dispatch.test.ts +512 -0
- package/packages/irc-server/tests/runtime.test.ts +52 -0
- package/packages/irc-server/tsconfig.build.json +13 -0
- package/packages/irc-server/tsconfig.test.json +11 -0
- package/packages/irc-server/vitest.config.ts +21 -0
- package/pnpm-workspace.yaml +4 -0
- package/tools/tcp-ws-forwarder/package.json +32 -0
- package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
- package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
- package/tools/tcp-ws-forwarder/src/main.ts +114 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
- package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
- package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
- package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
- package/tsconfig.base.json +25 -0
- 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 };
|