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
@@ -14,7 +14,13 @@
14
14
 
15
15
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
16
16
  import { PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
17
- import type { ParsedServerConfig } from '@serverless-ircd/irc-core';
17
+ import {
18
+ type ParsedServerConfig,
19
+ type WsSubprotocol,
20
+ parseSecWsProtocolOffers,
21
+ selectSubprotocol,
22
+ wsFrameModeFor,
23
+ } from '@serverless-ircd/irc-core';
18
24
  import { type AdmissionOutcome, decideConnectAdmission } from '../admission.js';
19
25
  import { marshalConnection } from '../serialize.js';
20
26
  import type { TablesConfig } from '../tables.js';
@@ -29,10 +35,24 @@ export interface ConnectParams {
29
35
  now?: number;
30
36
  /** Server config — consulted for the `maxClients` admission cap. */
31
37
  serverConfig: ParsedServerConfig;
38
+ /**
39
+ * Raw `Sec-WebSocket-Protocol` header offered by the client on the
40
+ * `$connect` upgrade, if present. Parsed into an ordered offer list and
41
+ * the first supported IRCv3 subprotocol is agreed (legacy fallback when
42
+ * none match or the header is absent).
43
+ */
44
+ secWebSocketProtocol?: string | null;
32
45
  }
33
46
 
34
- /** Re-exported so the dispatcher imports the outcome type from one place. */
35
- export type { AdmissionOutcome as ConnectOutcome } from '../admission.js';
47
+ /**
48
+ * Outcome of a `$connect`. Mirrors {@link AdmissionOutcome} on the rejection
49
+ * branch and carries the negotiated IRCv3 {@link WsSubprotocol} on success so
50
+ * the dispatcher can echo it back to the client in the integration response.
51
+ * `subprotocol === null` signals a legacy (no-subprotocol) connection.
52
+ */
53
+ export type ConnectOutcome =
54
+ | { readonly admitted: true; readonly subprotocol: WsSubprotocol | null }
55
+ | { readonly admitted: false; readonly statusCode: number; readonly reason: string };
36
56
 
37
57
  /**
38
58
  * Enforces the `maxClients` admission cap, then inserts the Connections
@@ -58,20 +78,31 @@ export type { AdmissionOutcome as ConnectOutcome } from '../admission.js';
58
78
  * into {@link decideConnectAdmission}'s `perIp` branch. That schema
59
79
  * change is tracked as a follow-up; the pure policy already supports it.
60
80
  */
61
- export async function handleConnect(params: ConnectParams): Promise<AdmissionOutcome> {
81
+ export async function handleConnect(params: ConnectParams): Promise<ConnectOutcome> {
62
82
  const now = params.now ?? Date.now();
63
83
  const total = await countConnections(params.dynamo, params.tables.Connections);
64
84
  const outcome = decideConnectAdmission({ total }, { maxClients: params.serverConfig.maxClients });
65
85
  if (!outcome.admitted) return outcome;
86
+ // IRCv3 WebSocket subprotocol negotiation: parse the offered
87
+ // `Sec-WebSocket-Protocol` list, select the first supported entry, and
88
+ // persist the resulting frame mode so every subsequent `$default`
89
+ // invocation recovers it (a Lambda `$connect` has no live socket to
90
+ // tag, unlike the CF adapter's hibernation tags). `null` means no
91
+ // supported subprotocol was offered → legacy framing, no mode column.
92
+ const chosen = selectSubprotocol(parseSecWsProtocolOffers(params.secWebSocketProtocol));
93
+ const mode = wsFrameModeFor(chosen ?? undefined);
66
94
  const state = createInitialConnectionState(params.connectionId, now);
67
95
  const row = marshalConnection(state, now);
96
+ if (mode !== 'legacy') {
97
+ row.wsMode = mode;
98
+ }
68
99
  await params.dynamo.send(
69
100
  new PutCommand({
70
101
  TableName: params.tables.Connections,
71
102
  Item: row,
72
103
  }),
73
104
  );
74
- return outcome;
105
+ return { admitted: true, subprotocol: chosen };
75
106
  }
76
107
 
77
108
  /**
@@ -22,9 +22,12 @@ import {
22
22
  type MtlsIdentityProvider,
23
23
  type NickHistoryStore,
24
24
  type ParsedServerConfig,
25
+ type ServerConfig,
25
26
  SystemClock,
26
27
  UuidIdFactory,
28
+ type WsFrameMode,
27
29
  createConnection,
30
+ frameToLines,
28
31
  } from '@serverless-ircd/irc-core';
29
32
  import { type ActorChannelAccess, ConnectionActor } from '@serverless-ircd/irc-server';
30
33
  import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
@@ -33,8 +36,18 @@ import {
33
36
  type MarshalledConnection,
34
37
  unmarshalConnection,
35
38
  } from '../serialize.js';
39
+ import { AwsStats } from '../stats.js';
36
40
  import type { TablesConfig } from '../tables.js';
37
41
 
42
+ /**
43
+ * Wall-clock timestamp captured at this Lambda isolate's first module load
44
+ * (cold start). Used as the `uptimeStartedAt` anchor for {@link AwsStats}
45
+ * so `STATS u` reports uptime for the current isolate. A deployment-wide
46
+ * uptime would require a persistent record (future work); for now each
47
+ * isolate reports its own.
48
+ */
49
+ const LAMBDA_STARTUP_AT = Date.now();
50
+
38
51
  /** Parameters accepted by {@link handleDefault}. */
39
52
  export interface DefaultParams {
40
53
  dynamo: DynamoDBDocumentClient;
@@ -86,6 +99,12 @@ export interface DefaultParams {
86
99
  ids?: IdFactory;
87
100
  /** Optional management endpoint URL used when constructing a default client. */
88
101
  managementEndpoint?: string;
102
+ /**
103
+ * Config-reload source for `REHASH`. When omitted the runtime's
104
+ * `reloadConfig` rejects so the actor emits a graceful error-suffixed
105
+ * `382` and retains the prior config.
106
+ */
107
+ configLoader?: () => Promise<ServerConfig>;
89
108
  }
90
109
 
91
110
  /**
@@ -112,6 +131,12 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
112
131
  const persisted = result.Item as unknown as MarshalledConnection;
113
132
  const state = unmarshalConnection(persisted);
114
133
 
134
+ // Recover the IRCv3 WebSocket frame mode persisted at `$connect`.
135
+ // Absent = legacy (no subprotocol negotiated). Drives both the inbound
136
+ // line-framing transport (spec: one IRC message per frame, never split)
137
+ // and the outbound delivery shape (spec: one postToConnection per line).
138
+ const wsMode: WsFrameMode = persisted.wsMode ?? 'legacy';
139
+
115
140
  // Mark this frame's activity.
116
141
  state.lastSeen = clock.now();
117
142
 
@@ -141,6 +166,7 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
141
166
  handlers,
142
167
  managementApi: params.managementApi,
143
168
  clock,
169
+ ...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
144
170
  });
145
171
 
146
172
  const channelAccess = new LambdaChannelAccess(runtime, clock.now());
@@ -150,9 +176,20 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
150
176
  runtime,
151
177
  channels: channelAccess,
152
178
  serverConfig: params.serverConfig,
179
+ configSource: 'Secrets Manager',
153
180
  clock,
154
181
  ids,
155
182
  motd: params.motd,
183
+ transport: { feed: (chunk) => frameToLines(chunk, wsMode) },
184
+ // AwsStats Scans Connections + ChannelMeta for LUSERS/STATS.
185
+ // `uptimeStartedAt` is captured at the Lambda's cold start so `STATS u`
186
+ // reports per-isolate uptime; a deployment-wide uptime would require a
187
+ // persistent record (future work).
188
+ stats: new AwsStats({
189
+ dynamo: params.dynamo,
190
+ tables: params.tables,
191
+ uptimeStartedAt: LAMBDA_STARTUP_AT,
192
+ }),
156
193
  ...(params.messages !== undefined ? { messages: params.messages } : {}),
157
194
  ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
158
195
  ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
@@ -172,13 +209,12 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
172
209
  await persistState(params.dynamo, params.tables, params.connectionId, state, clock.now());
173
210
 
174
211
  // In production, deliver outbound bytes back to the caller via APIGW.
212
+ // Spec-mode connections (text/binary.ircv3.net) receive exactly one IRC
213
+ // line per `postToConnection` (no trailing CRLF); legacy connections
214
+ // receive every line CRLF-joined in a single message.
175
215
  if (params.managementApi !== null && outbound.length > 0) {
176
- const data = outbound.join('\r\n');
177
216
  try {
178
- await params.managementApi.postToConnection({
179
- ConnectionId: params.connectionId,
180
- Data: data,
181
- });
217
+ await postOutbound(params.managementApi, params.connectionId, outbound, wsMode);
182
218
  } catch {
183
219
  // The handler still returns 200 — the actor's mutations are
184
220
  // persisted; the caller just didn't see the reply this frame.
@@ -187,6 +223,28 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
187
223
  return { statusCode: 200 };
188
224
  }
189
225
 
226
+ /**
227
+ * Delivers `lines` to `connectionId` via APIGW according to `mode`:
228
+ * - `legacy` — all lines joined with `\r\n` in a single `postToConnection`.
229
+ * - `spec-text` / `spec-binary` — one `postToConnection` per line, no
230
+ * trailing CRLF (the IRCv3 WebSocket contract: one IRC message per
231
+ * WebSocket message).
232
+ */
233
+ async function postOutbound(
234
+ api: ApiGatewayManagementApi | PostToConnection,
235
+ connectionId: string,
236
+ lines: string[],
237
+ mode: WsFrameMode,
238
+ ): Promise<void> {
239
+ if (mode === 'legacy') {
240
+ await api.postToConnection({ ConnectionId: connectionId, Data: lines.join('\r\n') });
241
+ return;
242
+ }
243
+ for (const line of lines) {
244
+ await api.postToConnection({ ConnectionId: connectionId, Data: line });
245
+ }
246
+ }
247
+
190
248
  /**
191
249
  * Writes the actor's mutated `state` back to the Connections row.
192
250
  *
@@ -21,6 +21,7 @@ import {
21
21
  type MotdProvider,
22
22
  type NickHistoryStore,
23
23
  type ParsedServerConfig,
24
+ type ServerConfig,
24
25
  StaticMotdProvider,
25
26
  SystemClock,
26
27
  } from '@serverless-ircd/irc-core';
@@ -68,6 +69,12 @@ export interface WebSocketEvent {
68
69
  */
69
70
  identity?: { sourceIp?: string };
70
71
  };
72
+ /**
73
+ * Request headers. APIGW populates `event.headers` on every route; the
74
+ * `$connect` upgrade carries the client's `Sec-WebSocket-Protocol` offer
75
+ * here so the handler can negotiate the IRCv3 subprotocol.
76
+ */
77
+ headers?: Record<string, string>;
71
78
  body?: string;
72
79
  }
73
80
 
@@ -75,6 +82,12 @@ export interface WebSocketEvent {
75
82
  export interface LambdaResponse {
76
83
  statusCode: number;
77
84
  body?: string;
85
+ /**
86
+ * Response headers. The `$connect` route sets `Sec-WebSocket-Protocol`
87
+ * to echo the agreed IRCv3 subprotocol back to the client; the CDK
88
+ * integration-response maps it onto the WebSocket handshake.
89
+ */
90
+ headers?: Record<string, string>;
78
91
  }
79
92
 
80
93
  /** Injectable dependencies. Tests construct one of these to bypass env loading. */
@@ -105,13 +118,24 @@ export interface HandlerDeps {
105
118
  * variant is a documented follow-up.
106
119
  */
107
120
  history?: NickHistoryStore;
121
+ /**
122
+ * Config-reload source for `REHASH` — re-invokes the Lambda env config
123
+ * loader (Secrets Manager / SSM / env vars). Bound once per cold start;
124
+ * each `REHASH` re-reads the live env so rotated oper creds / MOTD take
125
+ * effect without a redeploy. When omitted the actor's graceful failure
126
+ * path applies (error-suffixed `382`).
127
+ */
128
+ configLoader?: () => Promise<ServerConfig>;
108
129
  }
109
130
 
110
131
  /**
111
132
  * Strongly-typed event discriminated by routeKey. Useful for tests that
112
133
  * construct events directly.
113
134
  */
114
- export type ConnectEvent = { requestContext: { routeKey: '$connect'; connectionId: string } };
135
+ export type ConnectEvent = {
136
+ requestContext: { routeKey: '$connect'; connectionId: string };
137
+ headers?: Record<string, string>;
138
+ };
115
139
  export type DisconnectEvent = {
116
140
  requestContext: { routeKey: '$disconnect'; connectionId: string };
117
141
  };
@@ -162,8 +186,17 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
162
186
  tables: deps.tables,
163
187
  connectionId: connId,
164
188
  serverConfig: deps.serverConfig,
189
+ secWebSocketProtocol: event.headers?.['Sec-WebSocket-Protocol'] ?? null,
165
190
  });
