serverless-ircd 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +349 -18
  8. package/README.md +132 -30
  9. package/apps/aws-stack/README.md +6 -5
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +150 -10
  13. package/apps/aws-stack/tests/stack.test.ts +145 -4
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +149 -24
  38. package/apps/local-cli/tests/e2e.test.ts +1 -1
  39. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  40. package/package.json +14 -10
  41. package/packages/aws-adapter/package.json +3 -3
  42. package/packages/aws-adapter/src/account-store.ts +1 -1
  43. package/packages/aws-adapter/src/admission.ts +74 -0
  44. package/packages/aws-adapter/src/aws-runtime.ts +75 -4
  45. package/packages/aws-adapter/src/config-loader.ts +32 -0
  46. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  47. package/packages/aws-adapter/src/handlers/connect.ts +96 -9
  48. package/packages/aws-adapter/src/handlers/default.ts +98 -7
  49. package/packages/aws-adapter/src/handlers/index.ts +109 -4
  50. package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
  51. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  52. package/packages/aws-adapter/src/index.ts +8 -0
  53. package/packages/aws-adapter/src/serialize.ts +11 -1
  54. package/packages/aws-adapter/src/stats.ts +80 -0
  55. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  56. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  57. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  58. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  59. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  60. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  61. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  62. package/packages/aws-adapter/tests/connect.test.ts +174 -0
  63. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  64. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  65. package/packages/aws-adapter/tests/handlers.test.ts +302 -53
  66. package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
  67. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  68. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +5 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +68 -5
  73. package/packages/cf-adapter/src/channel-do.ts +2 -2
  74. package/packages/cf-adapter/src/config-loader.ts +33 -0
  75. package/packages/cf-adapter/src/connection-do.ts +278 -85
  76. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  77. package/packages/cf-adapter/src/env.ts +54 -11
  78. package/packages/cf-adapter/src/index.ts +11 -8
  79. package/packages/cf-adapter/src/registry-do.ts +22 -3
  80. package/packages/cf-adapter/src/sharding.ts +1 -2
  81. package/packages/cf-adapter/src/stats.ts +65 -0
  82. package/packages/cf-adapter/tests/cf-harness.ts +12 -2
  83. package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
  84. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  85. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  86. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  87. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  88. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  89. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  90. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  91. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  92. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  93. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  94. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  95. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  96. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  97. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  98. package/packages/cf-adapter/tests/worker/main.ts +15 -8
  99. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  100. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  101. package/packages/cf-adapter/wrangler.test.toml +15 -0
  102. package/packages/in-memory-runtime/package.json +1 -1
  103. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  104. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  105. package/packages/irc-core/package.json +6 -1
  106. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  107. package/packages/irc-core/src/admission.ts +16 -15
  108. package/packages/irc-core/src/caps/capabilities.ts +24 -3
  109. package/packages/irc-core/src/cloak.ts +1 -1
  110. package/packages/irc-core/src/commands/cap.ts +8 -1
  111. package/packages/irc-core/src/commands/index.ts +17 -0
  112. package/packages/irc-core/src/commands/invite.ts +2 -4
  113. package/packages/irc-core/src/commands/ison.ts +61 -0
  114. package/packages/irc-core/src/commands/isupport.ts +6 -2
  115. package/packages/irc-core/src/commands/kick.ts +2 -4
  116. package/packages/irc-core/src/commands/kill.ts +127 -0
  117. package/packages/irc-core/src/commands/list.ts +1 -1
  118. package/packages/irc-core/src/commands/lusers.ts +204 -0
  119. package/packages/irc-core/src/commands/mode.ts +4 -8
  120. package/packages/irc-core/src/commands/names.ts +3 -5
  121. package/packages/irc-core/src/commands/part.ts +2 -4
  122. package/packages/irc-core/src/commands/quit.ts +12 -0
  123. package/packages/irc-core/src/commands/registration.ts +18 -12
  124. package/packages/irc-core/src/commands/rehash.ts +119 -0
  125. package/packages/irc-core/src/commands/sasl.ts +72 -9
  126. package/packages/irc-core/src/commands/server-info.ts +129 -0
  127. package/packages/irc-core/src/commands/setname.ts +109 -0
  128. package/packages/irc-core/src/commands/stats.ts +152 -0
  129. package/packages/irc-core/src/commands/topic.ts +2 -4
  130. package/packages/irc-core/src/commands/trace.ts +137 -0
  131. package/packages/irc-core/src/commands/userhost.ts +84 -0
  132. package/packages/irc-core/src/commands/wallops.ts +118 -0
  133. package/packages/irc-core/src/commands/whowas.ts +113 -0
  134. package/packages/irc-core/src/config.ts +65 -0
  135. package/packages/irc-core/src/credential-hashing.ts +124 -0
  136. package/packages/irc-core/src/effects.ts +33 -30
  137. package/packages/irc-core/src/index.ts +3 -0
  138. package/packages/irc-core/src/ports.ts +360 -12
  139. package/packages/irc-core/src/protocol/numerics.ts +48 -11
  140. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  141. package/packages/irc-core/src/types.ts +46 -2
  142. package/packages/irc-core/src/ws-framing.ts +132 -0
  143. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  144. package/packages/irc-core/tests/account-store.test.ts +45 -2
  145. package/packages/irc-core/tests/admission.test.ts +18 -0
  146. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  147. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  148. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  149. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  150. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  151. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  152. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  153. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  154. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  155. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  156. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  157. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  158. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  159. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  160. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  161. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  162. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  163. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  164. package/packages/irc-core/tests/config.test.ts +95 -1
  165. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  166. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  167. package/packages/irc-core/tests/effects.test.ts +14 -27
  168. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  169. package/packages/irc-core/tests/numerics.test.ts +102 -0
  170. package/packages/irc-core/tests/outbound.test.ts +51 -0
  171. package/packages/irc-core/tests/ports.test.ts +22 -0
  172. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  173. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  174. package/packages/irc-core/tests/types.test.ts +35 -1
  175. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  176. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  177. package/packages/irc-core/tsconfig.build.json +1 -1
  178. package/packages/irc-core/tsconfig.test.json +1 -1
  179. package/packages/irc-server/package.json +1 -1
  180. package/packages/irc-server/src/actor.ts +393 -16
  181. package/packages/irc-server/src/dispatch.ts +1 -3
  182. package/packages/irc-server/src/index.ts +10 -2
  183. package/packages/irc-server/src/routing.ts +15 -0
  184. package/packages/irc-server/src/runtime.ts +31 -0
  185. package/packages/irc-server/src/transport.ts +104 -0
  186. package/packages/irc-server/tests/actor.test.ts +1489 -4
  187. package/packages/irc-server/tests/dispatch.test.ts +37 -17
  188. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  189. package/packages/irc-server/tests/routing.test.ts +5 -0
  190. package/packages/irc-server/tests/runtime.test.ts +7 -0
  191. package/packages/irc-server/tests/transport.test.ts +230 -0
  192. package/packages/irc-test-support/package.json +1 -1
  193. package/packages/irc-test-support/src/harness.ts +44 -9
  194. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  195. package/packages/irc-test-support/src/index.ts +3 -0
  196. package/packages/irc-test-support/src/scenarios.ts +141 -3
  197. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  198. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  199. package/pnpm-workspace.yaml +10 -1
  200. package/tools/ci-hardening/package.json +1 -1
  201. package/tools/package.json +9 -0
  202. package/tools/seed-aws-accounts.ts +5 -6
  203. package/tools/seed-cf-accounts.ts +107 -0
  204. package/tools/tcp-ws-forwarder/package.json +1 -1
  205. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  206. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  207. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
  208. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  209. package/docs/ADR-002-location-of-authority.md +0 -82
  210. package/docs/ADR-003-durable-object-sharding.md +0 -93
  211. package/docs/ADR-004-dynamodb-schema.md +0 -96
  212. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  213. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  214. package/docs/ADR-007-deterministic-ports.md +0 -82
  215. package/docs/ADR-008-monorepo-tooling.md +0 -60
  216. package/docs/AWS-Adapter-Architecture.md +0 -496
  217. package/docs/AWS-Deployment.md +0 -1186
  218. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  219. package/docs/Home.md +0 -11
  220. package/docs/Observability.md +0 -87
  221. package/docs/PlanIRCv3Websocket.md +0 -489
  222. package/docs/PlanWebClient.md +0 -451
  223. package/docs/Release-Process.md +0 -443
