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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Workers-flavoured {@link ServerStats} backend.
|
|
3
|
+
*
|
|
4
|
+
* Aggregates the network-wide counts the `LUSERS` / `STATS` reducers need
|
|
5
|
+
* by fanning out across the existing CF RPC surface: `listChannels` (via
|
|
6
|
+
* the {@link ChannelRegistryDO}) plus per-channel `listMembers` and
|
|
7
|
+
* per-member `getConnState`. Classification (oper / invisible / unknown)
|
|
8
|
+
* is delegated to the shared {@link computeStatsSnapshot} helper in
|
|
9
|
+
* irc-core so the classification rules live in exactly one place.
|
|
10
|
+
*
|
|
11
|
+
* **Limitation:** the CF runtime does not currently expose an
|
|
12
|
+
* "enumerate all connections" RPC, so a connection that has not joined
|
|
13
|
+
* any channel is not counted. This matches the ticket's "aggregate via
|
|
14
|
+
* listChannels / connection-enumeration RPCs" guidance; a future
|
|
15
|
+
* dedicated stats DO would close the gap (every connection would be
|
|
16
|
+
* counted regardless of channel membership).
|
|
17
|
+
*
|
|
18
|
+
* Constructed per `LUSERS` / `STATS` invocation alongside the
|
|
19
|
+
* {@link CfRuntime}; cheap to build, no caching. `uptimeStartedAt` is
|
|
20
|
+
* supplied by the caller (typically the worker's startup timestamp).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
ChanName,
|
|
25
|
+
ConnId,
|
|
26
|
+
ConnectionState,
|
|
27
|
+
ServerStatsSnapshot,
|
|
28
|
+
} from '@serverless-ircd/irc-core';
|
|
29
|
+
import { computeStatsSnapshot } from '@serverless-ircd/irc-core';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The minimal slice of {@link CfRuntime} (or any adapter runtime) that
|
|
33
|
+
* {@link CfStats} needs. Defined structurally so unit tests can pass a
|
|
34
|
+
* stub without spinning up workerd / miniflare.
|
|
35
|
+
*/
|
|
36
|
+
export interface CfStatsRuntime {
|
|
37
|
+
listChannels(): Promise<ReadonlyArray<{ nameLower: string }>>;
|
|
38
|
+
getChannelConnections(name: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class CfStats {
|
|
42
|
+
private readonly runtime: CfStatsRuntime;
|
|
43
|
+
private readonly uptimeStartedAt: number;
|
|
44
|
+
|
|
45
|
+
constructor(runtime: CfStatsRuntime, uptimeStartedAt: number) {
|
|
46
|
+
this.runtime = runtime;
|
|
47
|
+
this.uptimeStartedAt = uptimeStartedAt;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async getStats(): Promise<ServerStatsSnapshot> {
|
|
51
|
+
const channels = await this.runtime.listChannels();
|
|
52
|
+
// Fan out: gather every channel's members into one deduplicated map.
|
|
53
|
+
// A user in N channels is counted once.
|
|
54
|
+
const unique = new Map<ConnId, ConnectionState>();
|
|
55
|
+
await Promise.all(
|
|
56
|
+
channels.map(async (chan) => {
|
|
57
|
+
const members = await this.runtime.getChannelConnections(chan.nameLower as ChanName);
|
|
58
|
+
for (const [id, state] of members) {
|
|
59
|
+
unique.set(id, state);
|
|
60
|
+
}
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
return computeStatsSnapshot(unique.values(), channels.length, this.uptimeStartedAt);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CF-side {@link IrcHarnessFactory} — registers the parametrized IRC
|
|
3
|
-
* scenario suite
|
|
3
|
+
* scenario suite from `@serverless-ircd/irc-test-support` against the Cloudflare adapter.
|
|
4
4
|
*
|
|
5
5
|
* Each {@link CfHarness} instance is hermetic at the vitest-pool-workers
|
|
6
6
|
* level: every test gets an isolated storage namespace
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Parametrized IRC scenarios against the Cloudflare runtime
|
|
2
|
+
* Parametrized IRC scenarios against the Cloudflare runtime.
|
|
3
3
|
*
|
|
4
4
|
* Reuses the scenario runner from `@serverless-ircd/irc-test-support`
|
|
5
|
-
*
|
|
5
|
+
* and registers the CF harness factory built on top of the
|
|
6
6
|
* real ConnectionDO / RegistryDO / ChannelDO worker bindings. Each
|
|
7
7
|
* scenario spawns one or more real WebSockets and drives the full
|
|
8
8
|
* bytes → actor → CfRuntime pipeline inside `workerd`.
|
|
@@ -15,13 +15,13 @@
|
|
|
15
15
|
* against the in-memory runtime; CF-specific failures stem from the
|
|
16
16
|
* ConnectionDO's `PassthroughChannelAccess` not yet fetching
|
|
17
17
|
* authoritative roster state from ChannelDO before running channel
|
|
18
|
-
* reducers. That wiring lands in a follow-up to keep this
|
|
18
|
+
* reducers. That wiring lands in a follow-up to keep this work
|
|
19
19
|
* focused on the CfRuntime class itself):
|
|
20
20
|
* - Multi-client channel reads (NAMES of peers, KICK roster refresh)
|
|
21
21
|
* see only the local connection until the actor's channel access
|
|
22
22
|
* is upgraded to read from ChannelDO.
|
|
23
23
|
* - ChannelDO.getChannelSnapshot has an unrelated bug in the parallel
|
|
24
|
-
* ChannelDO
|
|
24
|
+
* ChannelDO work that returns a malformed snapshot;
|
|
25
25
|
* that bug is not introduced here.
|
|
26
26
|
*/
|
|
27
27
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CfRuntime
|
|
2
|
+
* CfRuntime.
|
|
3
3
|
*
|
|
4
4
|
* Verifies that every {@link IrcRuntime} method on {@link CfRuntime} issues
|
|
5
5
|
* the correct DO `stub()` call:
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Two envs are used:
|
|
17
17
|
* - `env` — production bindings (recording stubs for the
|
|
18
|
-
*
|
|
18
|
+
* ConnectionDO unit suite; swapped to `_REAL` for
|
|
19
19
|
* the CfRuntime uniqueness + end-to-end tests).
|
|
20
20
|
* - `envWithRealDOs()` — a shim that re-exposes the real RegistryDO +
|
|
21
21
|
* ChannelDO under the production binding names,
|
|
@@ -616,3 +616,39 @@ describe('CfRuntime — IrcRuntime structural conformance', () => {
|
|
|
616
616
|
expect(handlers.sent).toEqual([':server 001 alice :Welcome']);
|
|
617
617
|
});
|
|
618
618
|
});
|
|
619
|
+
|
|
620
|
+
describe('CfRuntime — reloadConfig (REHASH)', () => {
|
|
621
|
+
it('re-reads the bound Workers env and reparses the server config', async () => {
|
|
622
|
+
const realEnv = envWithRealDOs();
|
|
623
|
+
const rt = new CfRuntime(realEnv, 'conn-rehash', recordingHandlers());
|
|
624
|
+
|
|
625
|
+
const cfg = await rt.reloadConfig();
|
|
626
|
+
|
|
627
|
+
expect(cfg.serverName).toBe('irc.example.com');
|
|
628
|
+
expect(cfg.networkName).toBe('ExampleNet');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('reflects a rotated env value on the next reload (no caching)', async () => {
|
|
632
|
+
const rotatingEnv = {
|
|
633
|
+
...envWithRealDOs(),
|
|
634
|
+
OPER_USER: 'admin',
|
|
635
|
+
OPER_PASSWORD: 'old',
|
|
636
|
+
};
|
|
637
|
+
const rt = new CfRuntime(rotatingEnv, 'conn-rehash-2', recordingHandlers());
|
|
638
|
+
|
|
639
|
+
const first = await rt.reloadConfig();
|
|
640
|
+
expect(first.operCreds).toEqual([{ user: 'admin', password: 'old' }]);
|
|
641
|
+
|
|
642
|
+
// Rotate the env (simulate a re-deployed secret) and reload again.
|
|
643
|
+
rotatingEnv.OPER_PASSWORD = 'rotated';
|
|
644
|
+
const second = await rt.reloadConfig();
|
|
645
|
+
expect(second.operCreds).toEqual([{ user: 'admin', password: 'rotated' }]);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('propagates a schema-validation failure so the actor applies the graceful 382', async () => {
|
|
649
|
+
const badEnv: Env = { ...envWithRealDOs(), NETWORK_NAME: '' };
|
|
650
|
+
const rt = new CfRuntime(badEnv, 'conn-rehash-bad', recordingHandlers());
|
|
651
|
+
|
|
652
|
+
await expect(rt.reloadConfig()).rejects.toThrow(/networkName/u);
|
|
653
|
+
});
|
|
654
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ChannelDO — roster + modes + fanout via ConnectionDO stubs.
|
|
3
3
|
*
|
|
4
|
-
* TDD outline
|
|
4
|
+
* TDD outline:
|
|
5
5
|
* "Red on 'two members of a channel both receive PRIVMSG'; green;
|
|
6
6
|
* then gone-connection sweep."
|
|
7
7
|
*
|
|
@@ -452,7 +452,7 @@ describe('ChannelDO — instance keying', () => {
|
|
|
452
452
|
});
|
|
453
453
|
|
|
454
454
|
// ---------------------------------------------------------------------------
|
|
455
|
-
// Scale smoke test — exercises the 1k-member ceiling
|
|
455
|
+
// Scale smoke test — exercises the 1k-member ceiling.
|
|
456
456
|
// ---------------------------------------------------------------------------
|
|
457
457
|
|
|
458
458
|
describe('ChannelDO — scale', () => {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* CF `webSocketMessage` channel-registration: wire ChannelDO
|
|
3
3
|
* registration OR remove the dead loop.
|
|
4
4
|
*
|
|
5
|
-
* Spike conclusion
|
|
5
|
+
* Spike conclusion: the dead loop that
|
|
6
6
|
* iterated `state.joinedChannels` to "register" the ConnectionDO with
|
|
7
7
|
* each ChannelDO is redundant. Channel membership — and therefore
|
|
8
8
|
* broadcast fan-out registration — is driven solely by the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* CF outbound batching: implement OR drop the reserved optimization.
|
|
3
3
|
*
|
|
4
|
-
* Spike conclusion
|
|
4
|
+
* Spike conclusion: the speculative per-microtask
|
|
5
5
|
* outbound drain that was reserved as a commented-out field in
|
|
6
6
|
* `ConnectionDO` is dropped. Per-call batching is already implemented —
|
|
7
7
|
* `ConnectionDO.deliver()` joins every line of a ChannelDO broadcast into
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CF adapter — IRCv3 WebSocket spec-compliance (end-to-end contract).
|
|
3
|
+
*
|
|
4
|
+
* A single integration suite driving the real `ConnectionDO`
|
|
5
|
+
* through `cloudflare:test` (miniflare) that asserts the FULL IRCv3
|
|
6
|
+
* WebSocket spec contract end-to-end against a negotiated `text.ircv3.net`
|
|
7
|
+
* connection (with a legacy fallback for the differential cases):
|
|
8
|
+
*
|
|
9
|
+
* - `Sec-WebSocket-Protocol` echo (`text.ircv3.net` / `binary.ircv3.net`)
|
|
10
|
+
* - one IRC message per WebSocket message, BOTH directions:
|
|
11
|
+
* outbound registration replies arrive as N separate WS messages
|
|
12
|
+
* with no trailing CR-LF; bare inbound frames (no trailing CR-LF)
|
|
13
|
+
* are accepted; an embedded CR-LF is NOT split into two commands.
|
|
14
|
+
* - the 510-byte message budget: a 511-byte message is rejected with
|
|
15
|
+
* RFC 6455 close code 1009 (Message Too Big); a 510-byte message is
|
|
16
|
+
* accepted.
|
|
17
|
+
* - legacy fallback: a connection offering no subprotocol still
|
|
18
|
+
* registers and still splits `\r\n`-joined frames.
|
|
19
|
+
*
|
|
20
|
+
* Hermetic against Miniflare; runs in the normal `pnpm test` suite (no
|
|
21
|
+
* `--e2e` flag). The per-connection negotiation cases live in
|
|
22
|
+
* `connection-do-ws-subprotocol.test.ts`; this file is the
|
|
23
|
+
* consolidated end-to-end contract.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { env } from 'cloudflare:test';
|
|
27
|
+
import { MAX_WS_MESSAGE_BYTES } from '@serverless-ircd/irc-core';
|
|
28
|
+
import { describe, expect, it } from 'vitest';
|
|
29
|
+
|
|
30
|
+
declare global {
|
|
31
|
+
namespace Cloudflare {
|
|
32
|
+
interface Env {
|
|
33
|
+
CONNECTION_DO: DurableObjectNamespace;
|
|
34
|
+
REGISTRY_DO: DurableObjectNamespace;
|
|
35
|
+
CHANNEL_DO: DurableObjectNamespace;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Helpers — a WebSocket client that records raw WS messages (not pre-split).
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** Poll interval (ms) for the waitFor* helpers. */
|
|
45
|
+
const POLL_MS = 10;
|
|
46
|
+
/** Default timeout (ms) for the waitFor* helpers. */
|
|
47
|
+
const DEFAULT_TIMEOUT_MS = 2000;
|
|
48
|
+
|
|
49
|
+
class SpecWsClient {
|
|
50
|
+
/** Raw WebSocket messages exactly as delivered (one entry per WS message). */
|
|
51
|
+
readonly messages: string[] = [];
|
|
52
|
+
/** Lines split out of `messages` (tolerant of legacy `\r\n` framing). */
|
|
53
|
+
readonly lines: string[] = [];
|
|
54
|
+
readonly ws: WebSocket;
|
|
55
|
+
closedCode: number | undefined;
|
|
56
|
+
|
|
57
|
+
constructor(ws: WebSocket) {
|
|
58
|
+
this.ws = ws;
|
|
59
|
+
ws.addEventListener('message', (ev: MessageEvent) => {
|
|
60
|
+
const data = typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data);
|
|
61
|
+
this.messages.push(data);
|
|
62
|
+
for (const line of data.split('\r\n')) {
|
|
63
|
+
if (line.length > 0) this.lines.push(line);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
ws.addEventListener('close', (ev: CloseEvent) => {
|
|
67
|
+
this.closedCode = ev.code;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Sends one IRC line as a single WS message WITH a trailing CR-LF. */
|
|
72
|
+
send(line: string): void {
|
|
73
|
+
this.ws.send(`${line}\r\n`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Sends `frame` verbatim as a single WS message — no trailing CR-LF is
|
|
78
|
+
* appended. This is how an IRCv3 spec-compliant client sends each IRC
|
|
79
|
+
* message (one per WS message, no terminator on the wire).
|
|
80
|
+
*/
|
|
81
|
+
sendRaw(frame: string): void {
|
|
82
|
+
this.ws.send(frame);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async waitForLine(
|
|
86
|
+
predicate: (line: string) => boolean,
|
|
87
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
88
|
+
): Promise<string> {
|
|
89
|
+
const deadline = Date.now() + timeoutMs;
|
|
90
|
+
for (const l of this.lines) if (predicate(l)) return l;
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
93
|
+
for (const l of this.lines) if (predicate(l)) return l;
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`waitForLine timed out; lines: ${JSON.stringify(this.lines)}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async waitForClose(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<number | undefined> {
|
|
99
|
+
if (this.closedCode !== undefined) return this.closedCode;
|
|
100
|
+
const deadline = Date.now() + timeoutMs;
|
|
101
|
+
while (Date.now() < deadline) {
|
|
102
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
103
|
+
if (this.closedCode !== undefined) return this.closedCode;
|
|
104
|
+
}
|
|
105
|
+
throw new Error(`waitForClose timed out; messages: ${JSON.stringify(this.messages)}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Waits until at least `n` raw WS messages satisfy `predicate`. */
|
|
109
|
+
async waitForMessages(
|
|
110
|
+
predicate: (msg: string) => boolean,
|
|
111
|
+
n: number,
|
|
112
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
113
|
+
): Promise<string[]> {
|
|
114
|
+
const deadline = Date.now() + timeoutMs;
|
|
115
|
+
const matching = (): string[] => this.messages.filter(predicate);
|
|
116
|
+
let hit = matching();
|
|
117
|
+
if (hit.length >= n) return hit;
|
|
118
|
+
while (Date.now() < deadline) {
|
|
119
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
120
|
+
hit = matching();
|
|
121
|
+
if (hit.length >= n) return hit;
|
|
122
|
+
}
|
|
123
|
+
throw new Error(
|
|
124
|
+
`waitForMessages(n=${n}) timed out; matching ${hit.length}: ${JSON.stringify(hit)}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Counts raw WS messages whose command token is `cmd` (e.g. 'PONG'). */
|
|
130
|
+
function countCommand(messages: readonly string[], cmd: string): number {
|
|
131
|
+
const upper = cmd.toUpperCase();
|
|
132
|
+
let n = 0;
|
|
133
|
+
for (const msg of messages) {
|
|
134
|
+
const parts = msg.split(' ');
|
|
135
|
+
const start = parts[0]?.startsWith(':') ? 1 : 0;
|
|
136
|
+
if (parts[start]?.toUpperCase() === upper) n++;
|
|
137
|
+
}
|
|
138
|
+
return n;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Opens a WebSocket to a fresh ConnectionDO, optionally offering a
|
|
143
|
+
* `Sec-WebSocket-Protocol` list. Returns the upgrade Response (so the
|
|
144
|
+
* caller can assert on the echoed header) plus a ready-to-use client.
|
|
145
|
+
*/
|
|
146
|
+
async function openUpgrade(
|
|
147
|
+
connId: string,
|
|
148
|
+
protocols?: string,
|
|
149
|
+
): Promise<{ response: Response; client: SpecWsClient }> {
|
|
150
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(connId));
|
|
151
|
+
const headers: Record<string, string> = { Upgrade: 'websocket' };
|
|
152
|
+
if (protocols !== undefined) {
|
|
153
|
+
headers['Sec-WebSocket-Protocol'] = protocols;
|
|
154
|
+
}
|
|
155
|
+
const response = await stub.fetch('https://do/upgrade', { headers });
|
|
156
|
+
const ws = response.webSocket;
|
|
157
|
+
if (ws === undefined || ws === null) {
|
|
158
|
+
throw new Error('ConnectionDO.fetch did not return a WebSocket');
|
|
159
|
+
}
|
|
160
|
+
ws.accept();
|
|
161
|
+
const client = await new Promise<SpecWsClient>((resolve) => {
|
|
162
|
+
setTimeout(() => resolve(new SpecWsClient(ws as WebSocket)), 0);
|
|
163
|
+
});
|
|
164
|
+
return { response, client };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Registers a client using bare spec-style frames (no trailing CR-LF). */
|
|
168
|
+
async function registerBare(client: SpecWsClient, nick: string): Promise<void> {
|
|
169
|
+
client.sendRaw(`NICK ${nick}`);
|
|
170
|
+
client.sendRaw(`USER ${nick} 0 * :${nick}`);
|
|
171
|
+
await client.waitForLine((l) => l.includes(' 001 '));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Spec contract
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
describe('ConnectionDO — IRCv3 WebSocket spec contract (end-to-end)', () => {
|
|
179
|
+
// --- Sec-WebSocket-Protocol echo ----------------------------------------
|
|
180
|
+
|
|
181
|
+
it('echoes text.ircv3.net and binary.ircv3.net back in the 101 response', async () => {
|
|
182
|
+
const text = await openUpgrade('conn-contract-echo-text', 'text.ircv3.net');
|
|
183
|
+
expect(text.response.status).toBe(101);
|
|
184
|
+
expect(text.response.headers.get('Sec-WebSocket-Protocol')).toBe('text.ircv3.net');
|
|
185
|
+
text.client.ws.close();
|
|
186
|
+
|
|
187
|
+
const bin = await openUpgrade('conn-contract-echo-bin', 'binary.ircv3.net');
|
|
188
|
+
expect(bin.response.status).toBe(101);
|
|
189
|
+
expect(bin.response.headers.get('Sec-WebSocket-Protocol')).toBe('binary.ircv3.net');
|
|
190
|
+
bin.client.ws.close();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// --- Outbound: one IRC line per WS message, no trailing CR-LF -----------
|
|
194
|
+
|
|
195
|
+
it('delivers each outbound IRC line as its own WS message with no trailing CR-LF (spec-text)', async () => {
|
|
196
|
+
const { client } = await openUpgrade('conn-contract-outbound', 'text.ircv3.net');
|
|
197
|
+
client.sendRaw('NICK out-bob');
|
|
198
|
+
client.sendRaw('USER out-bob 0 * :out-bob');
|
|
199
|
+
await client.waitForLine((l) => l.includes(' 001 '));
|
|
200
|
+
|
|
201
|
+
// A successful registration emits several numerics (001..005, MOTD).
|
|
202
|
+
expect(client.messages.length).toBeGreaterThan(1);
|
|
203
|
+
for (const msg of client.messages) {
|
|
204
|
+
expect(msg.endsWith('\r\n')).toBe(false);
|
|
205
|
+
}
|
|
206
|
+
client.ws.close();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// --- Inbound: one IRC message per WS message ----------------------------
|
|
210
|
+
|
|
211
|
+
it('accepts bare single-line inbound frames (no trailing CR-LF) and completes registration (spec-text)', async () => {
|
|
212
|
+
const { client } = await openUpgrade('conn-contract-inbound-bare', 'text.ircv3.net');
|
|
213
|
+
// Each command is its own WS message with NO trailing CR-LF — the
|
|
214
|
+
// spec-mandated client shape. Registration must still complete.
|
|
215
|
+
await registerBare(client, 'bare-alice');
|
|
216
|
+
expect(client.lines.some((l) => l.includes(' 001 '))).toBe(true);
|
|
217
|
+
client.ws.close();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('treats one WS message as exactly one IRC command (embedded CR-LF is not split) in spec-text mode', async () => {
|
|
221
|
+
const { client } = await openUpgrade('conn-contract-nosplit', 'text.ircv3.net');
|
|
222
|
+
await registerBare(client, 'nosplit-alice');
|
|
223
|
+
client.messages.length = 0;
|
|
224
|
+
|
|
225
|
+
// One WS message that LOOKS like two PING lines. In spec mode the frame
|
|
226
|
+
// is a single IRC message, so exactly ONE PONG is emitted.
|
|
227
|
+
client.sendRaw('PING one\r\nPING two');
|
|
228
|
+
await client.waitForMessages((m) => countCommand([m], 'PONG') === 1, 1);
|
|
229
|
+
// Give a grace window for a (incorrect) second PONG to arrive.
|
|
230
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
231
|
+
expect(countCommand(client.messages, 'PONG')).toBe(1);
|
|
232
|
+
client.ws.close();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('splits a joined CR-LF frame into multiple commands in legacy mode', async () => {
|
|
236
|
+
const { client } = await openUpgrade('conn-contract-legacy-split');
|
|
237
|
+
await registerBare(client, 'legsplit-bob');
|
|
238
|
+
client.messages.length = 0;
|
|
239
|
+
|
|
240
|
+
// Same joined frame, but legacy framing splits on CR-LF → TWO PONGs.
|
|
241
|
+
client.sendRaw('PING one\r\nPING two');
|
|
242
|
+
await client.waitForMessages((m) => countCommand([m], 'PONG') === 1, 2);
|
|
243
|
+
expect(countCommand(client.messages, 'PONG')).toBe(2);
|
|
244
|
+
client.ws.close();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// --- 510-byte message budget -------------------------------------------
|
|
248
|
+
|
|
249
|
+
it(`accepts a ${MAX_WS_MESSAGE_BYTES}-byte message and rejects a ${MAX_WS_MESSAGE_BYTES + 1}-byte message with close 1009 (spec-text)`, async () => {
|
|
250
|
+
// Accepted: exactly the budget. A PING whose token pads the frame to
|
|
251
|
+
// MAX_WS_MESSAGE_BYTES bytes parses and is answered with a PONG.
|
|
252
|
+
const okFrame = `PING :${'a'.repeat(MAX_WS_MESSAGE_BYTES - 'PING :'.length)}`;
|
|
253
|
+
expect(okFrame.length).toBe(MAX_WS_MESSAGE_BYTES);
|
|
254
|
+
const ok = await openUpgrade('conn-contract-budget-ok', 'text.ircv3.net');
|
|
255
|
+
ok.client.sendRaw(okFrame);
|
|
256
|
+
await ok.client.waitForLine((l) => l.startsWith('PONG') || l.includes(' PONG '));
|
|
257
|
+
expect(ok.client.closedCode).toBeUndefined();
|
|
258
|
+
ok.client.ws.close();
|
|
259
|
+
|
|
260
|
+
// Rejected: one byte over the budget → RFC 6455 1009 (Message Too Big).
|
|
261
|
+
const bigFrame = `PING :${'a'.repeat(MAX_WS_MESSAGE_BYTES + 1 - 'PING :'.length)}`;
|
|
262
|
+
expect(bigFrame.length).toBe(MAX_WS_MESSAGE_BYTES + 1);
|
|
263
|
+
const big = await openUpgrade('conn-contract-budget-big', 'text.ircv3.net');
|
|
264
|
+
big.client.sendRaw(bigFrame);
|
|
265
|
+
const code = await big.client.waitForClose();
|
|
266
|
+
expect(code).toBe(1009);
|
|
267
|
+
|
|
268
|
+
// The same budget applies on a binary.ircv3.net connection (sent as a
|
|
269
|
+
// binary frame): oversize → close 1009.
|
|
270
|
+
const bin = await openUpgrade('conn-contract-budget-bin', 'binary.ircv3.net');
|
|
271
|
+
bin.client.ws.send(new TextEncoder().encode(bigFrame));
|
|
272
|
+
const binCode = await bin.client.waitForClose();
|
|
273
|
+
expect(binCode).toBe(1009);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// --- Legacy fallback ----------------------------------------------------
|
|
277
|
+
|
|
278
|
+
it('still serves a legacy (no subprotocol offer) connection end-to-end', async () => {
|
|
279
|
+
const { response, client } = await openUpgrade('conn-contract-legacy');
|
|
280
|
+
expect(response.headers.get('Sec-WebSocket-Protocol')).toBeNull();
|
|
281
|
+
// Legacy clients join several commands into one CR-LF frame; the server
|
|
282
|
+
// splits and processes both → registration completes.
|
|
283
|
+
client.send('NICK legacy-carol');
|
|
284
|
+
client.send('USER legacy-carol 0 * :legacy-carol');
|
|
285
|
+
await client.waitForLine((l) => l.includes(' 001 '));
|
|
286
|
+
expect(client.lines.some((l) => l.includes(' 001 '))).toBe(true);
|
|
287
|
+
client.ws.close();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CF adapter — IRCv3 WebSocket subprotocol negotiation + per-message framing.
|
|
3
|
+
*
|
|
4
|
+
* Drives the real `ConnectionDO` through `cloudflare:test` (miniflare) so
|
|
5
|
+
* the `Sec-WebSocket-Protocol` round-trip, hibernation tags, and frame
|
|
6
|
+
* framing behave exactly as in production. Each test gets its own
|
|
7
|
+
* isolated storage namespace.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { env } from 'cloudflare:test';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
declare global {
|
|
14
|
+
namespace Cloudflare {
|
|
15
|
+
interface Env {
|
|
16
|
+
CONNECTION_DO: DurableObjectNamespace;
|
|
17
|
+
REGISTRY_DO: DurableObjectNamespace;
|
|
18
|
+
CHANNEL_DO: DurableObjectNamespace;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Helpers — WebSocket client that records raw WS messages (not split lines).
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
class SpecWsClient {
|
|
28
|
+
/** Raw WebSocket messages exactly as delivered (one entry per WS message). */
|
|
29
|
+
readonly messages: string[] = [];
|
|
30
|
+
/** Lines split out of `messages` (tolerant of legacy `\r\n` framing). */
|
|
31
|
+
readonly lines: string[] = [];
|
|
32
|
+
readonly ws: WebSocket;
|
|
33
|
+
closedCode: number | undefined;
|
|
34
|
+
|
|
35
|
+
constructor(ws: WebSocket) {
|
|
36
|
+
this.ws = ws;
|
|
37
|
+
ws.addEventListener('message', (ev: MessageEvent) => {
|
|
38
|
+
const data = typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data);
|
|
39
|
+
this.messages.push(data);
|
|
40
|
+
for (const line of data.split('\r\n')) {
|
|
41
|
+
if (line.length > 0) this.lines.push(line);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
ws.addEventListener('close', (ev: CloseEvent) => {
|
|
45
|
+
this.closedCode = ev.code;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
send(line: string): void {
|
|
50
|
+
this.ws.send(`${line}\r\n`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
waitForLine(predicate: (line: string) => boolean, timeoutMs = 2000): Promise<string> {
|
|
54
|
+
return new Promise<string>((resolve, reject) => {
|
|
55
|
+
const start = Date.now();
|
|
56
|
+
const tick = (): void => {
|
|
57
|
+
const hit = this.lines.find(predicate);
|
|
58
|
+
if (hit !== undefined) {
|
|
59
|
+
resolve(hit);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (Date.now() - start > timeoutMs) {
|
|
63
|
+
reject(new Error(`timeout waiting for line; got: ${JSON.stringify(this.lines)}`));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
setTimeout(tick, 20);
|
|
67
|
+
};
|
|
68
|
+
tick();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
waitForClose(timeoutMs = 2000): Promise<number | undefined> {
|
|
73
|
+
return new Promise<number | undefined>((resolve, reject) => {
|
|
74
|
+
if (this.closedCode !== undefined) {
|
|
75
|
+
resolve(this.closedCode);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const start = Date.now();
|
|
79
|
+
const tick = (): void => {
|
|
80
|
+
if (this.closedCode !== undefined) {
|
|
81
|
+
resolve(this.closedCode);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (Date.now() - start > timeoutMs) {
|
|
85
|
+
reject(
|
|
86
|
+
new Error(`timeout waiting for close; messages: ${JSON.stringify(this.messages)}`),
|
|
87
|
+
);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
setTimeout(tick, 20);
|
|
91
|
+
};
|
|
92
|
+
tick();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Opens a WebSocket to a fresh ConnectionDO, optionally offering a
|
|
99
|
+
* `Sec-WebSocket-Protocol` list. Returns the upgrade Response so the
|
|
100
|
+
* caller can assert on the echoed header, plus a ready-to-use client.
|
|
101
|
+
*/
|
|
102
|
+
async function openUpgrade(
|
|
103
|
+
connId: string,
|
|
104
|
+
protocols?: string,
|
|
105
|
+
): Promise<{ response: Response; client: SpecWsClient }> {
|
|
106
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(connId));
|
|
107
|
+
const headers: Record<string, string> = { Upgrade: 'websocket' };
|
|
108
|
+
if (protocols !== undefined) {
|
|
109
|
+
headers['Sec-WebSocket-Protocol'] = protocols;
|
|
110
|
+
}
|
|
111
|
+
const response = await stub.fetch('https://do/upgrade', { headers });
|
|
112
|
+
const ws = response.webSocket;
|
|
113
|
+
if (ws === undefined || ws === null) {
|
|
114
|
+
throw new Error('ConnectionDO.fetch did not return a WebSocket');
|
|
115
|
+
}
|
|
116
|
+
ws.accept();
|
|
117
|
+
const client = await new Promise<SpecWsClient>((resolve) => {
|
|
118
|
+
setTimeout(() => resolve(new SpecWsClient(ws as WebSocket)), 0);
|
|
119
|
+
});
|
|
120
|
+
return { response, client };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function register(client: SpecWsClient, nick: string): Promise<void> {
|
|
124
|
+
client.send(`NICK ${nick}`);
|
|
125
|
+
client.send(`USER ${nick} 0 * :${nick}`);
|
|
126
|
+
await client.waitForLine((l) => l.includes(' 001 '));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Acceptance criteria
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
describe('ConnectionDO — IRCv3 WebSocket subprotocol negotiation', () => {
|
|
134
|
+
it('echoes text.ircv3.net back in the 101 response Sec-WebSocket-Protocol header', async () => {
|
|
135
|
+
const { response, client } = await openUpgrade('conn-subproto-text', 'text.ircv3.net');
|
|
136
|
+
expect(response.status).toBe(101);
|
|
137
|
+
expect(response.headers.get('Sec-WebSocket-Protocol')).toBe('text.ircv3.net');
|
|
138
|
+
client.ws.close();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('selects the first-listed supported protocol when both are offered', async () => {
|
|
142
|
+
const a = await openUpgrade('conn-subproto-order-a', 'text.ircv3.net, binary.ircv3.net');
|
|
143
|
+
expect(a.response.headers.get('Sec-WebSocket-Protocol')).toBe('text.ircv3.net');
|
|
144
|
+
a.client.ws.close();
|
|
145
|
+
|
|
146
|
+
const b = await openUpgrade('conn-subproto-order-b', 'binary.ircv3.net, text.ircv3.net');
|
|
147
|
+
expect(b.response.headers.get('Sec-WebSocket-Protocol')).toBe('binary.ircv3.net');
|
|
148
|
+
b.client.ws.close();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('omits Sec-WebSocket-Protocol when the client offers no supported protocol', async () => {
|
|
152
|
+
const { response, client } = await openUpgrade('conn-subproto-unsupported', 'bogus.proto');
|
|
153
|
+
expect(response.status).toBe(101);
|
|
154
|
+
expect(response.headers.get('Sec-WebSocket-Protocol')).toBeNull();
|
|
155
|
+
client.ws.close();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('connects without a subprotocol offer (legacy mode) and registers', async () => {
|
|
159
|
+
const { client } = await openUpgrade('conn-subproto-legacy');
|
|
160
|
+
await register(client, 'legacy-alice');
|
|
161
|
+
expect(client.lines.some((l) => l.includes(' 001 '))).toBe(true);
|
|
162
|
+
client.ws.close();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('delivers registration replies as N separate WS messages with no trailing CRLF (spec-text)', async () => {
|
|
166
|
+
const { client } = await openUpgrade('conn-subproto-permsg', 'text.ircv3.net');
|
|
167
|
+
client.send('NICK spec-bob');
|
|
168
|
+
client.send('USER spec-bob 0 * :spec-bob');
|
|
169
|
+
await client.waitForLine((l) => l.includes(' 001 '));
|
|
170
|
+
|
|
171
|
+
expect(client.messages.length).toBeGreaterThan(1);
|
|
172
|
+
for (const msg of client.messages) {
|
|
173
|
+
expect(msg.endsWith('\r\n')).toBe(false);
|
|
174
|
+
}
|
|
175
|
+
client.ws.close();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('closes with code 1003 when a binary frame arrives on a spec-text connection', async () => {
|
|
179
|
+
const { client } = await openUpgrade('conn-subproto-bin-on-text', 'text.ircv3.net');
|
|
180
|
+
client.ws.send(new Uint8Array([0x4e, 0x49, 0x43, 0x4b]).buffer);
|
|
181
|
+
const code = await client.waitForClose();
|
|
182
|
+
expect(code).toBe(1003);
|
|
183
|
+
});
|
|
184
|
+
});
|