166
- if (outcome.admitted) return { statusCode: 200 };
191
+ if (outcome.admitted) {
192
+ if (outcome.subprotocol !== null) {
193
+ return {
194
+ statusCode: 200,
195
+ headers: { 'Sec-WebSocket-Protocol': outcome.subprotocol },
196
+ };
197
+ }
198
+ return { statusCode: 200 };
199
+ }
167
200
  return { statusCode: outcome.statusCode, body: outcome.reason };
168
201
  }
169
202
 
@@ -192,6 +225,7 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
192
225
  messages: deps.messages,
193
226
  ...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
194
227
  ...(deps.history !== undefined ? { history: deps.history } : {}),
228
+ ...(deps.configLoader !== undefined ? { configLoader: deps.configLoader } : {}),
195
229
  managementApi: deps.managementApi,
196
230
  });
197
231
  return result;
@@ -333,6 +367,10 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
333
367
  const messages = bindMessageStore(cfg);
334
368
  const history = new InMemoryNickHistoryStore(SystemClock);
335
369
  const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
370
+ // REHASH reload source: re-read the live env so a rotated oper password
371
+ // or MOTD takes effect on the next REHASH without a redeploy.
372
+ const configLoader = async (): Promise<ServerConfig> =>
373
+ loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
336
374
  return {
337
375
  dynamo,
338
376
  tables,
@@ -341,6 +379,7 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
341
379
  managementApi,
342
380
  messages,
343
381
  history,
382
+ configLoader,
344
383
  ...(accounts !== undefined ? { accounts } : {}),
345
384
  };
