serverless-ircd 0.3.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/.github/workflows/ci.yml +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +349 -18
- package/README.md +132 -30
- package/apps/aws-stack/README.md +6 -5
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +150 -10
- package/apps/aws-stack/tests/stack.test.ts +145 -4
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.test.toml +6 -0
- package/apps/cf-worker/wrangler.toml +28 -2
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +10 -0
- package/apps/local-cli/src/server.ts +149 -24
- package/apps/local-cli/tests/e2e.test.ts +1 -1
- package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
- package/package.json +14 -10
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +1 -1
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +75 -4
- package/packages/aws-adapter/src/config-loader.ts +32 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
- package/packages/aws-adapter/src/handlers/connect.ts +96 -9
- package/packages/aws-adapter/src/handlers/default.ts +98 -7
- package/packages/aws-adapter/src/handlers/index.ts +109 -4
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +8 -0
- package/packages/aws-adapter/src/serialize.ts +11 -1
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
- package/packages/aws-adapter/tests/account-store.test.ts +19 -20
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- 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/config-loader.test.ts +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +174 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +302 -53
- package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +5 -1
- package/packages/cf-adapter/src/cf-runtime.ts +68 -5
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +278 -85
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +54 -11
- package/packages/cf-adapter/src/index.ts +11 -8
- 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 +12 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
- 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/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- 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/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- 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 +15 -8
- 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 +15 -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 +6 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +24 -3
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +17 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/ison.ts +61 -0
- 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/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -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/userhost.ts +84 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +65 -0
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +33 -30
- package/packages/irc-core/src/index.ts +3 -0
- package/packages/irc-core/src/ports.ts +360 -12
- package/packages/irc-core/src/protocol/numerics.ts +48 -11
- package/packages/irc-core/src/protocol/outbound.ts +20 -3
- package/packages/irc-core/src/types.ts +46 -2
- 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/account-store.test.ts +45 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -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/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -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/tagmsg.test.ts +9 -35
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +95 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +102 -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/types.test.ts +35 -1
- 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-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +393 -16
- package/packages/irc-server/src/dispatch.ts +1 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +15 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +104 -0
- package/packages/irc-server/tests/actor.test.ts +1489 -4
- package/packages/irc-server/tests/dispatch.test.ts +37 -17
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +5 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +141 -3
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +10 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +9 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +107 -0
- 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
- package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
- package/docs/ADR-002-location-of-authority.md +0 -82
- package/docs/ADR-003-durable-object-sharding.md +0 -93
- package/docs/ADR-004-dynamodb-schema.md +0 -96
- package/docs/ADR-005-wss-only-transport-v1.md +0 -83
- package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
- package/docs/ADR-007-deterministic-ports.md +0 -82
- package/docs/ADR-008-monorepo-tooling.md +0 -60
- package/docs/AWS-Adapter-Architecture.md +0 -496
- package/docs/AWS-Deployment.md +0 -1186
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -11
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -443
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reusable IRC scenario runner — exported so adapter packages (cf-adapter,
|
|
3
3
|
* aws-stack) can register their own {@link IrcHarnessFactory} and re-run
|
|
4
|
-
* the exact same scenario suite under their native runtime.
|
|
4
|
+
* the exact same scenario suite under their native runtime AND transport.
|
|
5
5
|
*
|
|
6
6
|
* Each scenario is written once against the {@link IrcHarness} seam. The
|
|
7
7
|
* runner takes a list of factories and produces one `describe` block per
|
|
8
8
|
* factory, mirroring the parametrized matrix called for in PLAN §8
|
|
9
9
|
* ("Runtime contract: vitest parametrized over IrcRuntime. Same scenarios
|
|
10
|
-
* pass in-memory + CF + AWS.")
|
|
10
|
+
* pass in-memory + CF + AWS.") extended to cover the transport dimension
|
|
11
|
+
* ("Same scenarios pass in-memory+WS, in-memory+TCP, CF+WS, CF+TCP,
|
|
12
|
+
* AWS+WS, AWS+TCP.").
|
|
13
|
+
*
|
|
14
|
+
* The general scenario block runs against every factory regardless of
|
|
15
|
+
* transport. The TCP-framing block (chunk boundaries, slow-loris partial
|
|
16
|
+
* sends, mixed line endings) runs ONLY against factories declaring
|
|
17
|
+
* `transport: 'tcp'` — those cases are meaningless (and behave
|
|
18
|
+
* differently) under the WebSocket text-frame transport where every
|
|
19
|
+
* `feed()` is a complete unit.
|
|
11
20
|
*/
|
|
12
21
|
|
|
13
22
|
import { describe, expect, it } from 'vitest';
|
|
@@ -183,7 +192,15 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
|
|
|
183
192
|
await alice.waitForLine((l) => l.includes('JOIN #bar'));
|
|
184
193
|
alice.received.length = 0;
|
|
185
194
|
await alice.send('JOIN 0');
|
|
186
|
-
|
|
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'));
|
|
187
204
|
const parts = alice.received.filter((l) => l.includes('PART'));
|
|
188
205
|
expect(parts.length).toBe(2);
|
|
189
206
|
expect(parts.some((l) => l.includes('#foo'))).toBe(true);
|
|
@@ -491,5 +508,126 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
|
|
|
491
508
|
await harness.close();
|
|
492
509
|
});
|
|
493
510
|
});
|
|
511
|
+
|
|
512
|
+
// -------------------------------------------------------------------------
|
|
513
|
+
// TCP-framing scenarios.
|
|
514
|
+
//
|
|
515
|
+
// These cases exercise the stateful `\r\n` byte-stream transport
|
|
516
|
+
// (PLAN §9, ADR-009): chunk boundaries, partial-line buffering across
|
|
517
|
+
// chunks, slow-loris-style one-byte sends, bare `\n` tolerance, and
|
|
518
|
+
// multi-message chunks. They are meaningless under the WebSocket
|
|
519
|
+
// text-frame transport (where every feed() is a complete unit), so
|
|
520
|
+
// they only run against factories declaring `transport: 'tcp'`.
|
|
521
|
+
// -------------------------------------------------------------------------
|
|
522
|
+
if (factory.transport === 'tcp') {
|
|
523
|
+
describe(`TCP framing — ${factory.name}`, () => {
|
|
524
|
+
let harness: IrcHarness;
|
|
525
|
+
|
|
526
|
+
it('tcp 01: message split across two chunks produces one command', async () => {
|
|
527
|
+
harness = await factory.create();
|
|
528
|
+
const c = await harness.spawnClient({ nick: 'alice' });
|
|
529
|
+
await c.waitForNumeric('001');
|
|
530
|
+
c.received.length = 0;
|
|
531
|
+
// Feed the first half — incomplete, must buffer without dispatching.
|
|
532
|
+
await c.feed('PI');
|
|
533
|
+
// Nothing has terminated yet: no PONG, no 421.
|
|
534
|
+
expect(c.received.length).toBe(0);
|
|
535
|
+
await c.feed('NG :split\r\n');
|
|
536
|
+
await c.waitForLine((l) => l === 'PONG :split');
|
|
537
|
+
await harness.close();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('tcp 02: partial line buffered across many one-byte chunks (slow-loris style)', async () => {
|
|
541
|
+
harness = await factory.create();
|
|
542
|
+
const c = await harness.spawnClient({ nick: 'alice' });
|
|
543
|
+
await c.waitForNumeric('001');
|
|
544
|
+
c.received.length = 0;
|
|
545
|
+
const line = 'PING :slow';
|
|
546
|
+
for (const ch of line) {
|
|
547
|
+
await c.feed(ch);
|
|
548
|
+
// No terminator yet — nothing dispatches.
|
|
549
|
+
expect(c.received.length).toBe(0);
|
|
550
|
+
}
|
|
551
|
+
await c.feed('\r\n');
|
|
552
|
+
await c.waitForLine((l) => l === 'PONG :slow');
|
|
553
|
+
await harness.close();
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('tcp 03: bare LF line ending is tolerated (legacy/mixed clients)', async () => {
|
|
557
|
+
harness = await factory.create();
|
|
558
|
+
const c = await harness.spawnClient({ nick: 'alice' });
|
|
559
|
+
await c.waitForNumeric('001');
|
|
560
|
+
c.received.length = 0;
|
|
561
|
+
await c.feed('PING :bare\n');
|
|
562
|
+
await c.waitForLine((l) => l === 'PONG :bare');
|
|
563
|
+
await harness.close();
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('tcp 04: multiple CRLF-terminated messages in one chunk dispatch in order', async () => {
|
|
567
|
+
harness = await factory.create();
|
|
568
|
+
const c = await harness.spawnClient({ nick: 'alice' });
|
|
569
|
+
await c.waitForNumeric('001');
|
|
570
|
+
c.received.length = 0;
|
|
571
|
+
await c.feed('PING :one\r\nPING :two\r\nPING :three\r\n');
|
|
572
|
+
await c.waitForLine((l) => l === 'PONG :three');
|
|
573
|
+
const pongs = c.received.filter((l) => l.startsWith('PONG :'));
|
|
574
|
+
expect(pongs).toEqual(['PONG :one', 'PONG :two', 'PONG :three']);
|
|
575
|
+
await harness.close();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('tcp 05: mixed CRLF and LF terminators in one chunk both frame', async () => {
|
|
579
|
+
harness = await factory.create();
|
|
580
|
+
const c = await harness.spawnClient({ nick: 'alice' });
|
|
581
|
+
await c.waitForNumeric('001');
|
|
582
|
+
c.received.length = 0;
|
|
583
|
+
await c.feed('PING :crlf\r\nPING :lf\n');
|
|
584
|
+
await c.waitForLine((l) => l === 'PONG :lf');
|
|
585
|
+
const toks = c.received
|
|
586
|
+
.filter((l) => l.startsWith('PONG :'))
|
|
587
|
+
.map((l) => l.slice('PONG :'.length));
|
|
588
|
+
expect(toks).toEqual(['crlf', 'lf']);
|
|
589
|
+
await harness.close();
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('tcp 06: CRLF terminator split across chunks (CR in one, LF in next)', async () => {
|
|
593
|
+
harness = await factory.create();
|
|
594
|
+
const c = await harness.spawnClient({ nick: 'alice' });
|
|
595
|
+
await c.waitForNumeric('001');
|
|
596
|
+
c.received.length = 0;
|
|
597
|
+
await c.feed('PING :cr\r');
|
|
598
|
+
expect(c.received.length).toBe(0);
|
|
599
|
+
await c.feed('\n');
|
|
600
|
+
await c.waitForLine((l) => l === 'PONG :cr');
|
|
601
|
+
await harness.close();
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('tcp 07: an empty chunk is a no-op (no dispatch, no crash)', async () => {
|
|
605
|
+
harness = await factory.create();
|
|
606
|
+
const c = await harness.spawnClient({ nick: 'alice' });
|
|
607
|
+
await c.waitForNumeric('001');
|
|
608
|
+
c.received.length = 0;
|
|
609
|
+
await c.feed('');
|
|
610
|
+
expect(c.received.length).toBe(0);
|
|
611
|
+
// The connection is still usable after the empty chunk.
|
|
612
|
+
await c.feed('PING :after-empty\r\n');
|
|
613
|
+
await c.waitForLine((l) => l === 'PONG :after-empty');
|
|
614
|
+
await harness.close();
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('tcp 08: a complete line followed by a partial line buffers the tail', async () => {
|
|
618
|
+
harness = await factory.create();
|
|
619
|
+
const c = await harness.spawnClient({ nick: 'alice' });
|
|
620
|
+
await c.waitForNumeric('001');
|
|
621
|
+
c.received.length = 0;
|
|
622
|
+
await c.feed('PING :first\r\nPI');
|
|
623
|
+
await c.waitForLine((l) => l === 'PONG :first');
|
|
624
|
+
// The trailing partial 'PI' must not have dispatched yet.
|
|
625
|
+
expect(c.received.some((l) => l === 'PONG :')).toBe(false);
|
|
626
|
+
await c.feed('NG :second\r\n');
|
|
627
|
+
await c.waitForLine((l) => l === 'PONG :second');
|
|
628
|
+
await harness.close();
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
}
|
|
494
632
|
}
|
|
495
633
|
}
|
|
@@ -12,7 +12,8 @@ import { InMemoryHarness, inMemoryHarnessFactory } from '../src/index.js';
|
|
|
12
12
|
|
|
13
13
|
describe('inMemoryHarnessFactory', () => {
|
|
14
14
|
it('exposes a stable name for the describe.each matrix', () => {
|
|
15
|
-
expect(inMemoryHarnessFactory.name).toBe('in-memory');
|
|
15
|
+
expect(inMemoryHarnessFactory.name).toBe('in-memory+ws');
|
|
16
|
+
expect(inMemoryHarnessFactory.transport).toBe('ws');
|
|
16
17
|
});
|
|
17
18
|
|
|
18
19
|
it('create() returns a fresh, independent InMemoryHarness each call', async () => {
|
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Parametrized IRC scenarios
|
|
2
|
+
* Parametrized IRC scenarios.
|
|
3
3
|
*
|
|
4
4
|
* The scenario bodies live in `src/scenarios.ts` (now re-exported as
|
|
5
5
|
* `runIrcScenarios`) so adapter packages can register their own
|
|
6
6
|
* {@link IrcHarnessFactory} against the exact same suite under their
|
|
7
7
|
* native vitest configuration (CF runs in `workerd`, AWS runs in node
|
|
8
|
-
* with dynamodb-local, etc.). This file is the in-memory entry point
|
|
9
|
-
* it registers only {@link inMemoryHarnessFactory}.
|
|
8
|
+
* with dynamodb-local, etc.). This file is the in-memory entry point.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* The transport dimension extends the matrix: every scenario runs
|
|
11
|
+
* against (runtime × transport). The in-memory runtime
|
|
12
|
+
* registers both transports — `inMemoryWsHarnessFactory` (WebSocket
|
|
13
|
+
* text-frame framing) and `inMemoryTcpHarnessFactory` (raw TCP+TLS
|
|
14
|
+
* byte-stream framing) — proving the same scenario suite is green across
|
|
15
|
+
* both transports on the reference runtime. CF and AWS append their own
|
|
16
|
+
* factories (per transport) to their package suites.
|
|
17
|
+
*
|
|
18
|
+
* Adding a runtime OR a transport to the in-memory suite = appending one
|
|
19
|
+
* entry to the `FACTORIES` array (acceptance criterion: "adding a new
|
|
20
|
+
* transport requires only registering a factory").
|
|
14
21
|
*/
|
|
15
22
|
|
|
16
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
inMemoryTcpHarnessFactory,
|
|
25
|
+
inMemoryWsHarnessFactory,
|
|
26
|
+
runIrcScenarios,
|
|
27
|
+
} from '../src/index.js';
|
|
17
28
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The (runtime × transport) matrix. Adding a runtime or transport =
|
|
31
|
+
* appending one entry here.
|
|
32
|
+
*/
|
|
33
|
+
runIrcScenarios([inMemoryWsHarnessFactory, inMemoryTcpHarnessFactory]);
|
package/pnpm-workspace.yaml
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
linkedWorkspacePackages: true
|
|
2
1
|
packages:
|
|
3
2
|
- "packages/*"
|
|
4
3
|
- "apps/*"
|
|
4
|
+
- "tools"
|
|
5
5
|
- "tools/*"
|
|
6
|
+
linkWorkspacePackages: true
|
|
7
|
+
allowBuilds:
|
|
8
|
+
'@biomejs/biome': true
|
|
9
|
+
cpu-features: true
|
|
10
|
+
esbuild: true
|
|
11
|
+
protobufjs: true
|
|
12
|
+
sharp: true
|
|
13
|
+
ssh2: true
|
|
14
|
+
workerd: true
|
|
@@ -17,12 +17,9 @@
|
|
|
17
17
|
* Re-running with the same username overwrites the prior row.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { parseArgs } from 'node:util';
|
|
21
20
|
import { readFileSync } from 'node:fs';
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
putAccountCredential,
|
|
25
|
-
} from '@serverless-ircd/aws-adapter';
|
|
21
|
+
import { parseArgs } from 'node:util';
|
|
22
|
+
import { createDynamoDocumentClient, putAccountCredential } from '@serverless-ircd/aws-adapter';
|
|
26
23
|
|
|
27
24
|
const { values } = parseArgs({
|
|
28
25
|
options: {
|
|
@@ -35,7 +32,9 @@ const { values } = parseArgs({
|
|
|
35
32
|
});
|
|
36
33
|
|
|
37
34
|
if (values.table === undefined) {
|
|
38
|
-
console.error(
|
|
35
|
+
console.error(
|
|
36
|
+
'Usage: seed-aws-accounts --table <TableName> [--endpoint <url>] [--accounts user:pass ...] [--file <path>]',
|
|
37
|
+
);
|
|
39
38
|
process.exit(1);
|
|
40
39
|
}
|
|
41
40
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Seed SASL PLAIN accounts into a Cloudflare D1 `accounts` table.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node --import tsx tools/seed-cf-accounts.ts \
|
|
7
|
+
* --database serverless-ircd-accounts-staging \
|
|
8
|
+
* --env staging \
|
|
9
|
+
* --remote \
|
|
10
|
+
* --accounts alice:s3cret bob:password2
|
|
11
|
+
*
|
|
12
|
+
* Or read from a file (newline-delimited `username:password`):
|
|
13
|
+
* node --import tsx tools/seed-cf-accounts.ts \
|
|
14
|
+
* --database serverless-ircd-accounts-staging \
|
|
15
|
+
* --env staging \
|
|
16
|
+
* --remote \
|
|
17
|
+
* --file accounts.txt
|
|
18
|
+
*
|
|
19
|
+
* The script hashes each password with scrypt (never writes plaintext) and
|
|
20
|
+
* executes the resulting `INSERT OR REPLACE` via `wrangler d1 execute`.
|
|
21
|
+
* Re-running with the same username overwrites the prior row. The `accounts`
|
|
22
|
+
* table is created (IF NOT EXISTS) on the first run.
|
|
23
|
+
*
|
|
24
|
+
* Flags:
|
|
25
|
+
* --database D1 database name (as declared in wrangler.toml). Required.
|
|
26
|
+
* --env Wrangler environment (e.g. `staging`). Optional.
|
|
27
|
+
* --remote Target the remote D1 (default: local preview).
|
|
28
|
+
* --local Target local D1 (explicit; default).
|
|
29
|
+
* --accounts One or more `username:password` pairs.
|
|
30
|
+
* --file Path to a newline-delimited `username:password` file.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { execFileSync } from 'node:child_process';
|
|
34
|
+
import { readFileSync } from 'node:fs';
|
|
35
|
+
import { parseArgs } from 'node:util';
|
|
36
|
+
import {
|
|
37
|
+
CREATE_ACCOUNTS_TABLE_SQL,
|
|
38
|
+
buildAccountPutStatement,
|
|
39
|
+
} from '@serverless-ircd/cf-adapter/accounts';
|
|
40
|
+
|
|
41
|
+
const { values } = parseArgs({
|
|
42
|
+
options: {
|
|
43
|
+
database: { type: 'string' },
|
|
44
|
+
env: { type: 'string', default: undefined },
|
|
45
|
+
remote: { type: 'boolean', default: false },
|
|
46
|
+
local: { type: 'boolean', default: false },
|
|
47
|
+
accounts: { type: 'string', multiple: true, default: [] },
|
|
48
|
+
file: { type: 'string', default: undefined },
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (values.database === undefined) {
|
|
53
|
+
console.error(
|
|
54
|
+
'Usage: seed-cf-accounts --database <d1-name> [--env staging] [--remote] [--accounts user:pass ...] [--file <path>]',
|
|
55
|
+
);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const pairs: Array<{ username: string; password: string }> = [];
|
|
60
|
+
|
|
61
|
+
for (const pair of values.accounts) {
|
|
62
|
+
const sep = pair.indexOf(':');
|
|
63
|
+
if (sep <= 0) {
|
|
64
|
+
console.error(`Skipping malformed entry (no colon): ${pair}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
pairs.push({ username: pair.slice(0, sep), password: pair.slice(sep + 1) });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (values.file !== undefined) {
|
|
71
|
+
const content = readFileSync(values.file, 'utf-8');
|
|
72
|
+
for (const line of content.split('\n')) {
|
|
73
|
+
const trimmed = line.trim();
|
|
74
|
+
if (trimmed.length === 0) continue;
|
|
75
|
+
const sep = trimmed.indexOf(':');
|
|
76
|
+
if (sep <= 0) continue;
|
|
77
|
+
pairs.push({ username: trimmed.slice(0, sep), password: trimmed.slice(sep + 1) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (pairs.length === 0) {
|
|
82
|
+
console.error('No accounts to seed. Use --accounts or --file.');
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Runs `wrangler d1 execute` with the supplied SQL command. */
|
|
87
|
+
function runD1Execute(sql: string): void {
|
|
88
|
+
const args = ['d1', 'execute', values.database as string, '--command', sql];
|
|
89
|
+
if (values.env !== undefined) {
|
|
90
|
+
args.push('--env', values.env);
|
|
91
|
+
}
|
|
92
|
+
if (values.remote) {
|
|
93
|
+
args.push('--remote');
|
|
94
|
+
}
|
|
95
|
+
execFileSync('npx', ['wrangler', ...args], { stdio: 'inherit' });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Ensure the table exists (idempotent).
|
|
99
|
+
runD1Execute(CREATE_ACCOUNTS_TABLE_SQL);
|
|
100
|
+
|
|
101
|
+
for (const { username, password } of pairs) {
|
|
102
|
+
const { entry, sql } = buildAccountPutStatement(username, password);
|
|
103
|
+
runD1Execute(sql);
|
|
104
|
+
console.log(`Seeded ${username} (algorithm=${entry.algorithm})`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log(`Done: ${pairs.length} account(s) written to D1 database ${values.database}.`);
|
|
@@ -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
|
+
});
|