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
|
@@ -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
|
|
@@ -41,9 +46,19 @@ import {
|
|
|
41
46
|
type MessageStore,
|
|
42
47
|
type NickHistoryStore,
|
|
43
48
|
type RawLine,
|
|
49
|
+
type ServerConfig,
|
|
50
|
+
WS_SUBPROTO_BINARY,
|
|
51
|
+
WS_SUBPROTO_TEXT,
|
|
52
|
+
type WsFrameMode,
|
|
53
|
+
type WsSubprotocol,
|
|
44
54
|
createConnection,
|
|
55
|
+
frameToLines,
|
|
56
|
+
isWithinWsByteBudget,
|
|
57
|
+
sanitizeForTextMode,
|
|
58
|
+
selectSubprotocol,
|
|
59
|
+
wsFrameModeFor,
|
|
45
60
|
} from '@serverless-ircd/irc-core';
|
|
46
|
-
import { ConnectionActor } from '@serverless-ircd/irc-server';
|
|
61
|
+
import { ConnectionActor, type Transport } from '@serverless-ircd/irc-server';
|
|
47
62
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
48
63
|
import { loadServerConfigFromCliArgs } from './config-loader.js';
|
|
49
64
|
import { LineScanner } from './line-scanner.js';
|
|
@@ -73,6 +88,28 @@ type ResolvedServerConfig = Omit<
|
|
|
73
88
|
| 'saslAccounts'
|
|
74
89
|
>;
|
|
75
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
|
+
|
|
76
113
|
/** Default per-IP / per-user caps used when callers don't override. */
|
|
77
114
|
const DEFAULT_MAX_CONNECTIONS_PER_IP = 10;
|
|
78
115
|
const DEFAULT_MAX_CONNECTIONS_PER_USER = 5;
|
|
@@ -216,7 +253,7 @@ const DEFAULT_CONFIG: Omit<
|
|
|
216
253
|
* the three generators once at module load, not per-connection. No reducer in
|
|
217
254
|
* the current codebase actually invokes `batchId` / `nonce` / `sessionId`;
|
|
218
255
|
* they are forward-looking seams for SASL / BATCH / context-restoration
|
|
219
|
-
*
|
|
256
|
+
* and are exercised by irc-core's own unit tests via test fakes.
|
|
220
257
|
*
|
|
221
258
|
* Exported so `apps/local-cli` can ship a focused unit test asserting each
|
|
222
259
|
* generator returns a fresh UUID-shaped string (defending against future
|
|
@@ -312,6 +349,10 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
312
349
|
const runtime = new InMemoryRuntime({
|
|
313
350
|
clock: { now: () => Date.now() },
|
|
314
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),
|
|
315
356
|
});
|
|
316
357
|
|
|
317
358
|
// Per-process chat-history store: one ring buffer shared by every
|
|
@@ -333,8 +374,23 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
333
374
|
? new InMemoryAccountStore(cfg.saslAccounts)
|
|
334
375
|
: undefined;
|
|
335
376
|
|
|
336
|
-
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
|
+
});
|
|
337
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>();
|
|
338
394
|
|
|
339
395
|
wss.on('connection', (ws, req) => {
|
|
340
396
|
// Admission gate: refuse the connection BEFORE we attach an actor / emit
|
|
@@ -350,18 +406,50 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
350
406
|
const admissionRecordId = decision.recordId;
|
|
351
407
|
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
352
408
|
|
|
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
|
+
|
|
353
418
|
const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
|
|
354
419
|
sendText: (text) => {
|
|
355
|
-
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);
|
|
356
424
|
},
|
|
357
425
|
closeTransport: () => ws.close(),
|
|
358
426
|
admissionRecordId,
|
|
359
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) } }),
|
|
360
435
|
});
|
|
361
436
|
wsConnections.set(ws, { state, actor });
|
|
362
437
|
|
|
363
438
|
ws.on('message', (data) => {
|
|
364
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
|
+
}
|
|
365
453
|
// Fire-and-forget: the actor's reducer pipeline is synchronous through
|
|
366
454
|
// to dispatch, and dispatch awaits each runtime call in order. Errors
|
|
367
455
|
// propagate to the catch here and are logged (never crash the server).
|
|
@@ -376,6 +464,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
376
464
|
// admission record (if any) so per-IP / per-user counters stay accurate.
|
|
377
465
|
const cleanup = (): void => {
|
|
378
466
|
wsConnections.delete(ws);
|
|
467
|
+
wsModes.delete(ws);
|
|
379
468
|
runtime.unregisterConnection(state.id);
|
|
380
469
|
};
|
|
381
470
|
ws.on('close', cleanup);
|
|
@@ -527,6 +616,20 @@ function attachConnection(
|
|
|
527
616
|
closeTransport: () => void;
|
|
528
617
|
admissionRecordId?: string;
|
|
529
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;
|
|
530
633
|
},
|
|
531
634
|
): ConnectionBindings {
|
|
532
635
|
const id = randomUUID();
|
|
@@ -547,19 +650,8 @@ function attachConnection(
|
|
|
547
650
|
state,
|
|
548
651
|
runtime,
|
|
549
652
|
channels: runtime,
|
|
550
|
-
serverConfig:
|
|
551
|
-
|
|
552
|
-
networkName: cfg.networkName,
|
|
553
|
-
maxChannelsPerUser: cfg.maxChannelsPerUser,
|
|
554
|
-
maxTargetsPerCommand: cfg.maxTargetsPerCommand,
|
|
555
|
-
maxListEntries: cfg.maxListEntries,
|
|
556
|
-
nickLen: cfg.nickLen,
|
|
557
|
-
channelLen: cfg.channelLen,
|
|
558
|
-
topicLen: cfg.topicLen,
|
|
559
|
-
quitMessage: cfg.quitMessage,
|
|
560
|
-
...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
|
|
561
|
-
...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
|
|
562
|
-
},
|
|
653
|
+
serverConfig: serverConfigFromResolved(cfg),
|
|
654
|
+
configSource: 'config file',
|
|
563
655
|
clock: { now: () => Date.now() },
|
|
564
656
|
ids: DEFAULT_ID_FACTORY,
|
|
565
657
|
motd: { lines: () => cfg.motdLines },
|
|
@@ -567,21 +659,37 @@ function attachConnection(
|
|
|
567
659
|
...(accounts !== undefined ? { accounts } : {}),
|
|
568
660
|
history,
|
|
569
661
|
logger,
|
|
662
|
+
...(transport.actorTransport !== undefined ? { transport: transport.actorTransport } : {}),
|
|
570
663
|
});
|
|
571
664
|
|
|
572
665
|
runtime.registerConnection(
|
|
573
666
|
state,
|
|
574
667
|
{
|
|
575
668
|
send: (lines: RawLine[]) => {
|
|
576
|
-
const
|
|
577
|
-
|
|
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
|
+
}
|
|
578
681
|
},
|
|
579
682
|
disconnect: (reason?: string) => {
|
|
580
683
|
// If a reason was supplied, send the RFC-style ERROR notice before
|
|
581
684
|
// closing. (QUIT does not supply one; future paths like flood
|
|
582
|
-
// control will.)
|
|
685
|
+
// control will.) In spec mode the notice is its own message with no
|
|
686
|
+
// trailing CR-LF.
|
|
583
687
|
if (reason !== undefined) {
|
|
584
|
-
|
|
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`);
|
|
585
693
|
}
|
|
586
694
|
transport.closeTransport();
|
|
587
695
|
},
|
|
@@ -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,6 +1,6 @@
|
|
|
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",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"typescript": "^5.9.3",
|
|
18
18
|
"vite": "^7.3.6",
|
|
19
19
|
"vitest": "^4.1.10",
|
|
20
|
-
"@serverless-ircd/aws-adapter": "0.
|
|
20
|
+
"@serverless-ircd/aws-adapter": "0.5.0"
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
23
|
"build": "turbo run build",
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
type Nick,
|
|
52
52
|
type RawLine,
|
|
53
53
|
type RosterEntry,
|
|
54
|
+
type ServerConfig,
|
|
54
55
|
SystemClock,
|
|
55
56
|
emptyModes,
|
|
56
57
|
toSnapshot as toConnSnapshot,
|
|
@@ -119,6 +120,14 @@ export interface AwsRuntimeOptions {
|
|
|
119
120
|
*/
|
|
120
121
|
managementApi: ApiGatewayManagementApi | PostToConnection | null;
|
|
121
122
|
clock?: Clock;
|
|
123
|
+
/**
|
|
124
|
+
* Config-reload source for `REHASH`. The Lambda handler binds this to a
|
|
125
|
+
* closure that re-invokes {@link loadServerConfigFromLambdaEnv} against the
|
|
126
|
+
* invocation env (Secrets Manager / SSM / env vars). When omitted
|
|
127
|
+
* `reloadConfig` rejects so the actor applies a graceful error-suffixed
|
|
128
|
+
* `382` and retains the prior config.
|
|
129
|
+
*/
|
|
130
|
+
readonly configLoader?: () => Promise<ServerConfig>;
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
/**
|
|
@@ -133,6 +142,7 @@ export class AwsRuntime implements IrcRuntime {
|
|
|
133
142
|
private readonly handlers: AwsRuntimeHandlers;
|
|
134
143
|
private readonly managementApi: ApiGatewayManagementApi | PostToConnection | null;
|
|
135
144
|
private readonly clock: Clock;
|
|
145
|
+
private readonly configLoader: (() => Promise<ServerConfig>) | undefined;
|
|
136
146
|
|
|
137
147
|
constructor(opts: AwsRuntimeOptions) {
|
|
138
148
|
this.dynamo = opts.dynamo;
|
|
@@ -141,6 +151,7 @@ export class AwsRuntime implements IrcRuntime {
|
|
|
141
151
|
this.handlers = opts.handlers;
|
|
142
152
|
this.managementApi = opts.managementApi;
|
|
143
153
|
this.clock = opts.clock ?? SystemClock;
|
|
154
|
+
this.configLoader = opts.configLoader;
|
|
144
155
|
}
|
|
145
156
|
|
|
146
157
|
// -------------------------------------------------------------------------
|
|
@@ -210,6 +221,51 @@ export class AwsRuntime implements IrcRuntime {
|
|
|
210
221
|
}
|
|
211
222
|
}
|
|
212
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Global cross-connection fan-out for `WALLOPS`. `Scan`s the `Connections`
|
|
226
|
+
* table filtered to rows whose `userModes.wallops` is `true`, then
|
|
227
|
+
* `PostToConnection`s each one (skipping `except`). The bound connection's
|
|
228
|
+
* own delivery goes through the in-process handlers (no APIGW round-trip),
|
|
229
|
+
* mirroring `send`.
|
|
230
|
+
*
|
|
231
|
+
* Cost: a DynamoDB `Scan` reads the ENTIRE table (chargeable per row read,
|
|
232
|
+
* not per match), so this is expensive at high connection counts. A fleet
|
|
233
|
+
* of 10k connections costs one full scan per WALLOPS plus one
|
|
234
|
+
* `PostToConnection` per `+w` recipient. Deployments should bound the
|
|
235
|
+
* oper's WALLOPS rate; a projected GSI on `userModes.wallops` would lift
|
|
236
|
+
* the scan to a targeted query (documented follow-up). The scan uses
|
|
237
|
+
* `FilterExpression` so only `+w` rows survive to the `PostToConnection`
|
|
238
|
+
* phase — the APIGW call count is bounded by the genuine recipient set.
|
|
239
|
+
*/
|
|
240
|
+
async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
|
|
241
|
+
let startKey: Record<string, NativeAttributeValue> | undefined;
|
|
242
|
+
// Paginate the scan: DynamoDB caps a single Scan at 1 MB. Loop until
|
|
243
|
+
// no `LastEvaluatedKey` remains so every +w connection is reached.
|
|
244
|
+
do {
|
|
245
|
+
const result = await this.dynamo.send(
|
|
246
|
+
new ScanCommand({
|
|
247
|
+
TableName: this.tables.Connections,
|
|
248
|
+
FilterExpression: 'userModes.#m = :true',
|
|
249
|
+
ExpressionAttributeNames: { '#m': 'wallops' },
|
|
250
|
+
ExpressionAttributeValues: { ':true': true },
|
|
251
|
+
...(startKey !== undefined ? { ExclusiveStartKey: startKey } : {}),
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
const items = (result.Items ?? []) as unknown as MarshalledConnection[];
|
|
255
|
+
for (const row of items) {
|
|
256
|
+
const connId = row.connectionId;
|
|
257
|
+
if (connId === except) continue;
|
|
258
|
+
// `send` short-cuts the bound connection through the in-process
|
|
259
|
+
// handlers and catches GoneException so vanished sockets are
|
|
260
|
+
// cleaned up rather than aborting the fan-out mid-loop.
|
|
261
|
+
if (row.userModes.wallops) {
|
|
262
|
+
await this.send(connId, lines);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
startKey = result.LastEvaluatedKey as Record<string, NativeAttributeValue> | undefined;
|
|
266
|
+
} while (startKey !== undefined);
|
|
267
|
+
}
|
|
268
|
+
|
|
213
269
|
// -------------------------------------------------------------------------
|
|
214
270
|
// Nick registry
|
|
215
271
|
// -------------------------------------------------------------------------
|
|
@@ -365,6 +421,19 @@ export class AwsRuntime implements IrcRuntime {
|
|
|
365
421
|
return out;
|
|
366
422
|
}
|
|
367
423
|
|
|
424
|
+
/**
|
|
425
|
+
* Re-invokes the bound config loader (Secrets Manager / SSM / env) and
|
|
426
|
+
* returns the fresh {@link ServerConfig}. The Lambda handler binds the
|
|
427
|
+
* loader at construction; when no loader was supplied this rejects so the
|
|
428
|
+
* actor's graceful failure path applies.
|
|
429
|
+
*/
|
|
430
|
+
async reloadConfig(): Promise<ServerConfig> {
|
|
431
|
+
if (this.configLoader === undefined) {
|
|
432
|
+
throw new Error('no config loader bound to AwsRuntime');
|
|
433
|
+
}
|
|
434
|
+
return this.configLoader();
|
|
435
|
+
}
|
|
436
|
+
|
|
368
437
|
// -------------------------------------------------------------------------
|
|
369
438
|
// Public helpers (re-exported surface)
|
|
370
439
|
// -------------------------------------------------------------------------
|