346
385
  }
@@ -53,6 +53,7 @@ import {
53
53
  type MotdProvider,
54
54
  type NickHistoryStore,
55
55
  type ParsedServerConfig,
56
+ type ServerConfig,
56
57
  SystemClock,
57
58
  UuidIdFactory,
58
59
  } from '@serverless-ircd/irc-core';
@@ -151,6 +152,12 @@ export interface NlbStreamParams {
151
152
  clock?: Clock;
152
153
  /** Injected for tests; defaults to {@link UuidIdFactory}. */
153
154
  ids?: IdFactory;
155
+ /**
156
+ * Config-reload source for `REHASH` (same as the wss `$default` path).
157
+ * When omitted the runtime's `reloadConfig` rejects so the actor emits a
158
+ * graceful error-suffixed `382`.
159
+ */
160
+ configLoader?: () => Promise<ServerConfig>;
154
161
  }
155
162
 
156
163
  /**
@@ -225,6 +232,7 @@ export async function handleNlbStream(
225
232
  handlers,
226
233
  managementApi: params.managementApi,
227
234
  clock,
235
+ ...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
228
236
  });
229
237
 
230
238
  const channelAccess = new NlbChannelAccess(runtime, now);
@@ -234,6 +242,7 @@ export async function handleNlbStream(
234
242
  runtime,
235
243
  channels: channelAccess,
236
244
  serverConfig: params.serverConfig,
245
+ configSource: 'Secrets Manager',
237
246
  clock,
238
247
  ids,
239
248
  motd: params.motd,
@@ -23,6 +23,8 @@ export {
23
23
  loadDynamoAccountStore,
24
24
  } from './dynamo-account-store.js';
25
25
  export { bindAccountStore, putAccountCredential, resolveAccountStore } from './account-store.js';
26
+ export { AwsStats } from './stats.js';
27
+ export type { AwsStatsOptions } from './stats.js';
26
28
  export {
27
29
  loadServerConfigFromLambdaEnv,
28
30
  type LambdaConfigEnv,
@@ -30,6 +30,7 @@ import type {
30
30
  RegistrationState,
31
31
  RosterEntry,
32
32
  UserModes,
33
+ WsFrameMode,
33
34
  } from '@serverless-ircd/irc-core';
34
35
 
35
36
  // ---------------------------------------------------------------------------
@@ -76,6 +77,15 @@ export interface MarshalledConnection {
76
77
  away?: string;
77
78
  saslMech?: string;
78
79
  saslBuffer?: string;
80
+ /**
81
+ * Negotiated IRCv3 WebSocket frame mode persisted at `$connect` time.
82
+ * Absent (`undefined`) on legacy connections (no subprotocol agreed) so
83
+ * the DynamoDB column is null by default — mirroring the CF adapter's
84
+ * hibernation-tag default. Set once and never mutated: `$default` uses
85
+ * `UpdateCommand` for its partial writes, so this field survives every
86
+ * subsequent frame.
87
+ */
88
+ wsMode?: WsFrameMode;
79
89
  }
