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
|
@@ -11,6 +11,7 @@ import { type DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/l
|
|
|
11
11
|
import { DEFAULT_SERVER_CONFIG } from '@serverless-ircd/irc-core';
|
|
12
12
|
import { describe, expect, it } from 'vitest';
|
|
13
13
|
import { handleConnect } from '../src/handlers/connect.js';
|
|
14
|
+
import type { MarshalledConnection } from '../src/serialize.js';
|
|
14
15
|
import type { TablesConfig } from '../src/tables.js';
|
|
15
16
|
|
|
16
17
|
const TABLES: TablesConfig = {
|
|
@@ -24,12 +25,15 @@ const TABLES: TablesConfig = {
|
|
|
24
25
|
interface Stub {
|
|
25
26
|
dynamo: DynamoDBDocumentClient;
|
|
26
27
|
puts: number;
|
|
28
|
+
/** Most recently PutCommand Item (undefined when no put happened). */
|
|
29
|
+
lastPutItem: Record<string, unknown> | undefined;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/** Builds a stub client that serves the supplied `Scan` pages in order. */
|
|
30
33
|
function makeStub(pages: Array<Record<string, unknown>>): Stub {
|
|
31
34
|
let scanIdx = 0;
|
|
32
35
|
const calls = { puts: 0 };
|
|
36
|
+
let lastPutItem: Record<string, unknown> | undefined;
|
|
33
37
|
const dynamo = {
|
|
34
38
|
send: async (cmd: unknown) => {
|
|
35
39
|
if (cmd instanceof ScanCommand) {
|
|
@@ -37,6 +41,7 @@ function makeStub(pages: Array<Record<string, unknown>>): Stub {
|
|
|
37
41
|
}
|
|
38
42
|
if (cmd instanceof PutCommand) {
|
|
39
43
|
calls.puts++;
|
|
44
|
+
lastPutItem = cmd.input.Item as Record<string, unknown> | undefined;
|
|
40
45
|
return {};
|
|
41
46
|
}
|
|
42
47
|
throw new Error('unexpected command');
|
|
@@ -47,6 +52,9 @@ function makeStub(pages: Array<Record<string, unknown>>): Stub {
|
|
|
47
52
|
get puts() {
|
|
48
53
|
return calls.puts;
|
|
49
54
|
},
|
|
55
|
+
get lastPutItem() {
|
|
56
|
+
return lastPutItem;
|
|
57
|
+
},
|
|
50
58
|
};
|
|
51
59
|
}
|
|
52
60
|
|
|
@@ -60,7 +68,7 @@ describe('handleConnect admission plumbing', () => {
|
|
|
60
68
|
now: 42,
|
|
61
69
|
serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
|
|
62
70
|
});
|
|
63
|
-
expect(outcome).toEqual({ admitted: true });
|
|
71
|
+
expect(outcome).toEqual({ admitted: true, subprotocol: null });
|
|
64
72
|
expect(stub.puts).toBe(1);
|
|
65
73
|
});
|
|
66
74
|
|
|
@@ -76,3 +84,91 @@ describe('handleConnect admission plumbing', () => {
|
|
|
76
84
|
expect(stub.puts).toBe(0);
|
|
77
85
|
});
|
|
78
86
|
});
|
|
87
|
+
|
|
88
|
+
describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
|
|
89
|
+
it('selects binary.ircv3.net, echoes it, and persists a spec-binary mode row', async () => {
|
|
90
|
+
const stub = makeStub([{ Count: 0 }]);
|
|
91
|
+
const outcome = await handleConnect({
|
|
92
|
+
dynamo: stub.dynamo,
|
|
93
|
+
tables: TABLES,
|
|
94
|
+
connectionId: 'c-binary',
|
|
95
|
+
now: 42,
|
|
96
|
+
secWebSocketProtocol: 'binary.ircv3.net',
|
|
97
|
+
serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
|
|
98
|
+
});
|
|
99
|
+
expect(outcome).toEqual({ admitted: true, subprotocol: 'binary.ircv3.net' });
|
|
100
|
+
expect(stub.puts).toBe(1);
|
|
101
|
+
const item = stub.lastPutItem as MarshalledConnection | undefined;
|
|
102
|
+
expect(item?.wsMode).toBe('spec-binary');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('selects text.ircv3.net and persists a spec-text mode row', async () => {
|
|
106
|
+
const stub = makeStub([{ Count: 0 }]);
|
|
107
|
+
const outcome = await handleConnect({
|
|
108
|
+
dynamo: stub.dynamo,
|
|
109
|
+
tables: TABLES,
|
|
110
|
+
connectionId: 'c-text',
|
|
111
|
+
now: 42,
|
|
112
|
+
secWebSocketProtocol: 'text.ircv3.net',
|
|
113
|
+
serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
|
|
114
|
+
});
|
|
115
|
+
expect(outcome).toEqual({ admitted: true, subprotocol: 'text.ircv3.net' });
|
|
116
|
+
const item = stub.lastPutItem as MarshalledConnection | undefined;
|
|
117
|
+
expect(item?.wsMode).toBe('spec-text');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('honours client-preference order when both subprotocols are offered', async () => {
|
|
121
|
+
const stub = makeStub([{ Count: 0 }]);
|
|
122
|
+
const outcome = await handleConnect({
|
|
123
|
+
dynamo: stub.dynamo,
|
|
124
|
+
tables: TABLES,
|
|
125
|
+
connectionId: 'c-pref',
|
|
126
|
+
now: 42,
|
|
127
|
+
secWebSocketProtocol: 'text.ircv3.net, binary.ircv3.net',
|
|
128
|
+
serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
|
|
129
|
+
});
|
|
130
|
+
expect(outcome).toEqual({ admitted: true, subprotocol: 'text.ircv3.net' });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('falls back to legacy (null subprotocol, no mode column) when no offer is present', async () => {
|
|
134
|
+
const stub = makeStub([{ Count: 0 }]);
|
|
135
|
+
const outcome = await handleConnect({
|
|
136
|
+
dynamo: stub.dynamo,
|
|
137
|
+
tables: TABLES,
|
|
138
|
+
connectionId: 'c-legacy',
|
|
139
|
+
now: 42,
|
|
140
|
+
serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
|
|
141
|
+
});
|
|
142
|
+
expect(outcome).toEqual({ admitted: true, subprotocol: null });
|
|
143
|
+
const item = stub.lastPutItem as MarshalledConnection | undefined;
|
|
144
|
+
expect(item?.wsMode).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('falls back to legacy when only unsupported subprotocols are offered', async () => {
|
|
148
|
+
const stub = makeStub([{ Count: 0 }]);
|
|
149
|
+
const outcome = await handleConnect({
|
|
150
|
+
dynamo: stub.dynamo,
|
|
151
|
+
tables: TABLES,
|
|
152
|
+
connectionId: 'c-unsupported',
|
|
153
|
+
now: 42,
|
|
154
|
+
secWebSocketProtocol: 'chat, superchat.v2',
|
|
155
|
+
serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
|
|
156
|
+
});
|
|
157
|
+
expect(outcome).toEqual({ admitted: true, subprotocol: null });
|
|
158
|
+
const item = stub.lastPutItem as MarshalledConnection | undefined;
|
|
159
|
+
expect(item?.wsMode).toBeUndefined();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('does not negotiate a subprotocol when the admission gate rejects', async () => {
|
|
163
|
+
const stub = makeStub([{ Count: 5 }]);
|
|
164
|
+
const outcome = await handleConnect({
|
|
165
|
+
dynamo: stub.dynamo,
|
|
166
|
+
tables: TABLES,
|
|
167
|
+
connectionId: 'c-full',
|
|
168
|
+
secWebSocketProtocol: 'binary.ircv3.net',
|
|
169
|
+
serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
|
|
170
|
+
});
|
|
171
|
+
expect(outcome).toEqual({ admitted: false, statusCode: 429, reason: 'server full' });
|
|
172
|
+
expect(stub.puts).toBe(0);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -96,6 +96,9 @@ async function makeFixture(): Promise<Fixture> {
|
|
|
96
96
|
motd: MOTD,
|
|
97
97
|
messages: bindMessageStore(SERVER_CONFIG),
|
|
98
98
|
managementApi: mgmt as never,
|
|
99
|
+
// Bind a REHASH reload source so the $default dispatch path exercises
|
|
100
|
+
// the configLoader wiring (and an oper's REHASH re-fetches config).
|
|
101
|
+
configLoader: async () => SERVER_CONFIG,
|
|
99
102
|
},
|
|
100
103
|
cleanup: async () => {
|
|
101
104
|
for (const logical of Object.keys(TABLE_DEFS)) {
|
|
@@ -142,6 +145,27 @@ describe.skipIf(!available)('handlers', () => {
|
|
|
142
145
|
expect(info?.registration).toBe('pre-registration');
|
|
143
146
|
});
|
|
144
147
|
|
|
148
|
+
it('echoes the agreed Sec-WebSocket-Protocol header back on a spec-mode connect', async () => {
|
|
149
|
+
const res = await dispatch(
|
|
150
|
+
{
|
|
151
|
+
requestContext: { routeKey: '$connect', connectionId: 'c-sub' },
|
|
152
|
+
headers: { 'Sec-WebSocket-Protocol': 'text.ircv3.net' },
|
|
153
|
+
},
|
|
154
|
+
requireFx().deps,
|
|
155
|
+
);
|
|
156
|
+
expect(res.statusCode).toBe(200);
|
|
157
|
+
expect(res.headers?.['Sec-WebSocket-Protocol']).toBe('text.ircv3.net');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('omits the Sec-WebSocket-Protocol header on a legacy connect', async () => {
|
|
161
|
+
const res = await dispatch(
|
|
162
|
+
{ requestContext: { routeKey: '$connect', connectionId: 'c-nosub' } },
|
|
163
|
+
requireFx().deps,
|
|
164
|
+
);
|
|
165
|
+
expect(res.statusCode).toBe(200);
|
|
166
|
+
expect(res.headers?.['Sec-WebSocket-Protocol']).toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
|
|
145
169
|
it('is idempotent on APIGW retry (same connectionId overwrites)', async () => {
|
|
146
170
|
for (let i = 0; i < 2; i++) {
|
|
147
171
|
await dispatch(
|
|
@@ -361,6 +385,95 @@ describe.skipIf(!available)('handlers', () => {
|
|
|
361
385
|
expect(sink).toContain('PONG :tok');
|
|
362
386
|
});
|
|
363
387
|
|
|
388
|
+
it('delivers each outbound line as its own postToConnection for a spec-mode connection', async () => {
|
|
389
|
+
await dispatch(
|
|
390
|
+
{
|
|
391
|
+
requestContext: { routeKey: '$connect', connectionId: 'spec1' },
|
|
392
|
+
headers: { 'Sec-WebSocket-Protocol': 'text.ircv3.net' },
|
|
393
|
+
},
|
|
394
|
+
requireFx().deps,
|
|
395
|
+
);
|
|
396
|
+
const calls: string[] = [];
|
|
397
|
+
requireFx().mgmt.register('spec1', (data) => {
|
|
398
|
+
calls.push(data);
|
|
399
|
+
});
|
|
400
|
+
// Spec clients send exactly one IRC message per WebSocket frame.
|
|
401
|
+
await dispatch(
|
|
402
|
+
{
|
|
403
|
+
requestContext: { routeKey: '$default', connectionId: 'spec1' },
|
|
404
|
+
body: 'NICK bob\r\n',
|
|
405
|
+
},
|
|
406
|
+
requireFx().deps,
|
|
407
|
+
);
|
|
408
|
+
await dispatch(
|
|
409
|
+
{
|
|
410
|
+
requestContext: { routeKey: '$default', connectionId: 'spec1' },
|
|
411
|
+
body: 'USER bob 0 * :Bob\r\n',
|
|
412
|
+
},
|
|
413
|
+
requireFx().deps,
|
|
414
|
+
);
|
|
415
|
+
// The welcome block is multiple IRC lines; the IRCv3 spec contract
|
|
416
|
+
// sends exactly one line per WebSocket message (no trailing CRLF).
|
|
417
|
+
expect(calls.length).toBeGreaterThan(1);
|
|
418
|
+
for (const data of calls) {
|
|
419
|
+
expect(data).not.toContain('\r\n');
|
|
420
|
+
}
|
|
421
|
+
expect(calls.some((d) => d.includes(' 001 '))).toBe(true);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('delivers one postToConnection per line for a binary.ircv3.net connection', async () => {
|
|
425
|
+
await dispatch(
|
|
426
|
+
{
|
|
427
|
+
requestContext: { routeKey: '$connect', connectionId: 'spec2' },
|
|
428
|
+
headers: { 'Sec-WebSocket-Protocol': 'binary.ircv3.net' },
|
|
429
|
+
},
|
|
430
|
+
requireFx().deps,
|
|
431
|
+
);
|
|
432
|
+
const calls: string[] = [];
|
|
433
|
+
requireFx().mgmt.register('spec2', (data) => {
|
|
434
|
+
calls.push(data);
|
|
435
|
+
});
|
|
436
|
+
await dispatch(
|
|
437
|
+
{
|
|
438
|
+
requestContext: { routeKey: '$default', connectionId: 'spec2' },
|
|
439
|
+
body: 'NICK binbob\r\n',
|
|
440
|
+
},
|
|
441
|
+
requireFx().deps,
|
|
442
|
+
);
|
|
443
|
+
await dispatch(
|
|
444
|
+
{
|
|
445
|
+
requestContext: { routeKey: '$default', connectionId: 'spec2' },
|
|
446
|
+
body: 'USER binbob 0 * :BinBob\r\n',
|
|
447
|
+
},
|
|
448
|
+
requireFx().deps,
|
|
449
|
+
);
|
|
450
|
+
expect(calls.length).toBeGreaterThan(1);
|
|
451
|
+
for (const data of calls) {
|
|
452
|
+
expect(data).not.toContain('\r\n');
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('batches outbound lines into a single postToConnection for a legacy connection', async () => {
|
|
457
|
+
await dispatch(
|
|
458
|
+
{ requestContext: { routeKey: '$connect', connectionId: 'leg1' } },
|
|
459
|
+
requireFx().deps,
|
|
460
|
+
);
|
|
461
|
+
const calls: string[] = [];
|
|
462
|
+
requireFx().mgmt.register('leg1', (data) => {
|
|
463
|
+
calls.push(data);
|
|
464
|
+
});
|
|
465
|
+
await dispatch(
|
|
466
|
+
{
|
|
467
|
+
requestContext: { routeKey: '$default', connectionId: 'leg1' },
|
|
468
|
+
body: 'NICK carol\r\nUSER carol 0 * :Carol\r\n',
|
|
469
|
+
},
|
|
470
|
+
requireFx().deps,
|
|
471
|
+
);
|
|
472
|
+
expect(calls.length).toBe(1);
|
|
473
|
+
expect(calls[0]).toContain('\r\n');
|
|
474
|
+
expect(calls[0]).toContain(' 001 ');
|
|
475
|
+
});
|
|
476
|
+
|
|
364
477
|
it('persists joined channels across $default invocations', async () => {
|
|
365
478
|
await dispatch(
|
|
366
479
|
{ requestContext: { routeKey: '$connect', connectionId: 'c1' } },
|
|
@@ -537,6 +650,41 @@ describe.skipIf(!available)('handlers', () => {
|
|
|
537
650
|
// The 500-branch is covered indirectly by GoneException tests.
|
|
538
651
|
expect(true).toBe(true);
|
|
539
652
|
});
|
|
653
|
+
|
|
654
|
+
it('runs an oper REHASH through the bound configLoader and emits 382', async () => {
|
|
655
|
+
await dispatch(
|
|
656
|
+
{ requestContext: { routeKey: '$connect', connectionId: 'rh1' } },
|
|
657
|
+
requireFx().deps,
|
|
658
|
+
);
|
|
659
|
+
const sink: string[] = [];
|
|
660
|
+
requireFx().mgmt.register('rh1', (data) => {
|
|
661
|
+
for (const line of data.split('\r\n')) {
|
|
662
|
+
if (line.length > 0) sink.push(line);
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
// Seed oper creds + a reload source so OPER then REHASH both succeed.
|
|
666
|
+
const depsWithOper: HandlerDeps = {
|
|
667
|
+
...requireFx().deps,
|
|
668
|
+
serverConfig: { ...SERVER_CONFIG, operCreds: [{ user: 'alice', password: 's3cret' }] },
|
|
669
|
+
configLoader: async () => ({
|
|
670
|
+
...SERVER_CONFIG,
|
|
671
|
+
operCreds: [{ user: 'alice', password: 'rotated' }],
|
|
672
|
+
}),
|
|
673
|
+
};
|
|
674
|
+
await dispatch(
|
|
675
|
+
{
|
|
676
|
+
requestContext: { routeKey: '$default', connectionId: 'rh1' },
|
|
677
|
+
body: 'NICK alice\r\nUSER alice 0 * :Alice\r\nOPER alice s3cret\r\nREHASH\r\n',
|
|
678
|
+
},
|
|
679
|
+
depsWithOper,
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
// 381 (oper) + 382 (rehashing from the Secrets Manager source) emitted.
|
|
683
|
+
expect(sink.some((l) => l.includes(' 381 '))).toBe(true);
|
|
684
|
+
expect(sink.some((l) => l.includes(' 382 ') && l.includes('Secrets Manager'))).toBe(true);
|
|
685
|
+
// Never a 421 for REHASH.
|
|
686
|
+
expect(sink.some((l) => l.includes(' 421 ') && l.includes('REHASH'))).toBe(false);
|
|
687
|
+
});
|
|
540
688
|
});
|
|
541
689
|
|
|
542
690
|
// -------------------------------------------------------------------------
|
|
@@ -151,6 +151,8 @@ describe.skipIf(!available)('NLB stream handler — byte framing', () => {
|
|
|
151
151
|
motd: MOTD,
|
|
152
152
|
messages: bindMessageStore(SERVER_CONFIG),
|
|
153
153
|
managementApi: f.mgmt,
|
|
154
|
+
// Bind a reload source so the configLoader wiring is exercised.
|
|
155
|
+
configLoader: async () => SERVER_CONFIG,
|
|
154
156
|
});
|
|
155
157
|
expect(res.statusCode).toBe(200);
|
|
156
158
|
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
2
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
3
|
+
import { type Clock, createConnection } from '@serverless-ircd/irc-core';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { AwsRuntime } from '../src/aws-runtime.js';
|
|
6
|
+
import type { AwsRuntimeHandlers } from '../src/aws-runtime.js';
|
|
7
|
+
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
8
|
+
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
9
|
+
import { AwsStats } from '../src/stats.js';
|
|
10
|
+
import type { TablesConfig } from '../src/tables.js';
|
|
11
|
+
|
|
12
|
+
const available = process.env.DDB_AVAILABLE === '1';
|
|
13
|
+
const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
14
|
+
|
|
15
|
+
interface Fixture {
|
|
16
|
+
tables: TablesConfig;
|
|
17
|
+
client: ReturnType<typeof createDynamoDocumentClient>;
|
|
18
|
+
cleanup: () => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let fx: Fixture;
|
|
22
|
+
|
|
23
|
+
async function makeFixture(): Promise<Fixture> {
|
|
24
|
+
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
25
|
+
const prefix = `t${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
|
|
26
|
+
const tables = Object.fromEntries(
|
|
27
|
+
Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
|
|
28
|
+
) as TablesConfig;
|
|
29
|
+
const client = createDynamoDocumentClient({ endpoint });
|
|
30
|
+
for (const [logical, props] of Object.entries(TABLE_DEFS)) {
|
|
31
|
+
const pkName = props.partitionKey?.name;
|
|
32
|
+
const skName = props.sortKey?.name;
|
|
33
|
+
if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
|
|
34
|
+
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
|
|
35
|
+
{ AttributeName: pkName, AttributeType: 'S' },
|
|
36
|
+
];
|
|
37
|
+
const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
38
|
+
{ AttributeName: pkName, KeyType: 'HASH' },
|
|
39
|
+
];
|
|
40
|
+
if (skName !== undefined) {
|
|
41
|
+
attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
|
|
42
|
+
keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
|
|
43
|
+
}
|
|
44
|
+
await client.send(
|
|
45
|
+
new CreateTableCommand({
|
|
46
|
+
TableName: `${prefix}${logical}`,
|
|
47
|
+
AttributeDefinitions: attributeDefinitions,
|
|
48
|
+
KeySchema: keySchema,
|
|
49
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
tables,
|
|
55
|
+
client,
|
|
56
|
+
cleanup: async () => {
|
|
57
|
+
for (const logical of Object.keys(TABLE_DEFS)) {
|
|
58
|
+
await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function noopHandlers(): AwsRuntimeHandlers {
|
|
65
|
+
return {
|
|
66
|
+
send: () => {},
|
|
67
|
+
disconnect: () => {},
|
|
68
|
+
snapshot: () => undefined,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function makeRuntime(connId: string, clockFn: () => number = () => 1_000): AwsRuntime {
|
|
73
|
+
const clock: Clock = { now: clockFn };
|
|
74
|
+
return new AwsRuntime({
|
|
75
|
+
dynamo: fx.client,
|
|
76
|
+
tables: fx.tables,
|
|
77
|
+
connId,
|
|
78
|
+
handlers: noopHandlers(),
|
|
79
|
+
managementApi: null,
|
|
80
|
+
clock,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe.skipIf(!available)('AwsStats', () => {
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
fx = await makeFixture();
|
|
87
|
+
});
|
|
88
|
+
afterEach(async () => {
|
|
89
|
+
if (fx) await fx.cleanup();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('returns zero counts for an empty deployment', async () => {
|
|
93
|
+
const stats = new AwsStats({
|
|
94
|
+
dynamo: fx.client,
|
|
95
|
+
tables: fx.tables,
|
|
96
|
+
uptimeStartedAt: 1_000,
|
|
97
|
+
});
|
|
98
|
+
const snap = await stats.getStats();
|
|
99
|
+
expect(snap.users).toBe(0);
|
|
100
|
+
expect(snap.channels).toBe(0);
|
|
101
|
+
expect(snap.servers).toBe(1);
|
|
102
|
+
expect(snap.localConns).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('counts registered connections (visible, invisible, opers)', async () => {
|
|
106
|
+
const rt = makeRuntime('self');
|
|
107
|
+
const a = createConnection({ id: 'c1', connectedSince: 0 });
|
|
108
|
+
a.nick = 'alice';
|
|
109
|
+
a.registration = 'registered';
|
|
110
|
+
const b = createConnection({ id: 'c2', connectedSince: 0 });
|
|
111
|
+
b.nick = 'bob';
|
|
112
|
+
b.registration = 'registered';
|
|
113
|
+
b.userModes.invisible = true;
|
|
114
|
+
const c = createConnection({ id: 'c3', connectedSince: 0 });
|
|
115
|
+
c.nick = 'carol';
|
|
116
|
+
c.registration = 'registered';
|
|
117
|
+
c.userModes.oper = true;
|
|
118
|
+
const d = createConnection({ id: 'c4', connectedSince: 0 });
|
|
119
|
+
d.registration = 'pre-registration';
|
|
120
|
+
await rt.persistConnectionState(a);
|
|
121
|
+
await rt.persistConnectionState(b);
|
|
122
|
+
await rt.persistConnectionState(c);
|
|
123
|
+
await rt.persistConnectionState(d);
|
|
124
|
+
|
|
125
|
+
const stats = new AwsStats({
|
|
126
|
+
dynamo: fx.client,
|
|
127
|
+
tables: fx.tables,
|
|
128
|
+
uptimeStartedAt: 1_000,
|
|
129
|
+
});
|
|
130
|
+
const snap = await stats.getStats();
|
|
131
|
+
// 3 registered (2 visible + 1 invisible), 1 oper, 1 unknown, 4 total.
|
|
132
|
+
expect(snap.users).toBe(2);
|
|
133
|
+
expect(snap.invisible).toBe(1);
|
|
134
|
+
expect(snap.opers).toBe(1);
|
|
135
|
+
expect(snap.unknownConnections).toBe(1);
|
|
136
|
+
expect(snap.localConns).toBe(4);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('counts channels via the ChannelMeta table', async () => {
|
|
140
|
+
const rt = makeRuntime('self');
|
|
141
|
+
await rt.applyChannelDelta('#a', {
|
|
142
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
|
|
143
|
+
});
|
|
144
|
+
await rt.applyChannelDelta('#b', {
|
|
145
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const stats = new AwsStats({
|
|
149
|
+
dynamo: fx.client,
|
|
150
|
+
tables: fx.tables,
|
|
151
|
+
uptimeStartedAt: 1_000,
|
|
152
|
+
});
|
|
153
|
+
const snap = await stats.getStats();
|
|
154
|
+
expect(snap.channels).toBe(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('echoes the supplied uptimeStartedAt', async () => {
|
|
158
|
+
const stats = new AwsStats({
|
|
159
|
+
dynamo: fx.client,
|
|
160
|
+
tables: fx.tables,
|
|
161
|
+
uptimeStartedAt: 7_777,
|
|
162
|
+
});
|
|
163
|
+
const snap = await stats.getStats();
|
|
164
|
+
expect(snap.uptimeStartedAt).toBe(7_777);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('honours supplied maxLocalConns / maxGlobalConns overrides', async () => {
|
|
168
|
+
const rt = makeRuntime('self');
|
|
169
|
+
const a = createConnection({ id: 'c1', connectedSince: 0 });
|
|
170
|
+
a.nick = 'alice';
|
|
171
|
+
a.registration = 'registered';
|
|
172
|
+
await rt.persistConnectionState(a);
|
|
173
|
+
|
|
174
|
+
const stats = new AwsStats({
|
|
175
|
+
dynamo: fx.client,
|
|
176
|
+
tables: fx.tables,
|
|
177
|
+
uptimeStartedAt: 1_000,
|
|
178
|
+
maxLocalConns: 99,
|
|
179
|
+
maxGlobalConns: 88,
|
|
180
|
+
});
|
|
181
|
+
const snap = await stats.getStats();
|
|
182
|
+
expect(snap.maxLocalConns).toBe(99);
|
|
183
|
+
expect(snap.maxGlobalConns).toBe(88);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Mock-based unit tests — exercise the defensive Scan fallbacks without
|
|
189
|
+
// requiring DynamoDB Local.
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Builds a stub DocumentClient whose `send` returns canned Scan output.
|
|
194
|
+
* Exercises the `?? []` and `?? 0` fallbacks in {@link AwsStats.getStats}
|
|
195
|
+
* that the DynamoDB-Local integration suite cannot reach (the SDK always
|
|
196
|
+
* returns defined arrays / counts there).
|
|
197
|
+
*
|
|
198
|
+
* Pass `connectionsItems: undefined` to simulate the SDK omitting the
|
|
199
|
+
* `Items` field entirely (the `?? []` fallback path).
|
|
200
|
+
*/
|
|
201
|
+
function stubDynamo(
|
|
202
|
+
connectionsItems: unknown[] | undefined,
|
|
203
|
+
channelCount: number | undefined,
|
|
204
|
+
): DynamoDBDocumentClient {
|
|
205
|
+
const calls: { TableName: string }[] = [];
|
|
206
|
+
const stub = {
|
|
207
|
+
send: async (cmd: {
|
|
208
|
+
input?: { TableName?: string; Select?: string };
|
|
209
|
+
TableName?: string;
|
|
210
|
+
Select?: string;
|
|
211
|
+
}) => {
|
|
212
|
+
// ScanCommand exposes its input via `.input`; both shapes are tolerated.
|
|
213
|
+
const input = (cmd as { input?: { TableName?: string; Select?: string } }).input ?? cmd;
|
|
214
|
+
const tableName = input.TableName ?? '';
|
|
215
|
+
const select = input.Select;
|
|
216
|
+
calls.push({ TableName: tableName });
|
|
217
|
+
void calls;
|
|
218
|
+
// Heuristic: the Connections scan omits `Select`, the ChannelMeta
|
|
219
|
+
// scan sets `Select: 'COUNT'`.
|
|
220
|
+
if (select === 'COUNT') {
|
|
221
|
+
return { Count: channelCount };
|
|
222
|
+
}
|
|
223
|
+
// Returning `{}` (no Items key) exercises the `?? []` fallback.
|
|
224
|
+
return connectionsItems === undefined ? {} : { Items: connectionsItems };
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
// Expose calls for any future assertions.
|
|
228
|
+
(stub as { _calls?: unknown })._calls = calls;
|
|
229
|
+
return stub as unknown as DynamoDBDocumentClient;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
describe('AwsStats — defensive Scan fallbacks (mocked client)', () => {
|
|
233
|
+
// These tests do NOT require DynamoDB Local — they exercise the
|
|
234
|
+
// `?? []` / `?? 0` fallbacks by feeding a stub client.
|
|
235
|
+
it('returns empty arrays when the Connections Scan yields no Items field', async () => {
|
|
236
|
+
const tables: TablesConfig = {
|
|
237
|
+
Connections: 'Connections',
|
|
238
|
+
ChannelMeta: 'ChannelMeta',
|
|
239
|
+
ChannelMembers: 'ChannelMembers',
|
|
240
|
+
Nicks: 'Nicks',
|
|
241
|
+
Accounts: 'Accounts',
|
|
242
|
+
};
|
|
243
|
+
// Pass `undefined` so the stub returns `{}` — no Items key — to
|
|
244
|
+
// exercise the `?? []` fallback on the Connections scan, and
|
|
245
|
+
// `undefined` for Count to exercise the `?? 0` fallback.
|
|
246
|
+
const dynamo = stubDynamo(undefined, undefined);
|
|
247
|
+
const stats = new AwsStats({ dynamo, tables, uptimeStartedAt: 1_000 });
|
|
248
|
+
const snap = await stats.getStats();
|
|
249
|
+
expect(snap.users).toBe(0);
|
|
250
|
+
expect(snap.unknownConnections).toBe(0);
|
|
251
|
+
expect(snap.channels).toBe(0);
|
|
252
|
+
expect(snap.localConns).toBe(0);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('honours maxLocalConns / maxGlobalConns overrides via the stub client', async () => {
|
|
256
|
+
const tables: TablesConfig = {
|
|
257
|
+
Connections: 'Connections',
|
|
258
|
+
ChannelMeta: 'ChannelMeta',
|
|
259
|
+
ChannelMembers: 'ChannelMembers',
|
|
260
|
+
Nicks: 'Nicks',
|
|
261
|
+
Accounts: 'Accounts',
|
|
262
|
+
};
|
|
263
|
+
const dynamo = stubDynamo([], 0);
|
|
264
|
+
const stats = new AwsStats({
|
|
265
|
+
dynamo,
|
|
266
|
+
tables,
|
|
267
|
+
uptimeStartedAt: 1_000,
|
|
268
|
+
maxLocalConns: 42,
|
|
269
|
+
maxGlobalConns: 43,
|
|
270
|
+
});
|
|
271
|
+
const snap = await stats.getStats();
|
|
272
|
+
expect(snap.maxLocalConns).toBe(42);
|
|
273
|
+
expect(snap.maxGlobalConns).toBe(43);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('counts connections returned by the stub client', async () => {
|
|
277
|
+
const tables: TablesConfig = {
|
|
278
|
+
Connections: 'Connections',
|
|
279
|
+
ChannelMeta: 'ChannelMeta',
|
|
280
|
+
ChannelMembers: 'ChannelMembers',
|
|
281
|
+
Nicks: 'Nicks',
|
|
282
|
+
Accounts: 'Accounts',
|
|
283
|
+
};
|
|
284
|
+
// Two registered connection rows.
|
|
285
|
+
const items = [
|
|
286
|
+
{
|
|
287
|
+
connectionId: 'c1',
|
|
288
|
+
registration: 'registered',
|
|
289
|
+
capNegotiating: false,
|
|
290
|
+
caps: [],
|
|
291
|
+
userModes: { invisible: false, oper: false, wallops: false, serverNotices: false },
|
|
292
|
+
lastSeen: 0,
|
|
293
|
+
connectedSince: 0,
|
|
294
|
+
idleSince: 0,
|
|
295
|
+
version: 1,
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
connectionId: 'c2',
|
|
299
|
+
registration: 'registered',
|
|
300
|
+
capNegotiating: false,
|
|
301
|
+
caps: [],
|
|
302
|
+
userModes: { invisible: true, oper: false, wallops: false, serverNotices: false },
|
|
303
|
+
lastSeen: 0,
|
|
304
|
+
connectedSince: 0,
|
|
305
|
+
idleSince: 0,
|
|
306
|
+
version: 1,
|
|
307
|
+
},
|
|
308
|
+
];
|
|
309
|
+
const dynamo = stubDynamo(items, 3);
|
|
310
|
+
const stats = new AwsStats({ dynamo, tables, uptimeStartedAt: 1_000 });
|
|
311
|
+
const snap = await stats.getStats();
|
|
312
|
+
expect(snap.users).toBe(1); // 2 registered − 1 invisible
|
|
313
|
+
expect(snap.invisible).toBe(1);
|
|
314
|
+
expect(snap.channels).toBe(3);
|
|
315
|
+
expect(snap.localConns).toBe(2);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/cf-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Cloudflare Workers adapter: Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and CfRuntime implementing IrcRuntime",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
".": {
|
|
12
12
|
"types": "./dist/index.d.ts",
|
|
13
13
|
"import": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./accounts": {
|
|
16
|
+
"types": "./dist/d1-account-store.d.ts",
|
|
17
|
+
"import": "./dist/d1-account-store.js"
|
|
14
18
|
}
|
|
15
19
|
},
|
|
16
20
|
"scripts": {
|