@@ -19,10 +19,15 @@ import {
19
19
  type IdFactory,
20
20
  type MessageStore,
21
21
  type MotdProvider,
22
+ type MtlsIdentityProvider,
23
+ type NickHistoryStore,
22
24
  type ParsedServerConfig,
25
+ type ServerConfig,
23
26
  SystemClock,
24
27
  UuidIdFactory,
28
+ type WsFrameMode,
25
29
  createConnection,
30
+ frameToLines,
26
31
  } from '@serverless-ircd/irc-core';
27
32
  import { type ActorChannelAccess, ConnectionActor } from '@serverless-ircd/irc-server';
28
33
  import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
@@ -31,8 +36,18 @@ import {
31
36
  type MarshalledConnection,
32
37
  unmarshalConnection,
33
38
  } from '../serialize.js';
39
+ import { AwsStats } from '../stats.js';
34
40
  import type { TablesConfig } from '../tables.js';
35
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
+
36
51
  /** Parameters accepted by {@link handleDefault}. */
37
52
  export interface DefaultParams {
38
53
  dynamo: DynamoDBDocumentClient;
@@ -53,6 +68,30 @@ export interface DefaultParams {
53
68
  * — preserves the behaviour of callers that have not opted in.
54
69
  */
55
70
  accounts?: AccountStore;
71
+ /**
72
+ * mTLS client-cert identity source for SASL EXTERNAL. When omitted the
73
+ * actor runs with `ctx.mtlsIdentity` undefined so `AUTHENTICATE EXTERNAL`
74
+ * fails with `904` — preserves the behaviour of callers that have not
75
+ * opted in.
76
+ *
77
+ * AWS APIGW custom-domain mTLS setup:
78
+ * 1. Upload a PEM trust store to S3 (the CA bundle clients must present).
79
+ * 2. Create a custom domain with `mutualTlsAuthentication` pointing at the
80
+ * S3 trust store URI.
81
+ * 3. Map the custom domain to the WebSocket API stage.
82
+ * 4. The `$connect` event's `requestContext.identity.clientCertSubjectDN`
83
+ * carries the verified cert subject — capture it at connect time and
84
+ * persist it alongside the connection row, then construct an
85
+ * {@link MtlsIdentityProvider} that resolves it by connection ID.
86
+ */
87
+ mtlsIdentity?: MtlsIdentityProvider;
88
+ /**
89
+ * Nick-history source for the `WHOWAS` command. When omitted the actor
90
+ * runs with `ctx.history` undefined so `WHOWAS` always returns
91
+ * `406`+`369` — preserves the behaviour of callers that have not opted
92
+ * in. A DynamoDB-backed persistent variant is a documented follow-up.
93
+ */
94
+ history?: NickHistoryStore;
56
95
  managementApi: ApiGatewayManagementApi | PostToConnection | null;
57
96
  /** Injected for tests; defaults to {@link SystemClock}. */
58
97
  clock?: Clock;
@@ -60,6 +99,12 @@ export interface DefaultParams {
60
99
  ids?: IdFactory;
61
100
  /** Optional management endpoint URL used when constructing a default client. */
62
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>;
63
108
  }
64
109
 
65
110
  /**
@@ -86,6 +131,12 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
86
131
  const persisted = result.Item as unknown as MarshalledConnection;
87
132
  const state = unmarshalConnection(persisted);
88
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
+
89
140
  // Mark this frame's activity.
90
141
  state.lastSeen = clock.now();
91
142
 
@@ -98,8 +149,13 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
98
149
  for (const l of lines) outbound.push(l.text);
99
150
  },
100
151
  disconnect: () => {
101
- // Lambda cannot close its own socket from inside the handler
102
- // (the `$disconnect` route fires when APIGW observes the close).
152
+ // No-op: a Lambda `$default` invocation cannot close its own APIGW
153
+ // socket. The canonical teardown is the `$disconnect` route (which
154
+ // fires when APIGW observes the close) plus the sweeper/ping-checker
155
+ // for connections that vanish without one. The actor's state
156
+ // mutations (nick release, roster deltas) are persisted below before
157
+ // this invocation returns, so the eventual `$disconnect` completes
158
+ // the fanout. See docs/AWS-Deployment.md §14.1.
103
159
  },
104
160
  snapshot: () => state,
105
161
  };
@@ -110,6 +166,7 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
110
166
  handlers,
111
167
  managementApi: params.managementApi,
112
168
  clock,
169
+ ...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
113
170
  });
114
171
 
115
172
  const channelAccess = new LambdaChannelAccess(runtime, clock.now());
@@ -119,11 +176,24 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
119
176
  runtime,
120
177
  channels: channelAccess,
121
178
  serverConfig: params.serverConfig,
179
+ configSource: 'Secrets Manager',
122
180
  clock,
123
181
  ids,
124
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
+ }),
125
193
  ...(params.messages !== undefined ? { messages: params.messages } : {}),
126
194
  ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
195
+ ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
196
+ ...(params.history !== undefined ? { history: params.history } : {}),
127
197
  });
128
198
 
129
199
  try {
@@ -139,13 +209,12 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
139
209
  await persistState(params.dynamo, params.tables, params.connectionId, state, clock.now());
140
210
 
141
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.
142
215
  if (params.managementApi !== null && outbound.length > 0) {
143
- const data = outbound.join('\r\n');
144
216
  try {
145
- await params.managementApi.postToConnection({
146
- ConnectionId: params.connectionId,
147
- Data: data,
148
- });
217
+ await postOutbound(params.managementApi, params.connectionId, outbound, wsMode);
149
218
  } catch {
150
219
  // The handler still returns 200 — the actor's mutations are
151
220
  // persisted; the caller just didn't see the reply this frame.
@@ -154,6 +223,28 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
154
223
  return { statusCode: 200 };
155
224
  }
156
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
+
157
248
  /**
158
249
  * Writes the actor's mutated `state` back to the Connections row.
159
250
  *
@@ -16,10 +16,14 @@ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
16
16
  import {
17
17
  type AccountStore,
18
18
  EmptyMotdProvider,
19
+ InMemoryNickHistoryStore,
19
20
  type MessageStore,
20
21
  type MotdProvider,
22
+ type NickHistoryStore,
21
23
  type ParsedServerConfig,
24
+ type ServerConfig,
22
25
  StaticMotdProvider,
26
+ SystemClock,
23
27
  } from '@serverless-ircd/irc-core';
24
28
  import { resolveAccountStore } from '../account-store.js';
25
29
  import { type LambdaConfigEnv, loadServerConfigFromLambdaEnv } from '../config-loader.js';
@@ -29,6 +33,13 @@ import { TABLE_KEYS, type TablesConfig } from '../tables.js';
29
33
  import { handleConnect } from './connect.js';
30
34
  import { handleDefault } from './default.js';
31
35
  import { handleDisconnect } from './disconnect.js';
36
+ import {
37
+ type NlbStreamEvent,
38
+ type NlbStreamParams,
39
+ type NlbStreamResponse,
40
+ deriveNlbConnectionId,
41
+ handleNlbStream,
42
+ } from './nlb-stream.js';
32
43
  import {
33
44
  DEFAULT_PING_INTERVAL_MS,
34
45
  DEFAULT_PONG_TIMEOUT_MS,
@@ -50,7 +61,20 @@ export interface WebSocketEvent {
50
61
  connectionId: string;
51
62
  domainName?: string;
52
63
  stage?: string;
64
+ /**
65
+ * Source IP of the connecting client. APIGW populates
66
+ * `requestContext.identity.sourceIp` on every `$connect`. Carried on
67
+ * the event type so the (currently deferred) per-IP admission gate
68
+ * can consume it without reshaping the handler signature.
69
+ */
70
+ identity?: { sourceIp?: string };
53
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>;
54
78
  body?: string;
55
79
  }
56
80
 
@@ -58,6 +82,12 @@ export interface WebSocketEvent {
58
82
  export interface LambdaResponse {
59
83
  statusCode: number;
60
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>;
61
91
  }
62
92
 
63
93
  /** Injectable dependencies. Tests construct one of these to bypass env loading. */
@@ -81,13 +111,31 @@ export interface HandlerDeps {
81
111
  * configured (the default) so `ctx.accounts` stays unset.
82
112
  */
83
113
  accounts?: AccountStore;
114
+ /**
115
+ * Nick-history source bound to every actor. Constructed once per cold
116
+ * start (memoised via {@link cachedDeps}) as an in-memory store so it
117
+ * survives across warm Lambda invocations. A DynamoDB-backed persistent
118
+ * variant is a documented follow-up.
119
+ */
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>;
84
129
  }
85
130
 
86
131
  /**
87
132
  * Strongly-typed event discriminated by routeKey. Useful for tests that
88
133
  * construct events directly.
89
134
  */
90
- export type ConnectEvent = { requestContext: { routeKey: '$connect'; connectionId: string } };
135
+ export type ConnectEvent = {
136
+ requestContext: { routeKey: '$connect'; connectionId: string };
137
+ headers?: Record<string, string>;
138
+ };
91
139
  export type DisconnectEvent = {
92
140
  requestContext: { routeKey: '$disconnect'; connectionId: string };
93
141
  };
@@ -132,14 +180,25 @@ export const handler = async (event: WebSocketEvent): Promise<LambdaResponse> =>
132
180
  export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promise<LambdaResponse> {
133
181
  const connId = event.requestContext.connectionId;
134
182
  switch (event.requestContext.routeKey) {
135
- case '$connect':
136
- await handleConnect({
183
+ case '$connect': {
184
+ const outcome = await handleConnect({
137
185
  dynamo: deps.dynamo,
138
186
  tables: deps.tables,
139
187
  connectionId: connId,
140
188
  serverConfig: deps.serverConfig,
189
+ secWebSocketProtocol: event.headers?.['Sec-WebSocket-Protocol'] ?? null,
141
190
  });
142
- 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
+ }
200
+ return { statusCode: outcome.statusCode, body: outcome.reason };
201
+ }
143
202
 
144
203
  case '$disconnect':
145
204
  await handleDisconnect({
@@ -165,6 +224,8 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
165
224
  motd: deps.motd,
166
225
  messages: deps.messages,
167
226
  ...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
227
+ ...(deps.history !== undefined ? { history: deps.history } : {}),
228
+ ...(deps.configLoader !== undefined ? { configLoader: deps.configLoader } : {}),
168
229
  managementApi: deps.managementApi,
169
230
  });
170
231
  return result;
@@ -245,6 +306,43 @@ export {
245
306
  type SweepResult,
246
307
  };
247
308
 
309
+ /**
310
+ * Lambda entry for the NLB TCP+TLS streaming target. Wired
311
+ * to the NLB target group in the CDK stack. Shares the cached
312
+ * {@link HandlerDeps} with the wss {@link handler} — same tables, same
313
+ * config, same management API (when available). Each NLB client chunk
314
+ * arrives as one invocation; the handler returns the outbound bytes for
315
+ * the NLB to stream back over the held-open TCP connection.
316
+ */
317
+ export async function nlbStreamHandler(event: NlbStreamEvent): Promise<NlbStreamResponse> {
318
+ if (cachedDeps === undefined) {
319
+ if (cachedDepsPromise === undefined) {
320
+ cachedDepsPromise = buildDepsFromEnv();
321
+ }
322
+ cachedDeps = await cachedDepsPromise;
323
+ cachedDepsPromise = undefined;
324
+ }
325
+ const deps = cachedDeps;
326
+ return handleNlbStream(event, {
327
+ dynamo: deps.dynamo,
328
+ tables: deps.tables,
329
+ serverConfig: deps.serverConfig,
330
+ motd: deps.motd,
331
+ messages: deps.messages,
332
+ managementApi: deps.managementApi,
333
+ ...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
334
+ ...(deps.history !== undefined ? { history: deps.history } : {}),
335
+ });
336
+ }
337
+
338
+ export {
339
+ handleNlbStream,
340
+ deriveNlbConnectionId,
341
+ type NlbStreamEvent,
342
+ type NlbStreamParams,
343
+ type NlbStreamResponse,
344
+ };
345
+
248
346
  /**
249
347
  * Builds a {@link HandlerDeps} from the current `process.env`.
250
348
  *
@@ -267,7 +365,12 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
267
365
  }
268
366
  const motd = cfg.motdLines.length > 0 ? new StaticMotdProvider(cfg.motdLines) : EmptyMotdProvider;
269
367
  const messages = bindMessageStore(cfg);
368
+ const history = new InMemoryNickHistoryStore(SystemClock);
270
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);
271
374
  return {
272
375
  dynamo,
273
376
  tables,
@@ -275,6 +378,8 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
275
378
  motd,
276
379
  managementApi,
277
380
  messages,
381
+ history,
382
+ configLoader,
278
383
  ...(accounts !== undefined ? { accounts } : {}),
279
384
  };
280
385
  }