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,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
+ });