serverless-ircd 0.4.0 → 0.5.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/CHANGELOG.md +175 -0
- package/README.md +88 -19
- package/apps/aws-stack/README.md +4 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +32 -4
- package/apps/aws-stack/tests/stack.test.ts +47 -1
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-worker/package.json +1 -1
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +129 -21
- package/apps/local-cli/tests/e2e.test.ts +1 -1
- package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +63 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +11 -1
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
- package/packages/aws-adapter/tests/connect.test.ts +97 -1
- package/packages/aws-adapter/tests/handlers.test.ts +148 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +2 -0
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +5 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +182 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +1 -1
- package/packages/irc-core/src/commands/index.ts +8 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +6 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +4 -8
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/config.ts +7 -0
- package/packages/irc-core/src/effects.ts +27 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +179 -0
- package/packages/irc-core/src/protocol/numerics.ts +42 -11
- package/packages/irc-core/src/protocol/outbound.ts +20 -3
- package/packages/irc-core/src/types.ts +8 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +57 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +249 -16
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +1089 -1
- package/packages/irc-server/tests/dispatch.test.ts +37 -0
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -18,6 +18,9 @@ function fakeRuntime(calls: RecordedCall[]): IrcRuntime {
|
|
|
18
18
|
async broadcast(chan, lines, except) {
|
|
19
19
|
calls.push({ method: 'broadcast', args: [chan, lines, except] });
|
|
20
20
|
},
|
|
21
|
+
async broadcastWallops(lines, except) {
|
|
22
|
+
calls.push({ method: 'broadcastWallops', args: [lines, except] });
|
|
23
|
+
},
|
|
21
24
|
async disconnect(conn, reason) {
|
|
22
25
|
calls.push({ method: 'disconnect', args: [conn, reason] });
|
|
23
26
|
},
|
|
@@ -60,6 +63,10 @@ function fakeRuntime(calls: RecordedCall[]): IrcRuntime {
|
|
|
60
63
|
async listChannels() {
|
|
61
64
|
return [];
|
|
62
65
|
},
|
|
66
|
+
async reloadConfig() {
|
|
67
|
+
calls.push({ method: 'reloadConfig', args: [] });
|
|
68
|
+
throw new Error('reloadConfig not configured for this fake');
|
|
69
|
+
},
|
|
63
70
|
};
|
|
64
71
|
}
|
|
65
72
|
|
|
@@ -126,6 +133,24 @@ describe('dispatch', () => {
|
|
|
126
133
|
});
|
|
127
134
|
});
|
|
128
135
|
|
|
136
|
+
it('routes BroadcastWallops with lines and except', async () => {
|
|
137
|
+
const calls: RecordedCall[] = [];
|
|
138
|
+
await dispatch([Effect.broadcastWallops([L(':a WALLOPS :hi')], 'c1')], fakeRuntime(calls));
|
|
139
|
+
expect(calls[0]).toEqual({
|
|
140
|
+
method: 'broadcastWallops',
|
|
141
|
+
args: [[{ text: ':a WALLOPS :hi' }], 'c1'],
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('routes BroadcastWallops preserving undefined except', async () => {
|
|
146
|
+
const calls: RecordedCall[] = [];
|
|
147
|
+
await dispatch([Effect.broadcastWallops([L(':a WALLOPS :hi')])], fakeRuntime(calls));
|
|
148
|
+
expect(calls[0]).toEqual({
|
|
149
|
+
method: 'broadcastWallops',
|
|
150
|
+
args: [[{ text: ':a WALLOPS :hi' }], undefined],
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
129
154
|
it('calls reserveNick, changeNick, releaseNick in order', async () => {
|
|
130
155
|
const calls: RecordedCall[] = [];
|
|
131
156
|
await dispatch(
|
|
@@ -247,6 +272,7 @@ function capAwareRuntime(members: ReadonlyMap<ConnId, MemberInfo>): {
|
|
|
247
272
|
async broadcast(chan, lines, except) {
|
|
248
273
|
broadcasts.push({ chan, lines, except });
|
|
249
274
|
},
|
|
275
|
+
async broadcastWallops() {},
|
|
250
276
|
async disconnect() {},
|
|
251
277
|
async sendToNick() {},
|
|
252
278
|
async reserveNick() {
|
|
@@ -304,6 +330,9 @@ function capAwareRuntime(members: ReadonlyMap<ConnId, MemberInfo>): {
|
|
|
304
330
|
async listChannels() {
|
|
305
331
|
return [];
|
|
306
332
|
},
|
|
333
|
+
async reloadConfig() {
|
|
334
|
+
throw new Error('reloadConfig not configured for this fake');
|
|
335
|
+
},
|
|
307
336
|
};
|
|
308
337
|
return { runtime, sends, broadcasts };
|
|
309
338
|
}
|
|
@@ -382,6 +411,7 @@ describe('dispatch — Broadcast cap / capLines', () => {
|
|
|
382
411
|
sends.push({ to, lines });
|
|
383
412
|
},
|
|
384
413
|
async broadcast() {},
|
|
414
|
+
async broadcastWallops() {},
|
|
385
415
|
async disconnect() {},
|
|
386
416
|
async sendToNick() {},
|
|
387
417
|
async reserveNick() {
|
|
@@ -410,6 +440,9 @@ describe('dispatch — Broadcast cap / capLines', () => {
|
|
|
410
440
|
async listChannels() {
|
|
411
441
|
return [];
|
|
412
442
|
},
|
|
443
|
+
async reloadConfig() {
|
|
444
|
+
throw new Error('reloadConfig not configured for this fake');
|
|
445
|
+
},
|
|
413
446
|
};
|
|
414
447
|
|
|
415
448
|
await dispatch([Effect.broadcast('#nope', [L('x')], undefined, 'chghost')], runtime);
|
|
@@ -425,6 +458,7 @@ describe('dispatch — Broadcast cap / capLines', () => {
|
|
|
425
458
|
sends.push({ to, lines });
|
|
426
459
|
},
|
|
427
460
|
async broadcast() {},
|
|
461
|
+
async broadcastWallops() {},
|
|
428
462
|
async disconnect() {},
|
|
429
463
|
async sendToNick() {},
|
|
430
464
|
async reserveNick() {
|
|
@@ -483,6 +517,9 @@ describe('dispatch — Broadcast cap / capLines', () => {
|
|
|
483
517
|
async listChannels() {
|
|
484
518
|
return [];
|
|
485
519
|
},
|
|
520
|
+
async reloadConfig() {
|
|
521
|
+
throw new Error('reloadConfig not configured for this fake');
|
|
522
|
+
},
|
|
486
523
|
};
|
|
487
524
|
|
|
488
525
|
await dispatch(
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient declaration so `?raw` imports (Vite's built-in transform that
|
|
3
|
+
* inlines a file's text at bundle time) typecheck under the strict
|
|
4
|
+
* `tsconfig.test.json`. Used by source-level regression guards that
|
|
5
|
+
* assert the actor default-branch triage stays accurate.
|
|
6
|
+
*/
|
|
7
|
+
declare module '*?raw' {
|
|
8
|
+
const content: string;
|
|
9
|
+
// eslint-disable-next-line import/no-default-export
|
|
10
|
+
export default content;
|
|
11
|
+
}
|
|
@@ -34,6 +34,10 @@ describe('IrcRuntime contract', () => {
|
|
|
34
34
|
async listChannels() {
|
|
35
35
|
return [];
|
|
36
36
|
},
|
|
37
|
+
async reloadConfig() {
|
|
38
|
+
throw new Error('reloadConfig not configured for this fake');
|
|
39
|
+
},
|
|
40
|
+
async broadcastWallops() {},
|
|
37
41
|
};
|
|
38
42
|
expect(runtime).toBeDefined();
|
|
39
43
|
expect(typeof runtime.send).toBe('function');
|
|
@@ -48,5 +52,8 @@ describe('IrcRuntime contract', () => {
|
|
|
48
52
|
expect(typeof runtime.getConnectionInfo).toBe('function');
|
|
49
53
|
expect(typeof runtime.getConnection).toBe('function');
|
|
50
54
|
expect(typeof runtime.getChannelSnapshot).toBe('function');
|
|
55
|
+
expect(typeof runtime.listChannels).toBe('function');
|
|
56
|
+
expect(typeof runtime.reloadConfig).toBe('function');
|
|
57
|
+
expect(typeof runtime.broadcastWallops).toBe('function');
|
|
51
58
|
});
|
|
52
59
|
});
|
|
@@ -192,7 +192,15 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
|
|
|
192
192
|
await alice.waitForLine((l) => l.includes('JOIN #bar'));
|
|
193
193
|
alice.received.length = 0;
|
|
194
194
|
await alice.send('JOIN 0');
|
|
195
|
-
|
|
195
|
+
// The two PART echoes arrive as separate deliveries (one per
|
|
196
|
+
// channel, with an ApplyChannelDelta in between). On distributed
|
|
197
|
+
// runtimes (cf-adapter) they land in distinct WS messages with
|
|
198
|
+
// cross-DO latency between them, so waiting for the first generic
|
|
199
|
+
// PART and then snapshotting `received` races the second one.
|
|
200
|
+
// Wait for each channel's PART explicitly to make the observation
|
|
201
|
+
// deterministic across in-memory and DO-backed runtimes.
|
|
202
|
+
await alice.waitForLine((l) => l.includes('PART') && l.includes('#foo'));
|
|
203
|
+
await alice.waitForLine((l) => l.includes('PART') && l.includes('#bar'));
|
|
196
204
|
const parts = alice.received.filter((l) => l.includes('PART'));
|
|
197
205
|
expect(parts.length).toBe(2);
|
|
198
206
|
expect(parts.some((l) => l.includes('#foo'))).toBe(true);
|
package/pnpm-workspace.yaml
CHANGED
package/tools/package.json
CHANGED
|
@@ -33,7 +33,10 @@
|
|
|
33
33
|
import { execFileSync } from 'node:child_process';
|
|
34
34
|
import { readFileSync } from 'node:fs';
|
|
35
35
|
import { parseArgs } from 'node:util';
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
CREATE_ACCOUNTS_TABLE_SQL,
|
|
38
|
+
buildAccountPutStatement,
|
|
39
|
+
} from '@serverless-ircd/cf-adapter/accounts';
|
|
37
40
|
|
|
38
41
|
const { values } = parseArgs({
|
|
39
42
|
options: {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/tcp-ws-forwarder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Local TCP to ws/wss forwarder so standard IRC clients can reach the WebSocket-only ServerlessIRCd transport",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -11,16 +11,25 @@
|
|
|
11
11
|
* IRC client ──TCP──▶ forwarder ──WS──▶ ServerlessIRCd endpoint
|
|
12
12
|
* IRC client ◀─TCP── forwarder ◀─WS── ServerlessIRCd endpoint
|
|
13
13
|
*
|
|
14
|
+
* IRCv3 WebSocket subprotocol: every dial offers
|
|
15
|
+
* `['binary.ircv3.net', 'text.ircv3.net']` so a spec-capable upstream
|
|
16
|
+
* (e.g. the CF `ConnectionDO`) agrees on one-message-per-frame framing. A
|
|
17
|
+
* server that supports neither simply leaves the subprotocol unset and the
|
|
18
|
+
* bridge keeps working with its tolerant line-split framing.
|
|
19
|
+
*
|
|
14
20
|
* Framing:
|
|
15
21
|
* • TCP→WS: the TCP byte stream is reassembled into IRC lines with the
|
|
16
22
|
* byte-accurate {@link LineScanner} (tolerant of CRLF and bare LF per
|
|
17
23
|
* PLAN §9). Each complete line is sent as one bare WebSocket text frame
|
|
18
24
|
* — the canonical "one message per frame" convention the server's
|
|
19
|
-
* `ConnectionActor` expects
|
|
25
|
+
* `ConnectionActor` expects — with any trailing CR-LF stripped by
|
|
26
|
+
* {@link toWsMessagePayload} per the IRCv3 spec ("no trailing CR-LF on
|
|
27
|
+
* the wire").
|
|
20
28
|
* • WS→TCP: each inbound WS frame is split on line terminators and every
|
|
21
29
|
* non-empty line is written back to the TCP socket with a trailing
|
|
22
|
-
* CRLF, normalizing whatever framing the server
|
|
23
|
-
* `\r\n`-joined, or bare) into canonical IRC wire
|
|
30
|
+
* CRLF via {@link toTcpWire}, normalizing whatever framing the server
|
|
31
|
+
* used (single message, `\r\n`-joined, or bare) into canonical IRC wire
|
|
32
|
+
* format.
|
|
24
33
|
*
|
|
25
34
|
* Lifetime: closing either side tears down the whole bridge. The forwarder
|
|
26
35
|
* itself is transport-agnostic and has no `@serverless-ircd/*` dependency.
|
|
@@ -31,6 +40,16 @@ import { type RawData, WebSocket } from 'ws';
|
|
|
31
40
|
import { LineScanner } from './line-scanner.js';
|
|
32
41
|
import { ConsoleLogger, LogLevel, type Logger } from './logger.js';
|
|
33
42
|
|
|
43
|
+
/**
|
|
44
|
+
* IRCv3 WebSocket subprotocols the forwarder offers when dialing the upstream
|
|
45
|
+
* IRCd, in preference order. `binary.ircv3.net` is offered first so a
|
|
46
|
+
* spec-capable server agrees on binary framing; `text.ircv3.net` is the
|
|
47
|
+
* fallback. A server that supports neither (e.g. a legacy deployment) leaves
|
|
48
|
+
* the subprotocol unset and the bridge keeps working with its tolerant
|
|
49
|
+
* line-split framing.
|
|
50
|
+
*/
|
|
51
|
+
export const FORWARDER_WS_SUBPROTOCOLS = ['binary.ircv3.net', 'text.ircv3.net'] as const;
|
|
52
|
+
|
|
34
53
|
/** Options accepted by {@link startForwarder}. */
|
|
35
54
|
export interface StartForwarderOptions {
|
|
36
55
|
/** TCP port to listen on. Use 0 for an ephemeral port. */
|
|
@@ -157,7 +176,7 @@ function bridgeConnection(
|
|
|
157
176
|
logger: Logger,
|
|
158
177
|
connId: number,
|
|
159
178
|
): () => void {
|
|
160
|
-
const ws = new WebSocket(targetHref);
|
|
179
|
+
const ws = new WebSocket(targetHref, [...FORWARDER_WS_SUBPROTOCOLS]);
|
|
161
180
|
const outgoing = new LineScanner();
|
|
162
181
|
const pending: string[] = [];
|
|
163
182
|
let wsReady = false;
|
|
@@ -167,8 +186,8 @@ function bridgeConnection(
|
|
|
167
186
|
logger.info('bridge.accepted', { conn: connId, remote });
|
|
168
187
|
|
|
169
188
|
const sendLine = (line: string): void => {
|
|
170
|
-
if (wsReady) ws.send(line);
|
|
171
|
-
else pending.push(line);
|
|
189
|
+
if (wsReady) ws.send(toWsMessagePayload(line));
|
|
190
|
+
else pending.push(toWsMessagePayload(line));
|
|
172
191
|
};
|
|
173
192
|
|
|
174
193
|
tcp.on('data', (chunk: Buffer) => {
|
|
@@ -191,9 +210,7 @@ function bridgeConnection(
|
|
|
191
210
|
ws.on('message', (data: RawData) => {
|
|
192
211
|
const text = toBuffer(data).toString('utf8');
|
|
193
212
|
logger.debug('ws.frame', { conn: connId, frame: text });
|
|
194
|
-
for (const
|
|
195
|
-
if (line.length > 0) tcp.write(`${line}\r\n`);
|
|
196
|
-
}
|
|
213
|
+
for (const chunk of toTcpWire(text)) tcp.write(chunk);
|
|
197
214
|
});
|
|
198
215
|
|
|
199
216
|
const teardown = (reason: CloseReason): void => {
|
|
@@ -241,6 +258,37 @@ export function splitFrameLines(text: string): string[] {
|
|
|
241
258
|
return text.split(/\r?\n/u);
|
|
242
259
|
}
|
|
243
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Translates a TCP IRC line into the WebSocket message payload for the
|
|
263
|
+
* upstream send direction. Strips a single trailing CR-LF (or bare LF) so
|
|
264
|
+
* the WS message carries the bare IRC line, per the IRCv3 WebSocket spec
|
|
265
|
+
* ("no trailing CR-LF on the wire"). Idempotent on already-bare lines
|
|
266
|
+
* (the {@link LineScanner} output), so callers can apply it unconditionally
|
|
267
|
+
* without inspecting the line first. Exported so the CRLF-strip contract is
|
|
268
|
+
* unit-testable in isolation.
|
|
269
|
+
*/
|
|
270
|
+
export function toWsMessagePayload(line: string): string {
|
|
271
|
+
if (line.endsWith('\r\n')) return line.slice(0, -2);
|
|
272
|
+
if (line.endsWith('\n')) return line.slice(0, -1);
|
|
273
|
+
return line;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Translates an inbound WebSocket message payload into the CRLF-terminated
|
|
278
|
+
* TCP chunk(s) to write back to the IRC client. Each non-empty IRC line is
|
|
279
|
+
* returned as its own entry ending in CRLF, ready for `socket.write`. A
|
|
280
|
+
* single trailing CR-LF on the message (defensive against servers that emit
|
|
281
|
+
* one) is collapsed rather than doubled. Exported so the CRLF-re-append
|
|
282
|
+
* contract is unit-testable in isolation.
|
|
283
|
+
*/
|
|
284
|
+
export function toTcpWire(text: string): string[] {
|
|
285
|
+
const out: string[] = [];
|
|
286
|
+
for (const line of splitFrameLines(text)) {
|
|
287
|
+
if (line.length > 0) out.push(`${line}\r\n`);
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
244
292
|
/**
|
|
245
293
|
* Normalizes a `ws` `RawData` payload into a single UTF-8 `Buffer`. Exported
|
|
246
294
|
* so the `ArrayBuffer` / `Uint8Array` / `Buffer[]` variants — which ws only
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
|
-
import http from 'node:http';
|
|
2
|
+
import http, { type IncomingHttpHeaders } from 'node:http';
|
|
3
3
|
import { type Socket, createConnection, createServer as createNetServer } from 'node:net';
|
|
4
4
|
import type { AddressInfo } from 'node:net';
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
@@ -51,6 +51,12 @@ interface Target {
|
|
|
51
51
|
readonly frames: string[];
|
|
52
52
|
readonly connections: WebSocket[];
|
|
53
53
|
waitForConnection(): Promise<WebSocket>;
|
|
54
|
+
/**
|
|
55
|
+
* Resolves the next inbound upgrade request, in arrival order. Used to
|
|
56
|
+
* assert headers the forwarder sends during the WS handshake (e.g. the
|
|
57
|
+
* offered `Sec-WebSocket-Protocol`).
|
|
58
|
+
*/
|
|
59
|
+
waitForUpgradeRequest(): Promise<{ headers: IncomingHttpHeaders }>;
|
|
54
60
|
close(): Promise<void>;
|
|
55
61
|
}
|
|
56
62
|
|
|
@@ -69,8 +75,14 @@ function startDelayedTarget(delayMs: number): Promise<Target> {
|
|
|
69
75
|
// arrival order; queue waiters that resolve one-for-one as connections land.
|
|
70
76
|
const waiters: Array<(ws: WebSocket) => void> = [];
|
|
71
77
|
let waitForCount = 0;
|
|
78
|
+
// Likewise for upgrade requests, captured before the WS is handed off so
|
|
79
|
+
// headers can be asserted even when the caller never awaits a connection.
|
|
80
|
+
const upgradeReqWaiters: Array<(req: { headers: IncomingHttpHeaders }) => void> = [];
|
|
72
81
|
|
|
73
82
|
server.on('upgrade', (req, socket, head) => {
|
|
83
|
+
const captured = { headers: req.headers };
|
|
84
|
+
const waiter = upgradeReqWaiters.shift();
|
|
85
|
+
if (waiter) waiter(captured);
|
|
74
86
|
const finish = () => {
|
|
75
87
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
76
88
|
connections.push(ws);
|
|
@@ -97,6 +109,10 @@ function startDelayedTarget(delayMs: number): Promise<Target> {
|
|
|
97
109
|
if (connections.length > want) res(connections[want] as WebSocket);
|
|
98
110
|
else waiters.push(res);
|
|
99
111
|
}),
|
|
112
|
+
waitForUpgradeRequest: () =>
|
|
113
|
+
new Promise((res) => {
|
|
114
|
+
upgradeReqWaiters.push(res);
|
|
115
|
+
}),
|
|
100
116
|
close: () =>
|
|
101
117
|
new Promise<void>((r) => {
|
|
102
118
|
for (const ws of connections) ws.close();
|
|
@@ -260,6 +276,23 @@ describe('startForwarder — lifecycle', () => {
|
|
|
260
276
|
openServers.push(fwd);
|
|
261
277
|
expect(fwd.targetUrl).toBe('wss://example.invalid/');
|
|
262
278
|
});
|
|
279
|
+
|
|
280
|
+
it('offers binary.ircv3.net and text.ircv3.net in the upstream WS handshake', async () => {
|
|
281
|
+
const target = await startTarget();
|
|
282
|
+
openServers.push(target);
|
|
283
|
+
const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
|
|
284
|
+
openServers.push(fwd);
|
|
285
|
+
|
|
286
|
+
const client = new TcpClient(fwd.listenPort);
|
|
287
|
+
openClients.push(client);
|
|
288
|
+
await client.opened();
|
|
289
|
+
|
|
290
|
+
const req = await target.waitForUpgradeRequest();
|
|
291
|
+
const offered = req.headers['sec-websocket-protocol'];
|
|
292
|
+
expect(typeof offered).toBe('string');
|
|
293
|
+
const list = (offered as string).split(',').map((s) => s.trim());
|
|
294
|
+
expect(list).toEqual(['binary.ircv3.net', 'text.ircv3.net']);
|
|
295
|
+
});
|
|
263
296
|
});
|
|
264
297
|
|
|
265
298
|
describe('forwarder — TCP→WS framing', () => {
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import type { RawData } from 'ws';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
FORWARDER_WS_SUBPROTOCOLS,
|
|
5
|
+
splitFrameLines,
|
|
6
|
+
toBuffer,
|
|
7
|
+
toTcpWire,
|
|
8
|
+
toWsMessagePayload,
|
|
9
|
+
} from '../src/forwarder';
|
|
4
10
|
|
|
5
11
|
/**
|
|
6
12
|
* Pure-function unit tests for the WS-side framing helpers. These edges
|
|
@@ -49,3 +55,61 @@ describe('toBuffer', () => {
|
|
|
49
55
|
expect(toBuffer(src as RawData).toString('utf8')).toBe('ABC');
|
|
50
56
|
});
|
|
51
57
|
});
|
|
58
|
+
|
|
59
|
+
describe('FORWARDER_WS_SUBPROTOCOLS', () => {
|
|
60
|
+
it('offers binary.ircv3.net first (preferred)', () => {
|
|
61
|
+
expect(FORWARDER_WS_SUBPROTOCOLS[0]).toBe('binary.ircv3.net');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('offers text.ircv3.net as the fallback', () => {
|
|
65
|
+
expect(FORWARDER_WS_SUBPROTOCOLS).toContain('text.ircv3.net');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('offers exactly the two IRCv3 subprotocols', () => {
|
|
69
|
+
expect([...FORWARDER_WS_SUBPROTOCOLS].sort()).toEqual(['binary.ircv3.net', 'text.ircv3.net']);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('toWsMessagePayload — TCP line → WS message (CRLF stripped)', () => {
|
|
74
|
+
it('strips a trailing CRLF from a TCP IRC line', () => {
|
|
75
|
+
expect(toWsMessagePayload('PING :tok\r\n')).toBe('PING :tok');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('strips a trailing bare LF', () => {
|
|
79
|
+
expect(toWsMessagePayload('PING :bare\n')).toBe('PING :bare');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('leaves an already-bare line unchanged (idempotent on LineScanner output)', () => {
|
|
83
|
+
expect(toWsMessagePayload('NICK alice')).toBe('NICK alice');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('strips only one trailing terminator, keeping embedded content', () => {
|
|
87
|
+
expect(toWsMessagePayload('PRIVMSG #foo :a\r\nb\r\n')).toBe('PRIVMSG #foo :a\r\nb');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns the empty string unchanged', () => {
|
|
91
|
+
expect(toWsMessagePayload('')).toBe('');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('toTcpWire — WS message → TCP bytes (CRLF re-appended)', () => {
|
|
96
|
+
it('appends CRLF to a bare spec-mode WS message', () => {
|
|
97
|
+
expect(toTcpWire(':srv 001 nick :Welcome')).toEqual([':srv 001 nick :Welcome\r\n']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('collapses a single trailing CRLF instead of doubling it', () => {
|
|
101
|
+
expect(toTcpWire(':srv 001 nick :Hi\r\n')).toEqual([':srv 001 nick :Hi\r\n']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('emits no bytes for an empty WS message', () => {
|
|
105
|
+
expect(toTcpWire('')).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('emits no bytes for a message that is only a terminator', () => {
|
|
109
|
+
expect(toTcpWire('\r\n')).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('re-appends CRLF to each line of a legacy joined multi-message frame', () => {
|
|
113
|
+
expect(toTcpWire(':srv 001\r\n:srv 376')).toEqual([':srv 001\r\n', ':srv 376\r\n']);
|
|
114
|
+
});
|
|
115
|
+
});
|