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
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* • a `ws.WebSocketServer` on {@link StartServerOptions.port} — the
|
|
9
9
|
* original WebSocket transport (one IRC message per text frame, or N
|
|
10
10
|
* `\r\n`-joined messages per frame). Programmatic test clients use this.
|
|
11
|
+
* When a client negotiates an IRCv3 subprotocol
|
|
12
|
+
* (`text.ircv3.net` / `binary.ircv3.net`) the connection switches to
|
|
13
|
+
* spec framing: one IRC message per WebSocket message with no trailing
|
|
14
|
+
* CR-LF, a 510-byte inbound budget, and (for text) lone-surrogate
|
|
15
|
+
* sanitization. Un-negotiated clients keep the legacy framing.
|
|
11
16
|
* • an optional `node:net` TCP server on {@link StartServerOptions.tcpPort}
|
|
12
17
|
* — the RFC-style byte stream that real IRC clients (WeeChat, HexChat,
|
|
13
18
|
* IRCCloud) speak. The TCP path uses {@link LineScanner} to reassemble
|
|
@@ -35,13 +40,25 @@ import {
|
|
|
35
40
|
ConsoleLogger,
|
|
36
41
|
InMemoryAccountStore,
|
|
37
42
|
InMemoryMessageStore,
|
|
43
|
+
InMemoryNickHistoryStore,
|
|
38
44
|
LogLevel,
|
|
39
45
|
type Logger,
|
|
40
46
|
type MessageStore,
|
|
47
|
+
type NickHistoryStore,
|
|
41
48
|
type RawLine,
|
|
49
|
+
type ServerConfig,
|
|
50
|
+
WS_SUBPROTO_BINARY,
|
|
51
|
+
WS_SUBPROTO_TEXT,
|
|
52
|
+
type WsFrameMode,
|
|
53
|
+
type WsSubprotocol,
|
|
42
54
|
createConnection,
|
|
55
|
+
frameToLines,
|
|
56
|
+
isWithinWsByteBudget,
|
|
57
|
+
sanitizeForTextMode,
|
|
58
|
+
selectSubprotocol,
|
|
59
|
+
wsFrameModeFor,
|
|
43
60
|
} from '@serverless-ircd/irc-core';
|
|
44
|
-
import { ConnectionActor } from '@serverless-ircd/irc-server';
|
|
61
|
+
import { ConnectionActor, type Transport } from '@serverless-ircd/irc-server';
|
|
45
62
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
46
63
|
import { loadServerConfigFromCliArgs } from './config-loader.js';
|
|
47
64
|
import { LineScanner } from './line-scanner.js';
|
|
@@ -71,6 +88,28 @@ type ResolvedServerConfig = Omit<
|
|
|
71
88
|
| 'saslAccounts'
|
|
72
89
|
>;
|
|
73
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Builds the reducer-facing {@link ServerConfig} from the resolved local-cli
|
|
93
|
+
* config. Shared by {@link attachConnection} (initial config) and the
|
|
94
|
+
* `InMemoryRuntime` reload source (REHASH) so a reload returns the same
|
|
95
|
+
* shape the actor was bootstrapped with — keeping the two paths consistent.
|
|
96
|
+
*/
|
|
97
|
+
function serverConfigFromResolved(cfg: ResolvedServerConfig): ServerConfig {
|
|
98
|
+
return {
|
|
99
|
+
serverName: cfg.serverName,
|
|
100
|
+
networkName: cfg.networkName,
|
|
101
|
+
maxChannelsPerUser: cfg.maxChannelsPerUser,
|
|
102
|
+
maxTargetsPerCommand: cfg.maxTargetsPerCommand,
|
|
103
|
+
maxListEntries: cfg.maxListEntries,
|
|
104
|
+
nickLen: cfg.nickLen,
|
|
105
|
+
channelLen: cfg.channelLen,
|
|
106
|
+
topicLen: cfg.topicLen,
|
|
107
|
+
quitMessage: cfg.quitMessage,
|
|
108
|
+
...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
|
|
109
|
+
...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
74
113
|
/** Default per-IP / per-user caps used when callers don't override. */
|
|
75
114
|
const DEFAULT_MAX_CONNECTIONS_PER_IP = 10;
|
|
76
115
|
const DEFAULT_MAX_CONNECTIONS_PER_USER = 5;
|
|
@@ -162,6 +201,12 @@ export interface LocalServer {
|
|
|
162
201
|
* one client is replayable by another. Exposed for test assertions.
|
|
163
202
|
*/
|
|
164
203
|
readonly messages: MessageStore;
|
|
204
|
+
/**
|
|
205
|
+
* The per-process nick-history store backing `WHOWAS`. Shared across
|
|
206
|
+
* every connection so a nick signed off by one client is queryable by
|
|
207
|
+
* another. Exposed for test assertions.
|
|
208
|
+
*/
|
|
209
|
+
readonly history: NickHistoryStore;
|
|
165
210
|
close(): Promise<void>;
|
|
166
211
|
/**
|
|
167
212
|
* Test-only seam: live server-side WebSockets. Exposed so tests can
|
|
@@ -208,7 +253,7 @@ const DEFAULT_CONFIG: Omit<
|
|
|
208
253
|
* the three generators once at module load, not per-connection. No reducer in
|
|
209
254
|
* the current codebase actually invokes `batchId` / `nonce` / `sessionId`;
|
|
210
255
|
* they are forward-looking seams for SASL / BATCH / context-restoration
|
|
211
|
-
*
|
|
256
|
+
* and are exercised by irc-core's own unit tests via test fakes.
|
|
212
257
|
*
|
|
213
258
|
* Exported so `apps/local-cli` can ship a focused unit test asserting each
|
|
214
259
|
* generator returns a fresh UUID-shaped string (defending against future
|
|
@@ -304,6 +349,10 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
304
349
|
const runtime = new InMemoryRuntime({
|
|
305
350
|
clock: { now: () => Date.now() },
|
|
306
351
|
...(admission !== undefined ? { admission } : {}),
|
|
352
|
+
// REHASH reload source: re-derive the live ServerConfig from the
|
|
353
|
+
// resolved config so an oper's `REHASH` re-fetches the latest values
|
|
354
|
+
// (rotated oper creds, MOTD, limits) without restarting the process.
|
|
355
|
+
configLoader: async () => serverConfigFromResolved(cfg),
|
|
307
356
|
});
|
|
308
357
|
|
|
309
358
|
// Per-process chat-history store: one ring buffer shared by every
|
|
@@ -311,6 +360,12 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
311
360
|
// is replayable when bob queries CHATHISTORY). The cap is config-driven.
|
|
312
361
|
const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
|
|
313
362
|
|
|
363
|
+
// Per-process nick-history store: one TTL-buffer shared by every
|
|
364
|
+
// connection so cross-connection WHOWAS works (a nick alice quits is
|
|
365
|
+
// queryable by bob). Driven by the real-system clock so TTL eviction
|
|
366
|
+
// matches wall time.
|
|
367
|
+
const history = new InMemoryNickHistoryStore({ now: () => Date.now() });
|
|
368
|
+
|
|
314
369
|
// SASL account store: seeded from config so `AUTHENTICATE PLAIN` works
|
|
315
370
|
// end-to-end. `undefined` (no accounts configured) leaves the actor's
|
|
316
371
|
// `ctx.accounts` unset, preserving the no-store behaviour.
|
|
@@ -319,8 +374,23 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
319
374
|
? new InMemoryAccountStore(cfg.saslAccounts)
|
|
320
375
|
: undefined;
|
|
321
376
|
|
|
322
|
-
const wss = new WebSocketServer({
|
|
377
|
+
const wss = new WebSocketServer({
|
|
378
|
+
port: opts.port,
|
|
379
|
+
host: hostname,
|
|
380
|
+
// IRCv3 WebSocket subprotocol negotiation: select the first supported
|
|
381
|
+
// offer in client-preference order (core selector), or fall through to
|
|
382
|
+
// legacy framing when none match. The `ws` library only invokes this
|
|
383
|
+
// when the client offered protocols; returning the selected name echoes
|
|
384
|
+
// it, returning false completes the handshake with no
|
|
385
|
+
// Sec-WebSocket-Protocol header (legacy) — it never rejects.
|
|
386
|
+
handleProtocols: (protocols: Set<string>): string | false =>
|
|
387
|
+
selectSubprotocol([...protocols]) ?? false,
|
|
388
|
+
});
|
|
323
389
|
const wsConnections = new Map<WebSocket, ConnectionBindings>();
|
|
390
|
+
// Per-connection negotiated IRCv3 subprotocol registry (null = legacy
|
|
391
|
+
// framing). Mirrors the selection made in `handleProtocols`; the resolved
|
|
392
|
+
// frame mode is also captured in the per-connection handlers below.
|
|
393
|
+
const wsModes = new Map<WebSocket, WsSubprotocol | null>();
|
|
324
394
|
|
|
325
395
|
wss.on('connection', (ws, req) => {
|
|
326
396
|
// Admission gate: refuse the connection BEFORE we attach an actor / emit
|
|
@@ -336,18 +406,50 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
336
406
|
const admissionRecordId = decision.recordId;
|
|
337
407
|
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
338
408
|
|
|
339
|
-
|
|
409
|
+
// Resolve the negotiated IRCv3 subprotocol (echoed by handleProtocols)
|
|
410
|
+
// and derive the frame mode. `ws.protocol` is '' when none negotiated.
|
|
411
|
+
const subproto: WsSubprotocol | null =
|
|
412
|
+
ws.protocol === WS_SUBPROTO_TEXT || ws.protocol === WS_SUBPROTO_BINARY
|
|
413
|
+
? (ws.protocol as WsSubprotocol)
|
|
414
|
+
: null;
|
|
415
|
+
wsModes.set(ws, subproto);
|
|
416
|
+
const frameMode = wsFrameModeFor(subproto ?? undefined);
|
|
417
|
+
|
|
418
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
|
|
340
419
|
sendText: (text) => {
|
|
341
|
-
if (ws.readyState
|
|
420
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
421
|
+
// binary.ircv3.net connections exchange binary WebSocket frames;
|
|
422
|
+
// text/legacy connections send UTF-8 text frames.
|
|
423
|
+
ws.send(frameMode === 'spec-binary' ? Buffer.from(text, 'utf8') : text);
|
|
342
424
|
},
|
|
343
425
|
closeTransport: () => ws.close(),
|
|
344
426
|
admissionRecordId,
|
|
345
427
|
sourceHost: ip,
|
|
428
|
+
frameMode,
|
|
429
|
+
// Spec-mode framing: one IRC message per WS message (strip a single
|
|
430
|
+
// trailing CR-LF, never split on an embedded line break). Legacy
|
|
431
|
+
// connections fall through to the actor's default WsTextFrameTransport.
|
|
432
|
+
...(frameMode === 'legacy'
|
|
433
|
+
? {}
|
|
434
|
+
: { actorTransport: { feed: (chunk: string) => frameToLines(chunk, frameMode) } }),
|
|
346
435
|
});
|
|
347
436
|
wsConnections.set(ws, { state, actor });
|
|
348
437
|
|
|
349
438
|
ws.on('message', (data) => {
|
|
350
439
|
const text = data.toString('utf8');
|
|
440
|
+
// Spec-mode inbound: enforce the IRCv3 510-byte message budget and
|
|
441
|
+
// sanitize lone surrogates for text frames before framing. Legacy
|
|
442
|
+
// frames pass through unchanged (the 512-byte line cap is enforced in
|
|
443
|
+
// the parser, as before).
|
|
444
|
+
if (frameMode === 'spec-text' || frameMode === 'spec-binary') {
|
|
445
|
+
if (!isWithinWsByteBudget(text)) {
|
|
446
|
+
ws.close(1009, 'Message Too Big');
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const inbound = frameMode === 'spec-text' ? sanitizeForTextMode(text) : text;
|
|
450
|
+
actor.receiveTextFrame(inbound).catch((err) => logError('actor failure', err));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
351
453
|
// Fire-and-forget: the actor's reducer pipeline is synchronous through
|
|
352
454
|
// to dispatch, and dispatch awaits each runtime call in order. Errors
|
|
353
455
|
// propagate to the catch here and are logged (never crash the server).
|
|
@@ -362,6 +464,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
362
464
|
// admission record (if any) so per-IP / per-user counters stay accurate.
|
|
363
465
|
const cleanup = (): void => {
|
|
364
466
|
wsConnections.delete(ws);
|
|
467
|
+
wsModes.delete(ws);
|
|
365
468
|
runtime.unregisterConnection(state.id);
|
|
366
469
|
};
|
|
367
470
|
ws.on('close', cleanup);
|
|
@@ -413,7 +516,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
413
516
|
const admissionRecordId = decision.recordId;
|
|
414
517
|
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
415
518
|
|
|
416
|
-
const { state, actor } = attachConnection(runtime, cfg, messages, accounts, {
|
|
519
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
|
|
417
520
|
sendText: (text) => {
|
|
418
521
|
if (socket.writable) socket.write(text);
|
|
419
522
|
},
|
|
@@ -478,6 +581,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
478
581
|
hostname,
|
|
479
582
|
runtime,
|
|
480
583
|
messages,
|
|
584
|
+
history,
|
|
481
585
|
testSockets: wss.clients,
|
|
482
586
|
testTcpSockets: tcpSockets,
|
|
483
587
|
close: () =>
|
|
@@ -506,11 +610,26 @@ function attachConnection(
|
|
|
506
610
|
cfg: ResolvedServerConfig,
|
|
507
611
|
messages: MessageStore,
|
|
508
612
|
accounts: AccountStore | undefined,
|
|
613
|
+
history: NickHistoryStore,
|
|
509
614
|
transport: {
|
|
510
615
|
sendText: (text: string) => void;
|
|
511
616
|
closeTransport: () => void;
|
|
512
617
|
admissionRecordId?: string;
|
|
513
618
|
sourceHost?: string;
|
|
619
|
+
/**
|
|
620
|
+
* Negotiated IRCv3 WebSocket frame mode for this connection. Spec modes
|
|
621
|
+
* (`spec-text` / `spec-binary`) switch outbound delivery to one WS
|
|
622
|
+
* message per IRC line with no trailing CR-LF (and surrogate
|
|
623
|
+
* sanitization for text). `undefined` / `legacy` keeps the original
|
|
624
|
+
* `\r\n`-joined batch. TCP connections never set this.
|
|
625
|
+
*/
|
|
626
|
+
frameMode?: WsFrameMode;
|
|
627
|
+
/**
|
|
628
|
+
* Inbound line-framing seam forwarded to the actor's `Transport`. Only
|
|
629
|
+
* set for spec-mode WebSocket connections (one IRC line per frame);
|
|
630
|
+
* when omitted the actor defaults to its WS-text-frame transport.
|
|
631
|
+
*/
|
|
632
|
+
actorTransport?: Transport;
|
|
514
633
|
},
|
|
515
634
|
): ConnectionBindings {
|
|
516
635
|
const id = randomUUID();
|
|
@@ -531,40 +650,46 @@ function attachConnection(
|
|
|
531
650
|
state,
|
|
532
651
|
runtime,
|
|
533
652
|
channels: runtime,
|
|
534
|
-
serverConfig:
|
|
535
|
-
|
|
536
|
-
networkName: cfg.networkName,
|
|
537
|
-
maxChannelsPerUser: cfg.maxChannelsPerUser,
|
|
538
|
-
maxTargetsPerCommand: cfg.maxTargetsPerCommand,
|
|
539
|
-
maxListEntries: cfg.maxListEntries,
|
|
540
|
-
nickLen: cfg.nickLen,
|
|
541
|
-
channelLen: cfg.channelLen,
|
|
542
|
-
topicLen: cfg.topicLen,
|
|
543
|
-
quitMessage: cfg.quitMessage,
|
|
544
|
-
...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
|
|
545
|
-
...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
|
|
546
|
-
},
|
|
653
|
+
serverConfig: serverConfigFromResolved(cfg),
|
|
654
|
+
configSource: 'config file',
|
|
547
655
|
clock: { now: () => Date.now() },
|
|
548
656
|
ids: DEFAULT_ID_FACTORY,
|
|
549
657
|
motd: { lines: () => cfg.motdLines },
|
|
550
658
|
messages,
|
|
551
659
|
...(accounts !== undefined ? { accounts } : {}),
|
|
660
|
+
history,
|
|
552
661
|
logger,
|
|
662
|
+
...(transport.actorTransport !== undefined ? { transport: transport.actorTransport } : {}),
|
|
553
663
|
});
|
|
554
664
|
|
|
555
665
|
runtime.registerConnection(
|
|
556
666
|
state,
|
|
557
667
|
{
|
|
558
668
|
send: (lines: RawLine[]) => {
|
|
559
|
-
const
|
|
560
|
-
|
|
669
|
+
const fm = transport.frameMode;
|
|
670
|
+
if (fm === 'spec-text' || fm === 'spec-binary') {
|
|
671
|
+
// Spec mode: one WebSocket message per IRC line, no trailing
|
|
672
|
+
// CR-LF. Sanitize lone surrogates for text frames (they have no
|
|
673
|
+
// valid UTF-8 encoding); binary frames pass through verbatim.
|
|
674
|
+
for (const l of lines) {
|
|
675
|
+
transport.sendText(fm === 'spec-text' ? sanitizeForTextMode(l.text) : l.text);
|
|
676
|
+
}
|
|
677
|
+
} else {
|
|
678
|
+
const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
|
|
679
|
+
transport.sendText(text);
|
|
680
|
+
}
|
|
561
681
|
},
|
|
562
682
|
disconnect: (reason?: string) => {
|
|
563
683
|
// If a reason was supplied, send the RFC-style ERROR notice before
|
|
564
684
|
// closing. (QUIT does not supply one; future paths like flood
|
|
565
|
-
// control will.)
|
|
685
|
+
// control will.) In spec mode the notice is its own message with no
|
|
686
|
+
// trailing CR-LF.
|
|
566
687
|
if (reason !== undefined) {
|
|
567
|
-
|
|
688
|
+
const line = `ERROR :Closing link: (${reason})`;
|
|
689
|
+
const fm = transport.frameMode;
|
|
690
|
+
if (fm === 'spec-text') transport.sendText(sanitizeForTextMode(line));
|
|
691
|
+
else if (fm === 'spec-binary') transport.sendText(line);
|
|
692
|
+
else transport.sendText(`${line}\r\n`);
|
|
568
693
|
}
|
|
569
694
|
transport.closeTransport();
|
|
570
695
|
},
|
|
@@ -641,4 +766,4 @@ function stringifyErr(err: unknown): unknown {
|
|
|
641
766
|
}
|
|
642
767
|
|
|
643
768
|
// Suppress "ChanName imported but unused" — kept for type re-export ergonomics.
|
|
644
|
-
export type { ChanName, ConnectionState };
|
|
769
|
+
export type { ChanName, ConnectionState, NickHistoryStore };
|
|
@@ -330,7 +330,7 @@ describe('local-cli error paths', () => {
|
|
|
330
330
|
// 'error' followed by 'close', and the cleanup handler unregisters the
|
|
331
331
|
// connection from the runtime. (The nick itself is released by the
|
|
332
332
|
// QUIT reducer on graceful shutdown; abrupt termination leaks the nick
|
|
333
|
-
// pending a future gone-connection sweep
|
|
333
|
+
// pending a future gone-connection sweep.)
|
|
334
334
|
client.ws.terminate();
|
|
335
335
|
await new Promise((r) => setTimeout(r, 100));
|
|
336
336
|
expect(srv.runtime.hasConnection(connId as string)).toBe(false);
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
3
|
+
import { WebSocket } from 'ws';
|
|
4
|
+
import { type LocalServer, startLocalServer } from '../src/server';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* IRCv3 WebSocket subprotocol negotiation + per-message framing e2e.
|
|
8
|
+
*
|
|
9
|
+
* A spec-mode (`text.ircv3.net` / `binary.ircv3.net`) connection MUST:
|
|
10
|
+
* - have its offered subprotocol echoed back in the handshake;
|
|
11
|
+
* - exchange exactly one IRC message per WebSocket message, with NO
|
|
12
|
+
* trailing CR-LF on either direction.
|
|
13
|
+
*
|
|
14
|
+
* A legacy (un-negotiated) connection keeps the original `\r\n`-batched
|
|
15
|
+
* framing, so the existing integration suite stays green.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A spec-mode client: one IRC line per WS message, no trailing CRLF. */
|
|
19
|
+
class SpecClient extends EventEmitter {
|
|
20
|
+
readonly ws: WebSocket;
|
|
21
|
+
readonly received: string[] = [];
|
|
22
|
+
|
|
23
|
+
constructor(url: string, protocols: string | string[]) {
|
|
24
|
+
super();
|
|
25
|
+
this.ws = new WebSocket(url, protocols);
|
|
26
|
+
// Each inbound WS message is exactly one IRC line in spec mode (no CRLF).
|
|
27
|
+
this.ws.on('message', (data) => {
|
|
28
|
+
const line = data.toString('utf8');
|
|
29
|
+
if (line.length > 0) {
|
|
30
|
+
this.received.push(line);
|
|
31
|
+
this.emit('line', line);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolves once the WS handshake completes. */
|
|
37
|
+
opened(): Promise<void> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
if (this.ws.readyState === WebSocket.OPEN) resolve();
|
|
40
|
+
else {
|
|
41
|
+
this.ws.once('open', () => resolve());
|
|
42
|
+
this.ws.once('error', reject);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Sends one bare IRC line as its own WS message (no trailing CRLF). */
|
|
48
|
+
send(line: string): Promise<void> {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
this.ws.send(line, (err) => (err ? reject(err) : resolve()));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Waits until a received line satisfies `predicate` (substring match). */
|
|
55
|
+
waitFor(predicate: (line: string) => boolean, timeoutMs = 1000): Promise<string> {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const timeout = setTimeout(
|
|
58
|
+
() => reject(new Error(`timeout; received: ${JSON.stringify(this.received)}`)),
|
|
59
|
+
timeoutMs,
|
|
60
|
+
);
|
|
61
|
+
const check = (line: string) => {
|
|
62
|
+
if (predicate(line)) {
|
|
63
|
+
clearTimeout(timeout);
|
|
64
|
+
this.off('line', check);
|
|
65
|
+
resolve(line);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
for (const line of this.received) {
|
|
69
|
+
if (predicate(line)) {
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
resolve(line);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
this.on('line', check);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
close(): Promise<void> {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
if (this.ws.readyState === WebSocket.CLOSED) {
|
|
82
|
+
resolve();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.ws.once('close', () => resolve());
|
|
86
|
+
this.ws.close();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('local-cli IRCv3 WebSocket subprotocol', () => {
|
|
92
|
+
let server: LocalServer;
|
|
93
|
+
|
|
94
|
+
beforeAll(async () => {
|
|
95
|
+
server = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterAll(async () => {
|
|
99
|
+
await server.close();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('echoes text.ircv3.net when offered', async () => {
|
|
103
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
|
|
104
|
+
await client.opened();
|
|
105
|
+
expect(client.ws.protocol).toBe('text.ircv3.net');
|
|
106
|
+
await client.close();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('echoes binary.ircv3.net when offered', async () => {
|
|
110
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'binary.ircv3.net');
|
|
111
|
+
await client.opened();
|
|
112
|
+
expect(client.ws.protocol).toBe('binary.ircv3.net');
|
|
113
|
+
await client.close();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('completes a full session over binary.ircv3.net (binary frames)', async () => {
|
|
117
|
+
// binary.ircv3.net exchanges binary WebSocket frames. The server emits
|
|
118
|
+
// binary outbound frames (Buffer) and decodes inbound as UTF-8; the
|
|
119
|
+
// round-trip must decode to valid IRC lines with no trailing CRLF.
|
|
120
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'binary.ircv3.net');
|
|
121
|
+
await client.opened();
|
|
122
|
+
await client.send('NICK spec-bin');
|
|
123
|
+
await client.send('USER spec-bin 0 * :Spec Bin');
|
|
124
|
+
const welcome = await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
125
|
+
expect(welcome).toContain('spec-bin');
|
|
126
|
+
|
|
127
|
+
await client.send('PING :bin-tok');
|
|
128
|
+
const pong = await client.waitFor((l) => l === 'PONG :bin-tok');
|
|
129
|
+
expect(pong).toBe('PONG :bin-tok');
|
|
130
|
+
|
|
131
|
+
// Every decoded outbound message is a single bare IRC line.
|
|
132
|
+
for (const line of client.received) {
|
|
133
|
+
expect(line.includes('\r')).toBe(false);
|
|
134
|
+
expect(line.includes('\n')).toBe(false);
|
|
135
|
+
}
|
|
136
|
+
await client.close();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('selects the first-listed supported subprotocol (client preference)', async () => {
|
|
140
|
+
// Client prefers binary over text; server honours the order.
|
|
141
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, [
|
|
142
|
+
'binary.ircv3.net',
|
|
143
|
+
'text.ircv3.net',
|
|
144
|
+
]);
|
|
145
|
+
await client.opened();
|
|
146
|
+
expect(client.ws.protocol).toBe('binary.ircv3.net');
|
|
147
|
+
await client.close();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('ignores unsupported offers and still selects a supported one', async () => {
|
|
151
|
+
// Client lists an unsupported protocol first, then a supported one. The
|
|
152
|
+
// server MUST select the supported entry, not blindly echo the first.
|
|
153
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, ['chat', 'text.ircv3.net']);
|
|
154
|
+
await client.opened();
|
|
155
|
+
expect(client.ws.protocol).toBe('text.ircv3.net');
|
|
156
|
+
await client.close();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('connects in legacy mode when no subprotocol is offered', async () => {
|
|
160
|
+
// A plain client (no Sec-WebSocket-Protocol) is the legacy path: the
|
|
161
|
+
// server does not negotiate and the existing integration suite keeps
|
|
162
|
+
// working unchanged.
|
|
163
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, []);
|
|
164
|
+
await client.opened();
|
|
165
|
+
expect(client.ws.protocol).toBe('');
|
|
166
|
+
await client.close();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('completes registration with one IRC message per WebSocket message (text mode)', async () => {
|
|
170
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
|
|
171
|
+
await client.opened();
|
|
172
|
+
// One bare line per WS message (no trailing CRLF).
|
|
173
|
+
await client.send('NICK spec-alice');
|
|
174
|
+
await client.send('USER spec-alice 0 * :Spec Alice');
|
|
175
|
+
const welcome = await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
176
|
+
expect(welcome).toContain('spec-alice');
|
|
177
|
+
|
|
178
|
+
// Every received message must be a single IRC line with no trailing CRLF
|
|
179
|
+
// and no embedded line break (one IRC message per WS message).
|
|
180
|
+
for (const line of client.received) {
|
|
181
|
+
expect(line.includes('\r')).toBe(false);
|
|
182
|
+
expect(line.includes('\n')).toBe(false);
|
|
183
|
+
}
|
|
184
|
+
await client.close();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('delivers each outbound line as a distinct WebSocket message in spec mode', async () => {
|
|
188
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
|
|
189
|
+
await client.opened();
|
|
190
|
+
await client.send('NICK spec-bob');
|
|
191
|
+
await client.send('USER spec-bob 0 * :Spec Bob');
|
|
192
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
193
|
+
|
|
194
|
+
// The welcome sequence is many numerics (001..005, 375/372/376). In spec
|
|
195
|
+
// mode each arrives as its own WS message; a legacy batch would have
|
|
196
|
+
// concatenated them with CRLF inside one message. Assert we observed
|
|
197
|
+
// several distinct messages.
|
|
198
|
+
expect(client.received.length).toBeGreaterThan(1);
|
|
199
|
+
await client.close();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('routes PING → PONG as a single-message frame in spec mode', async () => {
|
|
203
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
|
|
204
|
+
await client.opened();
|
|
205
|
+
await client.send('NICK spec-ping');
|
|
206
|
+
await client.send('USER spec-ping 0 * :Spec Ping');
|
|
207
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
208
|
+
|
|
209
|
+
await client.send('PING :spec-tok');
|
|
210
|
+
const pong = await client.waitFor((l) => l === 'PONG :spec-tok');
|
|
211
|
+
expect(pong).toBe('PONG :spec-tok');
|
|
212
|
+
await client.close();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('rejects an inbound message exceeding the 510-byte budget in spec mode', async () => {
|
|
216
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
|
|
217
|
+
await client.opened();
|
|
218
|
+
await client.send('NICK spec-budget');
|
|
219
|
+
await client.send('USER spec-budget 0 * :Spec Budget');
|
|
220
|
+
// Drain the entire registration burst (001..376) so delayed numerics
|
|
221
|
+
// don't pollute the post-overlong-message assertion below.
|
|
222
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 376 '));
|
|
223
|
+
|
|
224
|
+
client.received.length = 0;
|
|
225
|
+
// 511-byte payload: `PRIVMSG #x :` (12 bytes) + 499 chars = 511 total.
|
|
226
|
+
const overlong = `PRIVMSG #x :${'a'.repeat(499)}`;
|
|
227
|
+
expect(overlong.length).toBe(511);
|
|
228
|
+
await client.send(overlong);
|
|
229
|
+
// Give the server a moment to process + close. An over-budget message
|
|
230
|
+
// MUST NOT produce a PRIVMSG broadcast or numeric reply.
|
|
231
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
232
|
+
expect(client.received.length).toBe(0);
|
|
233
|
+
await client.close();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// A reducer-driven disconnect with a reason (e.g. flood control / oper
|
|
237
|
+
// kill) emits the RFC-style ERROR notice. In spec mode it MUST arrive as
|
|
238
|
+
// its own WebSocket message with no trailing CR-LF, for both text and
|
|
239
|
+
// binary negotiated connections.
|
|
240
|
+
for (const subproto of ['text.ircv3.net', 'binary.ircv3.net'] as const) {
|
|
241
|
+
it(`delivers the ERROR notice as a single message on a ${subproto} connection`, async () => {
|
|
242
|
+
const nick = subproto === 'text.ircv3.net' ? 'spec-disc-text' : 'spec-disc-bin';
|
|
243
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, subproto);
|
|
244
|
+
await client.opened();
|
|
245
|
+
await client.send(`NICK ${nick}`);
|
|
246
|
+
await client.send(`USER ${nick} 0 * :Spec Disc`);
|
|
247
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
248
|
+
|
|
249
|
+
const connId = await server.runtime.lookupNick(nick);
|
|
250
|
+
expect(connId).not.toBeNull();
|
|
251
|
+
await server.runtime.disconnect(connId as string, 'Excess Flood');
|
|
252
|
+
const notice = await client.waitFor((l) => l.startsWith('ERROR :Closing link:'));
|
|
253
|
+
expect(notice).toBe('ERROR :Closing link: (Excess Flood)');
|
|
254
|
+
await client.close();
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
});
|
package/package.json
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "serverless-ircd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
7
7
|
"engines": {
|
|
8
|
-
"node": ">=
|
|
8
|
+
"node": ">=24"
|
|
9
9
|
},
|
|
10
10
|
"devDependencies": {
|
|
11
|
-
"@biomejs/biome": "^1.9.
|
|
12
|
-
"@vitest/coverage-v8": "^4.1.
|
|
13
|
-
"fast-check": "^3.
|
|
14
|
-
"rimraf": "^6.
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
11
|
+
"@biomejs/biome": "^1.9.4",
|
|
12
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
13
|
+
"fast-check": "^3.23.2",
|
|
14
|
+
"rimraf": "^6.1.3",
|
|
15
|
+
"tsx": "^4.19.0",
|
|
16
|
+
"turbo": "^2.10.7",
|
|
17
|
+
"typescript": "^5.9.3",
|
|
18
|
+
"vite": "^7.3.6",
|
|
19
|
+
"vitest": "^4.1.10",
|
|
20
|
+
"@serverless-ircd/aws-adapter": "0.5.0"
|
|
19
21
|
},
|
|
20
22
|
"scripts": {
|
|
21
23
|
"build": "turbo run build",
|
|
@@ -33,6 +35,8 @@
|
|
|
33
35
|
"deploy:cf:staging": "pnpm --filter @serverless-ircd/cf-worker deploy:staging",
|
|
34
36
|
"deploy:cf:prod": "pnpm --filter @serverless-ircd/cf-worker deploy:prod",
|
|
35
37
|
"smoke:cf:staging": "pnpm --filter @serverless-ircd/cf-worker smoke:staging",
|
|
38
|
+
"deploy:cf-tcp:staging": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:staging",
|
|
39
|
+
"deploy:cf-tcp:prod": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:prod",
|
|
36
40
|
"deploy:aws:staging": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:staging",
|
|
37
41
|
"deploy:aws:prod": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:prod",
|
|
38
42
|
"smoke:aws:staging": "pnpm --filter @serverless-ircd/aws-stack smoke:staging"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/aws-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "AWS Lambda + DynamoDB adapter: AwsRuntime implementing IrcRuntime + $connect/$disconnect/$default handlers",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@serverless-ircd/irc-test-support": "workspace:*",
|
|
39
39
|
"@types/node": "^26.1.1",
|
|
40
40
|
"@vitest/coverage-v8": "^4.1.0",
|
|
41
|
-
"aws-cdk-lib": "^2.
|
|
41
|
+
"aws-cdk-lib": "^2.262.0",
|
|
42
42
|
"aws-sdk-client-mock": "^4.1.0",
|
|
43
43
|
"constructs": "^10.4.0",
|
|
44
44
|
"rimraf": "^6.0.0",
|
|
@@ -47,6 +47,6 @@
|
|
|
47
47
|
"vitest": "^4.1.0"
|
|
48
48
|
},
|
|
49
49
|
"engines": {
|
|
50
|
-
"node": ">=
|
|
50
|
+
"node": ">=24"
|
|
51
51
|
}
|
|
52
52
|
}
|
|
@@ -28,8 +28,8 @@ import {
|
|
|
28
28
|
type ParsedServerConfig,
|
|
29
29
|
} from '@serverless-ircd/irc-core';
|
|
30
30
|
import {
|
|
31
|
-
type HashedAccountCredential,
|
|
32
31
|
DynamoAccountStore,
|
|
32
|
+
type HashedAccountCredential,
|
|
33
33
|
hashAccountCredential,
|
|
34
34
|
loadDynamoAccountStore,
|
|
35
35
|
} from './dynamo-account-store.js';
|