serverless-ircd 0.4.0 → 0.6.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 +34 -2
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/CHANGELOG.md +435 -0
- package/README.md +206 -27
- package/apps/aws-stack/README.md +37 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +106 -13
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +49 -3
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.toml +5 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +138 -28
- package/apps/local-cli/tests/e2e.test.ts +113 -29
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
- package/biome.json +1 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +110 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +15 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -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 +66 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +40 -2
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +100 -4
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +238 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +6 -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 +185 -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/serialize.ts +25 -4
- 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 +307 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- 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-pure.test.ts +91 -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 +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- 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/vitest.config.ts +1 -1
- 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 +38 -4
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +42 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +59 -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 +12 -9
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- 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/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- 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/tagmsg.ts +71 -1
- 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/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +72 -10
- package/packages/irc-core/src/effects.ts +41 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +568 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +57 -11
- package/packages/irc-core/src/protocol/outbound.ts +36 -4
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +120 -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/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -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 +129 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- 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 +357 -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/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- 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/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -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/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +27 -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 +699 -19
- package/packages/irc-server/src/dispatch.ts +109 -16
- 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 +2523 -42
- package/packages/irc-server/tests/dispatch.test.ts +300 -2
- 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/index.ts +6 -0
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- 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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
type ClientResult,
|
|
4
|
+
PHASE_NAMES,
|
|
5
|
+
type PhaseName,
|
|
6
|
+
percentile,
|
|
7
|
+
summarizePhase,
|
|
8
|
+
summarizeResults,
|
|
9
|
+
} from '../src/metrics';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The metrics module is the statistical core of the load tester. It is the
|
|
13
|
+
* piece that turns raw per-client latency samples into the p50/p95/p99
|
|
14
|
+
* summary the report renders, so its math must be pinned independently of
|
|
15
|
+
* any real socket behaviour.
|
|
16
|
+
*/
|
|
17
|
+
describe('percentile (nearest-rank)', () => {
|
|
18
|
+
it('returns the upper-median for p50 of an even-sized sample set', () => {
|
|
19
|
+
// nearest-rank: index = ceil(p/100 * n) - 1
|
|
20
|
+
// n=4, p=50 -> ceil(2)-1 = 1 -> sorted[1] = 20
|
|
21
|
+
expect(percentile([10, 20, 30, 40], 50)).toBe(20);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns the exact middle for p50 of an odd-sized sample set', () => {
|
|
25
|
+
expect(percentile([10, 20, 30], 50)).toBe(20);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns the max for p100', () => {
|
|
29
|
+
expect(percentile([5, 9, 3, 12, 7], 100)).toBe(12);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns the min for p0', () => {
|
|
33
|
+
expect(percentile([5, 9, 3, 12, 7], 0)).toBe(3);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('clamps the rank index to the last element for p99 on a small set', () => {
|
|
37
|
+
// n=3, p=99 -> ceil(2.97)-1 = 3-1 = 2 -> sorted[2] = 90
|
|
38
|
+
expect(percentile([10, 50, 90], 99)).toBe(90);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('throws for a percentile below 0 (programming error)', () => {
|
|
42
|
+
expect(() => percentile([10, 20, 30], -5)).toThrow(/percentile/i);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('works on an unsorted input (sorts internally)', () => {
|
|
46
|
+
expect(percentile([100, 1, 50, 25, 75], 50)).toBe(50);
|
|
47
|
+
expect(percentile([100, 1, 50, 25, 75], 100)).toBe(100);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns 0 for an empty sample set', () => {
|
|
51
|
+
expect(percentile([], 50)).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns the sole sample for a single-element set at any percentile', () => {
|
|
55
|
+
expect(percentile([42], 50)).toBe(42);
|
|
56
|
+
expect(percentile([42], 99)).toBe(42);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('throws for a percentile above 100 (programming error)', () => {
|
|
60
|
+
expect(() => percentile([1, 2, 3], 101)).toThrow(/percentile/i);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('summarizePhase', () => {
|
|
65
|
+
it('reports count and the percentile triple for a populated phase', () => {
|
|
66
|
+
const s = summarizePhase([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]);
|
|
67
|
+
expect(s.count).toBe(10);
|
|
68
|
+
expect(s.min).toBe(10);
|
|
69
|
+
expect(s.max).toBe(100);
|
|
70
|
+
expect(s.p50).toBe(50);
|
|
71
|
+
expect(s.p95).toBe(100);
|
|
72
|
+
expect(s.p99).toBe(100);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns a zeroed summary for an empty sample set', () => {
|
|
76
|
+
const s = summarizePhase([]);
|
|
77
|
+
expect(s).toEqual({ count: 0, min: 0, max: 0, p50: 0, p95: 0, p99: 0 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('handles a single sample', () => {
|
|
81
|
+
expect(summarizePhase([77])).toEqual({
|
|
82
|
+
count: 1,
|
|
83
|
+
min: 77,
|
|
84
|
+
max: 77,
|
|
85
|
+
p50: 77,
|
|
86
|
+
p95: 77,
|
|
87
|
+
p99: 77,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('summarizeResults', () => {
|
|
93
|
+
const emptySamples = (): Record<PhaseName, number[]> => ({
|
|
94
|
+
connect: [],
|
|
95
|
+
register: [],
|
|
96
|
+
join: [],
|
|
97
|
+
privmsg: [],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('computes drop rate as failed / total', () => {
|
|
101
|
+
const results: ClientResult[] = [
|
|
102
|
+
{ id: 0, ok: true, samples: emptySamples() },
|
|
103
|
+
{ id: 1, ok: true, samples: emptySamples() },
|
|
104
|
+
{
|
|
105
|
+
id: 2,
|
|
106
|
+
ok: false,
|
|
107
|
+
error: 'connect timeout',
|
|
108
|
+
failedPhase: 'connect',
|
|
109
|
+
samples: emptySamples(),
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
const summary = summarizeResults(results);
|
|
113
|
+
expect(summary.totalClients).toBe(3);
|
|
114
|
+
expect(summary.succeeded).toBe(2);
|
|
115
|
+
expect(summary.failed).toBe(1);
|
|
116
|
+
expect(summary.dropRate).toBeCloseTo(1 / 3, 5);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('flatten-aggregates samples per phase across all clients (including privmsg repeats)', () => {
|
|
120
|
+
const results: ClientResult[] = [
|
|
121
|
+
{
|
|
122
|
+
id: 0,
|
|
123
|
+
ok: true,
|
|
124
|
+
samples: {
|
|
125
|
+
connect: [12],
|
|
126
|
+
register: [40],
|
|
127
|
+
join: [8],
|
|
128
|
+
privmsg: [3, 4],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 1,
|
|
133
|
+
ok: true,
|
|
134
|
+
samples: {
|
|
135
|
+
connect: [20],
|
|
136
|
+
register: [60],
|
|
137
|
+
join: [10],
|
|
138
|
+
privmsg: [5],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
const summary = summarizeResults(results);
|
|
143
|
+
expect(summary.phases.connect).toEqual(summarizePhase([12, 20]));
|
|
144
|
+
expect(summary.phases.privmsg).toEqual(summarizePhase([3, 4, 5]));
|
|
145
|
+
expect(summary.phases.privmsg.count).toBe(3);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('only counts samples up to the failed phase (drop attribution)', () => {
|
|
149
|
+
// A client that failed at `register` contributes a connect sample but
|
|
150
|
+
// no register/join/privmsg sample.
|
|
151
|
+
const results: ClientResult[] = [
|
|
152
|
+
{
|
|
153
|
+
id: 0,
|
|
154
|
+
ok: false,
|
|
155
|
+
error: 'register timeout',
|
|
156
|
+
failedPhase: 'register',
|
|
157
|
+
samples: { connect: [12], register: [], join: [], privmsg: [] },
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
const summary = summarizeResults(results);
|
|
161
|
+
expect(summary.phases.connect.count).toBe(1);
|
|
162
|
+
expect(summary.phases.register.count).toBe(0);
|
|
163
|
+
expect(summary.phases.join.count).toBe(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('returns a zeroed summary for no clients', () => {
|
|
167
|
+
const summary = summarizeResults([]);
|
|
168
|
+
expect(summary.totalClients).toBe(0);
|
|
169
|
+
expect(summary.dropRate).toBe(0);
|
|
170
|
+
for (const phase of PHASE_NAMES) {
|
|
171
|
+
expect(summary.phases[phase].count).toBe(0);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { type LoadTestConfig, resolveConfig } from '../src/config';
|
|
3
|
+
import {
|
|
4
|
+
type ClientResult,
|
|
5
|
+
type LoadTestSummary,
|
|
6
|
+
PHASE_NAMES,
|
|
7
|
+
summarizeResults,
|
|
8
|
+
} from '../src/metrics';
|
|
9
|
+
import { type LoadTestRun, formatMs, formatPercent, generateReport } from '../src/report';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The report module renders the run's aggregate into a markdown string.
|
|
13
|
+
* Tests pin the structural pieces a human scans for (platform, target,
|
|
14
|
+
* drop rate, the per-phase latency table) without coupling to exact
|
|
15
|
+
* whitespace — the layout can evolve without churn.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function baseConfig(overrides: Partial<LoadTestConfig> = {}): LoadTestConfig {
|
|
19
|
+
return resolveConfig({
|
|
20
|
+
target: 'wss://irc.staging.example.com/',
|
|
21
|
+
platform: 'cf-staging',
|
|
22
|
+
connections: 5,
|
|
23
|
+
channel: '#loadtest',
|
|
24
|
+
...overrides,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function emptySummary(): LoadTestSummary {
|
|
29
|
+
return summarizeResults([]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeRun(
|
|
33
|
+
summary: LoadTestSummary = emptySummary(),
|
|
34
|
+
config: LoadTestConfig = baseConfig(),
|
|
35
|
+
durationMs = 1000,
|
|
36
|
+
): LoadTestRun {
|
|
37
|
+
const startedAt = new Date('2026-08-03T14:00:00.000Z');
|
|
38
|
+
return {
|
|
39
|
+
config,
|
|
40
|
+
summary,
|
|
41
|
+
startedAt,
|
|
42
|
+
finishedAt: new Date(startedAt.getTime() + durationMs),
|
|
43
|
+
durationMs,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function okClient(
|
|
48
|
+
id: number,
|
|
49
|
+
latencies: Partial<Record<(typeof PHASE_NAMES)[number], number[]>>,
|
|
50
|
+
): ClientResult {
|
|
51
|
+
const samples: ClientResult['samples'] = { connect: [], register: [], join: [], privmsg: [] };
|
|
52
|
+
for (const [phase, vals] of Object.entries(latencies) as [keyof typeof samples, number[]][]) {
|
|
53
|
+
samples[phase] = vals;
|
|
54
|
+
}
|
|
55
|
+
return { id, ok: true, samples };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('formatMs', () => {
|
|
59
|
+
it('formats sub-second durations in ms', () => {
|
|
60
|
+
expect(formatMs(12)).toBe('12.00ms');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('formats seconds with two decimals', () => {
|
|
64
|
+
expect(formatMs(42_300)).toBe('42.30s');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('formats minutes with seconds', () => {
|
|
68
|
+
expect(formatMs(90_000)).toBe('1m30.00s');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('formats zero as 0.00ms', () => {
|
|
72
|
+
expect(formatMs(0)).toBe('0.00ms');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('formatPercent', () => {
|
|
77
|
+
it('formats a fraction as a percentage with two decimals', () => {
|
|
78
|
+
expect(formatPercent(0.0002)).toBe('0.02%');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('formats 1 as 100.00%', () => {
|
|
82
|
+
expect(formatPercent(1)).toBe('100.00%');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('formats 0 as 0.00%', () => {
|
|
86
|
+
expect(formatPercent(0)).toBe('0.00%');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('generateReport', () => {
|
|
91
|
+
it('includes the platform label and target URL in the header', () => {
|
|
92
|
+
const md = generateReport(makeRun());
|
|
93
|
+
expect(md).toContain('cf-staging');
|
|
94
|
+
expect(md).toContain('wss://irc.staging.example.com/');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('includes the ISO date and the human-readable duration', () => {
|
|
98
|
+
const md = generateReport(makeRun(emptySummary(), baseConfig(), 42_300));
|
|
99
|
+
expect(md).toContain('2026-08-03T14:00:00.000Z');
|
|
100
|
+
expect(md).toContain('42.30s');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('reports the configured connection count, channel, and messages/client', () => {
|
|
104
|
+
const cfg = baseConfig({ connections: 10_000, channel: '#perf', messagesPerClient: 3 });
|
|
105
|
+
const md = generateReport(makeRun(emptySummary(), cfg));
|
|
106
|
+
expect(md).toContain('10000');
|
|
107
|
+
expect(md).toContain('#perf');
|
|
108
|
+
expect(md).toContain('3');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('renders the drop rate from the summary', () => {
|
|
112
|
+
const results: ClientResult[] = [
|
|
113
|
+
okClient(0, { connect: [10] }),
|
|
114
|
+
okClient(1, { connect: [12] }),
|
|
115
|
+
{
|
|
116
|
+
id: 2,
|
|
117
|
+
ok: false,
|
|
118
|
+
error: 'timeout',
|
|
119
|
+
failedPhase: 'connect',
|
|
120
|
+
samples: { connect: [], register: [], join: [], privmsg: [] },
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
const run = makeRun(summarizeResults(results));
|
|
124
|
+
const md = generateReport(run);
|
|
125
|
+
expect(md).toContain('Attempted');
|
|
126
|
+
expect(md).toContain('3');
|
|
127
|
+
expect(md).toContain('2'); // succeeded
|
|
128
|
+
expect(md).toContain('1'); // failed
|
|
129
|
+
expect(md).toContain('33.33%'); // drop rate
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('renders a latency table row per phase with p50/p95/p99 columns', () => {
|
|
133
|
+
const results: ClientResult[] = [
|
|
134
|
+
okClient(0, { connect: [12, 20], register: [40], join: [8], privmsg: [3, 4] }),
|
|
135
|
+
okClient(1, { connect: [30], register: [60], join: [10], privmsg: [5] }),
|
|
136
|
+
];
|
|
137
|
+
const md = generateReport(makeRun(summarizeResults(results)));
|
|
138
|
+
// Column headers present.
|
|
139
|
+
expect(md).toContain('p50');
|
|
140
|
+
expect(md).toContain('p95');
|
|
141
|
+
expect(md).toContain('p99');
|
|
142
|
+
// Each phase name appears as a row.
|
|
143
|
+
for (const phase of PHASE_NAMES) {
|
|
144
|
+
expect(md).toContain(`| ${phase} `);
|
|
145
|
+
}
|
|
146
|
+
// Sample counts reach the table.
|
|
147
|
+
expect(md).toContain('| connect |'); // row marker
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('renders a zeroed latency table when no clients succeeded', () => {
|
|
151
|
+
const md = generateReport(makeRun(emptySummary()));
|
|
152
|
+
for (const phase of PHASE_NAMES) {
|
|
153
|
+
expect(md).toContain(`| ${phase} `);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('documents that cost modeling is platform-specific (operator-filled)', () => {
|
|
158
|
+
const md = generateReport(makeRun());
|
|
159
|
+
expect(md.toLowerCase()).toContain('cost');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type LocalServer, startLocalServer } from '@serverless-ircd/local-cli/dist/server.js';
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
3
|
+
import { resolveConfig } from '../src/config';
|
|
4
|
+
import { runLoadTest } from '../src/harness';
|
|
5
|
+
import { generateReport } from '../src/report';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Smoke acceptance: "load test script completes without crashing."
|
|
9
|
+
*
|
|
10
|
+
* This is the end-to-end smoke: the real `IrcLoadClient` factory, a real
|
|
11
|
+
* in-process IRC server (`startLocalServer`, the project's e2e fixture
|
|
12
|
+
* target), and a small fanout of real WebSocket connections through the
|
|
13
|
+
* full lifecycle. Sizes are kept tiny so the suite stays fast; the same
|
|
14
|
+
* machinery scales to 10k by changing `--connections` on the CLI.
|
|
15
|
+
*/
|
|
16
|
+
describe('load-test smoke (real IRC server)', () => {
|
|
17
|
+
let irc: LocalServer;
|
|
18
|
+
let target: string;
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
irc = await startLocalServer({
|
|
22
|
+
port: 0,
|
|
23
|
+
hostname: '127.0.0.1',
|
|
24
|
+
serverName: 'irc.load-test.local',
|
|
25
|
+
networkName: 'LoadTestNet',
|
|
26
|
+
});
|
|
27
|
+
target = `ws://127.0.0.1:${irc.port}/`;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
await irc.close();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('completes a small fanout with no crashes and sane metrics', async () => {
|
|
35
|
+
const channel = `#smoke-${Math.random().toString(36).slice(2, 8)}`;
|
|
36
|
+
const config = resolveConfig({
|
|
37
|
+
target,
|
|
38
|
+
connections: 6,
|
|
39
|
+
connectConcurrency: 3,
|
|
40
|
+
rampMs: 0,
|
|
41
|
+
channel,
|
|
42
|
+
nickPrefix: 'smoke',
|
|
43
|
+
messagesPerClient: 2,
|
|
44
|
+
messageIntervalMs: 0,
|
|
45
|
+
platform: 'local-cli-smoke',
|
|
46
|
+
connectTimeoutMs: 3000,
|
|
47
|
+
registerTimeoutMs: 3000,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const run = await runLoadTest(config);
|
|
51
|
+
|
|
52
|
+
// Every client should succeed against the healthy in-process server.
|
|
53
|
+
expect(run.summary.totalClients).toBe(6);
|
|
54
|
+
expect(run.summary.succeeded).toBe(6);
|
|
55
|
+
expect(run.summary.failed).toBe(0);
|
|
56
|
+
expect(run.summary.dropRate).toBe(0);
|
|
57
|
+
// Each phase that was reached has samples.
|
|
58
|
+
expect(run.summary.phases.connect.count).toBe(6);
|
|
59
|
+
expect(run.summary.phases.register.count).toBe(6);
|
|
60
|
+
expect(run.summary.phases.join.count).toBe(6);
|
|
61
|
+
expect(run.summary.phases.privmsg.count).toBe(6 * 2);
|
|
62
|
+
// The report renders without throwing and mentions the platform.
|
|
63
|
+
const md = generateReport(run);
|
|
64
|
+
expect(md).toContain('local-cli-smoke');
|
|
65
|
+
expect(md).toContain('Latency');
|
|
66
|
+
}, 20_000);
|
|
67
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"composite": true,
|
|
5
|
+
"rootDir": "./src",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"tsBuildInfoFile": "./.tsbuildinfo",
|
|
8
|
+
"types": ["node"]
|
|
9
|
+
},
|
|
10
|
+
"include": ["src/**/*.ts"],
|
|
11
|
+
"exclude": ["dist", "tests", "**/*.test.ts"]
|
|
12
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
environment: 'node',
|
|
6
|
+
include: ['tests/**/*.test.ts'],
|
|
7
|
+
// The harness smoke test spins up a real in-process WS IRC server
|
|
8
|
+
// (via @serverless-ircd/local-cli) and ramps a small fanout of real
|
|
9
|
+
// WebSocket connections against it; give it headroom while staying
|
|
10
|
+
// fast enough for watch mode.
|
|
11
|
+
testTimeout: 15_000,
|
|
12
|
+
hookTimeout: 15_000,
|
|
13
|
+
coverage: {
|
|
14
|
+
provider: 'v8',
|
|
15
|
+
all: false,
|
|
16
|
+
include: ['src/**/*.ts'],
|
|
17
|
+
// `main.ts` is CLI glue (argv parsing, signal handlers); exercised
|
|
18
|
+
// manually by `pnpm start`. Mirrors the apps/local-cli policy.
|
|
19
|
+
exclude: ['src/main.ts'],
|
|
20
|
+
reporter: ['text', 'html', 'json-summary'],
|
|
21
|
+
thresholds: {
|
|
22
|
+
lines: 90,
|
|
23
|
+
functions: 90,
|
|
24
|
+
branches: 80,
|
|
25
|
+
statements: 90,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
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.6.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', () => {
|