80
90
 
81
91
  /**
@@ -118,7 +128,7 @@ export function marshalConnection(state: ConnectionState, idleSince: number): Ma
118
128
  /**
119
129
  * Materialises a {@link ConnectionState} from a stored row. Rehydrates
120
130
  * `caps` and `joinedChannels` into Sets. Throws on unrecognised
121
- * `version` values (forward migrations live in a future ticket).
131
+ * `version` values (forward migrations are not yet implemented).
122
132
  */
123
133
  export function unmarshalConnection(row: MarshalledConnection): ConnectionState {
124
134
  if (row.version > CONNECTION_VERSION) {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * AWS-flavoured {@link ServerStats} backend.
3
+ *
4
+ * Aggregates the network-wide counts the `LUSERS` / `STATS` reducers need
5
+ * directly from DynamoDB: a single `Scan` over `Connections` (the source
6
+ * of truth for every live connection) and one over `ChannelMeta` (one row
7
+ * per channel). Classification (oper / invisible / unknown) is delegated
8
+ * to the shared {@link computeStatsSnapshot} helper in irc-core so the
9
+ * classification rules live in exactly one place.
10
+ *
11
+ * Constructed per `LUSERS` / `STATS` invocation alongside the
12
+ * {@link AwsRuntime}; cheap to build, no caching. `uptimeStartedAt` is
13
+ * supplied by the caller (typically the Lambda's cold-start timestamp,
14
+ * stashed in module scope at first import).
15
+ */
16
+
17
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
18
+ import { ScanCommand } from '@aws-sdk/lib-dynamodb';
19
+ import { type ServerStatsSnapshot, computeStatsSnapshot } from '@serverless-ircd/irc-core';
20
+ import type { MarshalledConnection } from './serialize.js';
21
+ import { unmarshalConnection } from './serialize.js';
22
+ import type { TablesConfig } from './tables.js';
23
+
24
+ export interface AwsStatsOptions {
25
+ /** DynamoDB DocumentClient (the same client used by {@link AwsRuntime}). */
26
+ readonly dynamo: DynamoDBDocumentClient;
27
+ /** Logical→physical table-name map. */
28
+ readonly tables: TablesConfig;
29
+ /** Epoch-ms the deployment counts uptime from (Lambda cold start). */
30
+ readonly uptimeStartedAt: number;
31
+ /**
32
+ * Optional high-water marks for `265 RPL_LOCALUSERS` / `266
33
+ * RPL_GLOBALUSERS`. When omitted the live count is reported as the max
34
+ * (so the wire numeric never claims a max below the current count).
35
+ */
36
+ readonly maxLocalConns?: number;
37
+ readonly maxGlobalConns?: number;
38
+ }
39
+
40
+ export class AwsStats {
41
+ private readonly dynamo: DynamoDBDocumentClient;
42
+ private readonly tables: TablesConfig;
43
+ private readonly uptimeStartedAt: number;
44
+ private readonly maxLocalConns: number | undefined;
45
+ private readonly maxGlobalConns: number | undefined;
46
+
47
+ constructor(opts: AwsStatsOptions) {
48
+ this.dynamo = opts.dynamo;
49
+ this.tables = opts.tables;
50
+ this.uptimeStartedAt = opts.uptimeStartedAt;
51
+ this.maxLocalConns = opts.maxLocalConns;
52
+ this.maxGlobalConns = opts.maxGlobalConns;
53
+ }
54
+
55
+ async getStats(): Promise<ServerStatsSnapshot> {
56
+ // Scan Connections in a single request. DynamoDB Local + production
57
+ // both paginate at 1MB; for the serverless-IRC scale (≤ tens of
58
+ // thousands of connections per deployment) a single page covers the
59
+ // realistic ceiling. A deployment that outgrows this should maintain
60
+ // rolling count records (updated on connect/disconnect) rather than
61
+ // Scan — a tracked follow-up.
62
+ const connResult = await this.dynamo.send(
63
+ new ScanCommand({ TableName: this.tables.Connections }),
64
+ );
65
+ const connItems = (connResult.Items ?? []) as unknown as MarshalledConnection[];
66
+ const connections = connItems.map(unmarshalConnection);
67
+
68
+ // Channel count: one row per channel in `ChannelMeta`, again in a
69
+ // single page (channel counts are small).
70
+ const chanResult = await this.dynamo.send(
71
+ new ScanCommand({ TableName: this.tables.ChannelMeta, Select: 'COUNT' }),
72
+ );
73
+ const channelCount = chanResult.Count ?? 0;
74
+
75
+ return computeStatsSnapshot(connections, channelCount, this.uptimeStartedAt, {
76
+ ...(this.maxLocalConns !== undefined ? { maxLocalConns: this.maxLocalConns } : {}),
77
+ ...(this.maxGlobalConns !== undefined ? { maxGlobalConns: this.maxGlobalConns } : {}),
78
+ });
79
+ }
80
+ }
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Gated on `process.env.DDB_AVAILABLE`: the suite is skipped when
10
10
  * DynamoDB Local cannot be booted (no Docker, no Java). When available,
11
- * every scenario MUST pass — that is the 040 acceptance criterion.
11
+ * every scenario MUST pass — that is the acceptance criterion.
12
12
  */
13
13
 
14
14
  import { runIrcScenarios } from '@serverless-ircd/irc-test-support';
@@ -12,6 +12,7 @@ import {
12
12
  type Clock,
13
13
  type ConnectionState,
14
14
  type RawLine,
15
+ type ServerConfig,
15
16
  createConnection,
16
17
  } from '@serverless-ircd/irc-core';
17
18
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@@ -652,3 +653,63 @@ function handlersSink(sink: string[], snapshot?: ConnectionState): AwsRuntimeHan
652
653
  snapshot: () => snapshot,
653
654
  };
654
655
  }
