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,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TCP → ws/wss forwarder.
|
|
3
|
+
*
|
|
4
|
+
* The ServerlessIRCd v1 transport is WebSocket-only (PLAN §3): real IRC
|
|
5
|
+
* clients speak a line-oriented TCP byte stream, so a standard client
|
|
6
|
+
* cannot dial a deployed worker directly. This forwarder is the local
|
|
7
|
+
* bridge: it accepts an IRC TCP connection on `127.0.0.1:<port>` and, for
|
|
8
|
+
* each TCP socket, opens one WebSocket to the configured `ws://` / `wss://`
|
|
9
|
+
* target and pumps bytes both ways.
|
|
10
|
+
*
|
|
11
|
+
* IRC client ──TCP──▶ forwarder ──WS──▶ ServerlessIRCd endpoint
|
|
12
|
+
* IRC client ◀─TCP── forwarder ◀─WS── ServerlessIRCd endpoint
|
|
13
|
+
*
|
|
14
|
+
* Framing:
|
|
15
|
+
* • TCP→WS: the TCP byte stream is reassembled into IRC lines with the
|
|
16
|
+
* byte-accurate {@link LineScanner} (tolerant of CRLF and bare LF per
|
|
17
|
+
* PLAN §9). Each complete line is sent as one bare WebSocket text frame
|
|
18
|
+
* — the canonical "one message per frame" convention the server's
|
|
19
|
+
* `ConnectionActor` expects.
|
|
20
|
+
* • WS→TCP: each inbound WS frame is split on line terminators and every
|
|
21
|
+
* non-empty line is written back to the TCP socket with a trailing
|
|
22
|
+
* CRLF, normalizing whatever framing the server used (single message,
|
|
23
|
+
* `\r\n`-joined, or bare) into canonical IRC wire format.
|
|
24
|
+
*
|
|
25
|
+
* Lifetime: closing either side tears down the whole bridge. The forwarder
|
|
26
|
+
* itself is transport-agnostic and has no `@serverless-ircd/*` dependency.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { type Socket, createServer } from 'node:net';
|
|
30
|
+
import { type RawData, WebSocket } from 'ws';
|
|
31
|
+
import { LineScanner } from './line-scanner.js';
|
|
32
|
+
|
|
33
|
+
/** Options accepted by {@link startForwarder}. */
|
|
34
|
+
export interface StartForwarderOptions {
|
|
35
|
+
/** TCP port to listen on. Use 0 for an ephemeral port. */
|
|
36
|
+
listenPort: number;
|
|
37
|
+
/** Interface to bind. Defaults to `127.0.0.1` (loopback only). */
|
|
38
|
+
listenHost?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Upstream WebSocket URL to bridge each TCP connection to. Must use the
|
|
41
|
+
* `ws:` or `wss:` scheme; otherwise `startForwarder` throws synchronously.
|
|
42
|
+
*/
|
|
43
|
+
targetUrl: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A running forwarder. Call {@link Forwarder.close} to shut it down. */
|
|
47
|
+
export interface Forwarder {
|
|
48
|
+
/** The bound TCP port (the actual port when `listenPort: 0` was requested). */
|
|
49
|
+
readonly listenPort: number;
|
|
50
|
+
/** The interface the TCP listener is bound to. */
|
|
51
|
+
readonly listenHost: string;
|
|
52
|
+
/** The normalized `ws://` / `wss://` URL each connection is bridged to. */
|
|
53
|
+
readonly targetUrl: string;
|
|
54
|
+
/** Closes the listener and every live bridge. Idempotent. */
|
|
55
|
+
close(): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Test-only seam: live server-side TCP sockets. Exposed so tests can
|
|
58
|
+
* synthesize socket-level events (`emit('error', ...)`) that are otherwise
|
|
59
|
+
* unreachable without a real transport fault. Production code MUST NOT use.
|
|
60
|
+
*/
|
|
61
|
+
readonly testSockets: ReadonlySet<Socket>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parses and validates the upstream target URL. Throws synchronously if the
|
|
66
|
+
* URL is unparseable or does not use the `ws:` / `wss:` scheme.
|
|
67
|
+
*/
|
|
68
|
+
export function parseTargetUrl(raw: string): URL {
|
|
69
|
+
let url: URL;
|
|
70
|
+
try {
|
|
71
|
+
url = new URL(raw);
|
|
72
|
+
} catch {
|
|
73
|
+
throw new Error(`invalid target URL (expected ws:// or wss://): ${raw}`);
|
|
74
|
+
}
|
|
75
|
+
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
|
|
76
|
+
throw new Error(`target URL must use ws:// or wss:// (got ${url.protocol})`);
|
|
77
|
+
}
|
|
78
|
+
return url;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Starts the forwarder. Resolves once the TCP listener is ready. Throws
|
|
83
|
+
* synchronously for a bad `targetUrl`; the returned promise rejects if the
|
|
84
|
+
* requested port cannot be bound.
|
|
85
|
+
*/
|
|
86
|
+
export function startForwarder(opts: StartForwarderOptions): Promise<Forwarder> {
|
|
87
|
+
const target = parseTargetUrl(opts.targetUrl);
|
|
88
|
+
const listenHost = opts.listenHost ?? '127.0.0.1';
|
|
89
|
+
const targetHref = target.href;
|
|
90
|
+
|
|
91
|
+
const bridges = new Map<Socket, () => void>();
|
|
92
|
+
const sockets = new Set<Socket>();
|
|
93
|
+
let forwarderClosed = false;
|
|
94
|
+
|
|
95
|
+
const server = createServer((tcp) => {
|
|
96
|
+
sockets.add(tcp);
|
|
97
|
+
bridges.set(tcp, bridgeConnection(tcp, targetHref, bridges, sockets));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const close = (): Promise<void> => {
|
|
101
|
+
if (forwarderClosed) return Promise.resolve();
|
|
102
|
+
forwarderClosed = true;
|
|
103
|
+
const teardowns = [...bridges.values()];
|
|
104
|
+
bridges.clear();
|
|
105
|
+
for (const teardown of teardowns) teardown();
|
|
106
|
+
return new Promise<void>((resolve, reject) => {
|
|
107
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return new Promise<Forwarder>((resolve, reject) => {
|
|
112
|
+
const onError = (err: Error): void => {
|
|
113
|
+
server.removeListener('listening', onListening);
|
|
114
|
+
reject(err);
|
|
115
|
+
};
|
|
116
|
+
const onListening = (): void => {
|
|
117
|
+
server.removeListener('error', onError);
|
|
118
|
+
const addr = server.address();
|
|
119
|
+
// c8 ignore next 1 - defensive: a TCP listener always yields AddressInfo.
|
|
120
|
+
const port = typeof addr === 'object' && addr !== null ? addr.port : opts.listenPort;
|
|
121
|
+
resolve({
|
|
122
|
+
listenPort: port,
|
|
123
|
+
listenHost,
|
|
124
|
+
targetUrl: targetHref,
|
|
125
|
+
close,
|
|
126
|
+
testSockets: sockets,
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
server.once('listening', onListening);
|
|
130
|
+
server.once('error', onError);
|
|
131
|
+
server.listen(opts.listenPort, listenHost);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Wires one TCP socket to one fresh WebSocket. Returns a teardown closure
|
|
137
|
+
* that closes both ends idempotently; the caller registers it in `bridges`
|
|
138
|
+
* so {@link Forwarder.close} can sweep active connections.
|
|
139
|
+
*/
|
|
140
|
+
function bridgeConnection(
|
|
141
|
+
tcp: Socket,
|
|
142
|
+
targetHref: string,
|
|
143
|
+
bridges: Map<Socket, () => void>,
|
|
144
|
+
sockets: Set<Socket>,
|
|
145
|
+
): () => void {
|
|
146
|
+
const ws = new WebSocket(targetHref);
|
|
147
|
+
const outgoing = new LineScanner();
|
|
148
|
+
const pending: string[] = [];
|
|
149
|
+
let wsReady = false;
|
|
150
|
+
let bridgeClosed = false;
|
|
151
|
+
|
|
152
|
+
const sendLine = (line: string): void => {
|
|
153
|
+
if (wsReady) ws.send(line);
|
|
154
|
+
else pending.push(line);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
tcp.on('data', (chunk: Buffer) => {
|
|
158
|
+
for (const line of outgoing.push(chunk)) {
|
|
159
|
+
if (line.length > 0) sendLine(line);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
ws.on('open', () => {
|
|
164
|
+
wsReady = true;
|
|
165
|
+
for (const line of pending) ws.send(line);
|
|
166
|
+
pending.length = 0;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
ws.on('message', (data: RawData) => {
|
|
170
|
+
const text = toBuffer(data).toString('utf8');
|
|
171
|
+
for (const line of splitFrameLines(text)) {
|
|
172
|
+
if (line.length > 0) tcp.write(`${line}\r\n`);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const teardown = (): void => {
|
|
177
|
+
if (bridgeClosed) return;
|
|
178
|
+
bridgeClosed = true;
|
|
179
|
+
bridges.delete(tcp);
|
|
180
|
+
sockets.delete(tcp);
|
|
181
|
+
// ws.close() does not throw: on a CONNECTING socket it aborts the
|
|
182
|
+
// handshake and emits an 'error' event, which our handler suppresses
|
|
183
|
+
// via the `bridgeClosed` guard set above.
|
|
184
|
+
ws.close();
|
|
185
|
+
tcp.destroy();
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
tcp.on('close', teardown);
|
|
189
|
+
tcp.on('error', (err) => {
|
|
190
|
+
// Errors that arrive after we already tore this bridge down (e.g. an
|
|
191
|
+
// RST echoing our own `destroy()`) are expected — don't log them.
|
|
192
|
+
if (bridgeClosed) return;
|
|
193
|
+
logError('forwarder tcp socket error', err);
|
|
194
|
+
teardown();
|
|
195
|
+
});
|
|
196
|
+
ws.on('close', teardown);
|
|
197
|
+
ws.on('error', (err) => {
|
|
198
|
+
// Same suppression as above; also covers the benign "closed before the
|
|
199
|
+
// connection was established" event ws emits when we close() a socket
|
|
200
|
+
// that was still CONNECTING at shutdown time.
|
|
201
|
+
if (bridgeClosed) return;
|
|
202
|
+
logError('forwarder upstream ws error', err);
|
|
203
|
+
teardown();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return teardown;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Splits an inbound WebSocket text frame into IRC lines. Tolerates `\r\n`,
|
|
211
|
+
* bare `\n`, and missing trailing terminators. Mirrors the server-side
|
|
212
|
+
* framing in `ConnectionActor.splitFrameLines`. Exported so the framing
|
|
213
|
+
* edges (empty frame, joined frame) can be unit-tested in isolation.
|
|
214
|
+
*/
|
|
215
|
+
export function splitFrameLines(text: string): string[] {
|
|
216
|
+
if (text.length === 0) return [];
|
|
217
|
+
return text.split(/\r?\n/u);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Normalizes a `ws` `RawData` payload into a single UTF-8 `Buffer`. Exported
|
|
222
|
+
* so the `ArrayBuffer` / `Uint8Array` / `Buffer[]` variants — which ws only
|
|
223
|
+
* emits under non-default `binaryType` / fragmentation settings — can be
|
|
224
|
+
* unit-tested without contriving real socket conditions.
|
|
225
|
+
*/
|
|
226
|
+
export function toBuffer(data: RawData): Buffer {
|
|
227
|
+
if (Array.isArray(data)) return Buffer.concat(data);
|
|
228
|
+
if (Buffer.isBuffer(data)) return data;
|
|
229
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data);
|
|
230
|
+
return Buffer.from(data as Uint8Array);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function logError(msg: string, err: unknown): void {
|
|
234
|
+
const payload = {
|
|
235
|
+
ts: new Date().toISOString(),
|
|
236
|
+
level: 'error',
|
|
237
|
+
msg,
|
|
238
|
+
err:
|
|
239
|
+
err instanceof Error
|
|
240
|
+
? { name: err.name, message: err.message, stack: err.stack }
|
|
241
|
+
: String(err),
|
|
242
|
+
};
|
|
243
|
+
console.error(JSON.stringify(payload));
|
|
244
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte-accurate line scanner for the forwarder's TCP→WS direction.
|
|
3
|
+
*
|
|
4
|
+
* TCP is a byte stream, not a message stream: a single `data` chunk may
|
|
5
|
+
* contain several IRC lines, a partial line, or a line split across two
|
|
6
|
+
* chunks. The {@link LineScanner} accumulates raw `Buffer` chunks and emits
|
|
7
|
+
* each complete line as a decoded UTF-8 string the moment it sees a line
|
|
8
|
+
* terminator. Decoding happens only on whole lines, so a multi-byte UTF-8
|
|
9
|
+
* sequence split across two TCP chunks is never corrupted.
|
|
10
|
+
*
|
|
11
|
+
* Line endings are tolerated per PLAN §9 transport rules: both CRLF
|
|
12
|
+
* (`\r\n`) and bare LF (`\n`) terminate a line; a lone CR in the middle of
|
|
13
|
+
* text is treated as data. The trailing `\r` of a CRLF pair is stripped
|
|
14
|
+
* before the line is returned.
|
|
15
|
+
*/
|
|
16
|
+
export class LineScanner {
|
|
17
|
+
private carry: Buffer = Buffer.alloc(0);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Feeds a chunk of TCP bytes. Returns the complete lines found in this
|
|
21
|
+
* chunk (joined with any leftover carry from prior chunks), in order,
|
|
22
|
+
* with terminators removed. Any trailing unterminated bytes are retained
|
|
23
|
+
* internally for the next call.
|
|
24
|
+
*/
|
|
25
|
+
push(chunk: Buffer): string[] {
|
|
26
|
+
const buf = Buffer.concat([this.carry, chunk]);
|
|
27
|
+
const lines: string[] = [];
|
|
28
|
+
let start = 0;
|
|
29
|
+
let nl = buf.indexOf(0x0a, start);
|
|
30
|
+
while (nl !== -1) {
|
|
31
|
+
let end = nl;
|
|
32
|
+
if (end > start && buf[end - 1] === 0x0d) {
|
|
33
|
+
end--;
|
|
34
|
+
}
|
|
35
|
+
lines.push(buf.toString('utf8', start, end));
|
|
36
|
+
start = nl + 1;
|
|
37
|
+
nl = buf.indexOf(0x0a, start);
|
|
38
|
+
}
|
|
39
|
+
this.carry = buf.subarray(start);
|
|
40
|
+
return lines;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns any unterminated tail still buffered (lossless), then clears
|
|
45
|
+
* the carry. The forwarder does NOT call this on TCP close — an IRC line
|
|
46
|
+
* without a terminator is a protocol violation and is dropped — but the
|
|
47
|
+
* method is exposed for completeness and tested directly.
|
|
48
|
+
*/
|
|
49
|
+
flush(): string[] {
|
|
50
|
+
if (this.carry.length === 0) return [];
|
|
51
|
+
const rest = this.carry.toString('utf8');
|
|
52
|
+
this.carry = Buffer.alloc(0);
|
|
53
|
+
return [rest];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `tools/tcp-ws-forwarder` entry point.
|
|
4
|
+
*
|
|
5
|
+
* v1 of ServerlessIRCd is WebSocket-only, so a standard TCP IRC client
|
|
6
|
+
* (WeeChat, HexChat, irssi, …) cannot dial a deployed worker directly.
|
|
7
|
+
* This tool runs a local bridge: it listens on a TCP port and forwards
|
|
8
|
+
* every connection to a `ws://` / `wss://` endpoint, framing bytes into
|
|
9
|
+
* IRC lines in both directions.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* tcp-ws-forwarder --target wss://irc.example.com/ [options]
|
|
13
|
+
*
|
|
14
|
+
* --target <url> Upstream ws:// or wss:// URL (required).
|
|
15
|
+
* --listen-port <n> TCP port to listen on (default 6667).
|
|
16
|
+
* --listen-host <addr> Interface to bind (default 127.0.0.1).
|
|
17
|
+
* -h, --help Show this help and exit.
|
|
18
|
+
*
|
|
19
|
+
* Point your IRC client at 127.0.0.1:<listen-port> and it will appear as
|
|
20
|
+
* a direct connection to the WebSocket endpoint.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { startForwarder } from './forwarder.js';
|
|
24
|
+
|
|
25
|
+
interface CliArgs {
|
|
26
|
+
target: string | undefined;
|
|
27
|
+
listenPort: number;
|
|
28
|
+
listenHost: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv: string[]): CliArgs {
|
|
32
|
+
const out: CliArgs = { target: undefined, listenPort: 6667, listenHost: '127.0.0.1' };
|
|
33
|
+
for (let i = 0; i < argv.length; i++) {
|
|
34
|
+
const a = argv[i];
|
|
35
|
+
if (a === undefined) continue;
|
|
36
|
+
const next = argv[i + 1];
|
|
37
|
+
if (a === '--target' && next !== undefined) {
|
|
38
|
+
out.target = next;
|
|
39
|
+
i++;
|
|
40
|
+
} else if (a === '--listen-port' && next !== undefined) {
|
|
41
|
+
out.listenPort = Number.parseInt(next, 10);
|
|
42
|
+
i++;
|
|
43
|
+
} else if (a === '--listen-host' && next !== undefined) {
|
|
44
|
+
out.listenHost = next;
|
|
45
|
+
i++;
|
|
46
|
+
} else if (a === '--help' || a === '-h') {
|
|
47
|
+
process.stdout.write(
|
|
48
|
+
[
|
|
49
|
+
'Usage: tcp-ws-forwarder --target <ws|wss url> [options]',
|
|
50
|
+
'',
|
|
51
|
+
'Options:',
|
|
52
|
+
' --target <url> Upstream ws:// or wss:// URL (required).',
|
|
53
|
+
' --listen-port <n> TCP port to listen on (default 6667).',
|
|
54
|
+
' --listen-host <addr> Interface to bind (default 127.0.0.1).',
|
|
55
|
+
' -h, --help Show this help and exit.',
|
|
56
|
+
'',
|
|
57
|
+
].join('\n'),
|
|
58
|
+
);
|
|
59
|
+
process.exit(0);
|
|
60
|
+
} else if (next === undefined && !a.startsWith('-') && out.target === undefined) {
|
|
61
|
+
// Allow a bare positional URL: tcp-ws-forwarder wss://host/
|
|
62
|
+
out.target = a;
|
|
63
|
+
} else {
|
|
64
|
+
process.stderr.write(`unknown argument: ${a}\n`);
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function main(): Promise<void> {
|
|
72
|
+
const args = parseArgs(process.argv.slice(2));
|
|
73
|
+
if (args.target === undefined) {
|
|
74
|
+
process.stderr.write('error: --target <ws|wss url> is required\n');
|
|
75
|
+
process.exit(2);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const forwarder = await startForwarder({
|
|
79
|
+
listenPort: args.listenPort,
|
|
80
|
+
listenHost: args.listenHost,
|
|
81
|
+
targetUrl: args.target,
|
|
82
|
+
});
|
|
83
|
+
process.stdout.write(
|
|
84
|
+
`${JSON.stringify({
|
|
85
|
+
ts: new Date().toISOString(),
|
|
86
|
+
level: 'info',
|
|
87
|
+
msg: 'tcp-ws-forwarder listening',
|
|
88
|
+
listen: `${forwarder.listenHost}:${forwarder.listenPort}`,
|
|
89
|
+
target: forwarder.targetUrl,
|
|
90
|
+
})}\n`,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const shutdown = (signal: string): void => {
|
|
94
|
+
process.stdout.write(
|
|
95
|
+
`${JSON.stringify({ ts: new Date().toISOString(), level: 'info', msg: 'shutdown', signal })}\n`,
|
|
96
|
+
);
|
|
97
|
+
forwarder
|
|
98
|
+
.close()
|
|
99
|
+
.then(() => process.exit(0))
|
|
100
|
+
.catch((err) => {
|
|
101
|
+
process.stderr.write(`shutdown error: ${String(err)}\n`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
106
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
main().catch((err) => {
|
|
110
|
+
process.stderr.write(
|
|
111
|
+
`fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`,
|
|
112
|
+
);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
});
|