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.
Files changed (124) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/README.md +88 -19
  3. package/apps/aws-stack/README.md +4 -3
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/aws-stack/src/aws-stack.ts +32 -4
  6. package/apps/aws-stack/tests/stack.test.ts +47 -1
  7. package/apps/cf-tcp-container/package.json +1 -1
  8. package/apps/cf-worker/package.json +1 -1
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/server.ts +129 -21
  11. package/apps/local-cli/tests/e2e.test.ts +1 -1
  12. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  13. package/package.json +2 -2
  14. package/packages/aws-adapter/package.json +1 -1
  15. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  16. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  17. package/packages/aws-adapter/src/handlers/default.ts +63 -5
  18. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  19. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  20. package/packages/aws-adapter/src/index.ts +2 -0
  21. package/packages/aws-adapter/src/serialize.ts +11 -1
  22. package/packages/aws-adapter/src/stats.ts +80 -0
  23. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  24. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  25. package/packages/aws-adapter/tests/connect.test.ts +97 -1
  26. package/packages/aws-adapter/tests/handlers.test.ts +148 -0
  27. package/packages/aws-adapter/tests/nlb-stream.test.ts +2 -0
  28. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  29. package/packages/cf-adapter/package.json +5 -1
  30. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  31. package/packages/cf-adapter/src/channel-do.ts +2 -2
  32. package/packages/cf-adapter/src/connection-do.ts +182 -54
  33. package/packages/cf-adapter/src/env.ts +25 -6
  34. package/packages/cf-adapter/src/index.ts +2 -0
  35. package/packages/cf-adapter/src/registry-do.ts +22 -3
  36. package/packages/cf-adapter/src/sharding.ts +1 -2
  37. package/packages/cf-adapter/src/stats.ts +65 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  41. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  42. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  43. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  44. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  45. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  46. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  47. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  48. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  49. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  50. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  51. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  52. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  53. package/packages/cf-adapter/wrangler.test.toml +7 -0
  54. package/packages/in-memory-runtime/package.json +1 -1
  55. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  56. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  57. package/packages/irc-core/package.json +1 -1
  58. package/packages/irc-core/src/admission.ts +16 -15
  59. package/packages/irc-core/src/caps/capabilities.ts +1 -1
  60. package/packages/irc-core/src/commands/index.ts +8 -0
  61. package/packages/irc-core/src/commands/invite.ts +2 -4
  62. package/packages/irc-core/src/commands/isupport.ts +6 -2
  63. package/packages/irc-core/src/commands/kick.ts +2 -4
  64. package/packages/irc-core/src/commands/kill.ts +127 -0
  65. package/packages/irc-core/src/commands/list.ts +1 -1
  66. package/packages/irc-core/src/commands/lusers.ts +204 -0
  67. package/packages/irc-core/src/commands/mode.ts +4 -8
  68. package/packages/irc-core/src/commands/names.ts +3 -5
  69. package/packages/irc-core/src/commands/part.ts +2 -4
  70. package/packages/irc-core/src/commands/rehash.ts +119 -0
  71. package/packages/irc-core/src/commands/setname.ts +109 -0
  72. package/packages/irc-core/src/commands/stats.ts +152 -0
  73. package/packages/irc-core/src/commands/topic.ts +2 -4
  74. package/packages/irc-core/src/commands/trace.ts +137 -0
  75. package/packages/irc-core/src/commands/wallops.ts +118 -0
  76. package/packages/irc-core/src/config.ts +7 -0
  77. package/packages/irc-core/src/effects.ts +27 -1
  78. package/packages/irc-core/src/index.ts +2 -0
  79. package/packages/irc-core/src/ports.ts +179 -0
  80. package/packages/irc-core/src/protocol/numerics.ts +42 -11
  81. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  82. package/packages/irc-core/src/types.ts +8 -1
  83. package/packages/irc-core/src/ws-framing.ts +132 -0
  84. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  85. package/packages/irc-core/tests/admission.test.ts +18 -0
  86. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  87. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  88. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  89. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  90. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  91. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  92. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  93. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  94. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  95. package/packages/irc-core/tests/effects.test.ts +14 -0
  96. package/packages/irc-core/tests/numerics.test.ts +90 -0
  97. package/packages/irc-core/tests/outbound.test.ts +51 -0
  98. package/packages/irc-core/tests/ports.test.ts +22 -0
  99. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  100. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  101. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  102. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  103. package/packages/irc-server/package.json +1 -1
  104. package/packages/irc-server/src/actor.ts +249 -16
  105. package/packages/irc-server/src/dispatch.ts +1 -0
  106. package/packages/irc-server/src/routing.ts +3 -0
  107. package/packages/irc-server/src/runtime.ts +31 -0
  108. package/packages/irc-server/src/transport.ts +10 -7
  109. package/packages/irc-server/tests/actor.test.ts +1089 -1
  110. package/packages/irc-server/tests/dispatch.test.ts +37 -0
  111. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  112. package/packages/irc-server/tests/routing.test.ts +1 -0
  113. package/packages/irc-server/tests/runtime.test.ts +7 -0
  114. package/packages/irc-test-support/package.json +1 -1
  115. package/packages/irc-test-support/src/scenarios.ts +9 -1
  116. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  117. package/pnpm-workspace.yaml +1 -0
  118. package/tools/ci-hardening/package.json +1 -1
  119. package/tools/package.json +6 -1
  120. package/tools/seed-cf-accounts.ts +4 -1
  121. package/tools/tcp-ws-forwarder/package.json +1 -1
  122. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  123. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  124. 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.4.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": {