656
+
657
+ // ---------------------------------------------------------------------------
658
+ // reloadConfig (REHASH) — no DynamoDB access, so this group runs unconditionally.
659
+ // ---------------------------------------------------------------------------
660
+
661
+ describe('AwsRuntime — reloadConfig (REHASH)', () => {
662
+ const baseConfig: ServerConfig = {
663
+ serverName: 'irc.example.com',
664
+ networkName: 'ExampleNet',
665
+ maxChannelsPerUser: 30,
666
+ maxTargetsPerCommand: 10,
667
+ maxListEntries: 50,
668
+ nickLen: 30,
669
+ channelLen: 50,
670
+ topicLen: 390,
671
+ quitMessage: 'Client Quit',
672
+ };
673
+
674
+ function makeReloadRuntime(loader?: () => Promise<ServerConfig>): AwsRuntime {
675
+ return new AwsRuntime({
676
+ // reloadConfig never touches DynamoDB; a default client + empty tables
677
+ // suffice for the no-IO reload path.
678
+ dynamo: createDynamoDocumentClient({}),
679
+ tables: {} as TablesConfig,
680
+ connId: 'c1',
681
+ handlers: noopHandlers(),
682
+ managementApi: null,
683
+ ...(loader !== undefined ? { configLoader: loader } : {}),
684
+ });
685
+ }
686
+
687
+ it('re-invokes the bound loader and returns the fresh config', async () => {
688
+ let password = 'old';
689
+ const rt = makeReloadRuntime(async () => ({
690
+ ...baseConfig,
691
+ operCreds: [{ user: 'admin', password }],
692
+ }));
693
+
694
+ const first = await rt.reloadConfig();
695
+ expect(first.operCreds).toEqual([{ user: 'admin', password: 'old' }]);
696
+
697
+ password = 'rotated';
698
+ const second = await rt.reloadConfig();
699
+ expect(second.operCreds).toEqual([{ user: 'admin', password: 'rotated' }]);
700
+ });
701
+
702
+ it('propagates a loader rejection so the actor applies the graceful 382', async () => {
703
+ const rt = makeReloadRuntime(async () => {
704
+ throw new Error('Secrets Manager unreachable');
705
+ });
706
+
707
+ await expect(rt.reloadConfig()).rejects.toThrow('Secrets Manager unreachable');
708
+ });
709
+
710
+ it('rejects when no loader was bound at construction', async () => {
711
+ const rt = makeReloadRuntime();
712
+
713
+ await expect(rt.reloadConfig()).rejects.toThrow('no config loader bound');
714
+ });
715
+ });