serverless-ircd 0.1.0 → 0.2.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 (198) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/AGENTS.md +5 -0
  6. package/CHANGELOG.md +352 -0
  7. package/MOTD.txt +3 -0
  8. package/PLAN-FIXES.md +358 -0
  9. package/PLAN.md +420 -0
  10. package/README.md +115 -81
  11. package/apps/aws-stack/README.md +73 -0
  12. package/apps/aws-stack/bin/aws.ts +49 -0
  13. package/apps/aws-stack/cdk.json +10 -0
  14. package/apps/aws-stack/package.json +41 -0
  15. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  16. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  17. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  18. package/apps/aws-stack/src/aws-stack.ts +263 -0
  19. package/apps/aws-stack/src/tables.ts +10 -0
  20. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  21. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  22. package/apps/aws-stack/tests/stack.test.ts +464 -0
  23. package/apps/aws-stack/tsconfig.build.json +11 -0
  24. package/apps/aws-stack/tsconfig.test.json +8 -0
  25. package/apps/aws-stack/vitest.config.ts +20 -0
  26. package/apps/cf-worker/package.json +1 -1
  27. package/apps/cf-worker/wrangler.toml +25 -0
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +56 -0
  30. package/apps/local-cli/src/server.ts +241 -32
  31. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  32. package/apps/local-cli/tests/e2e.test.ts +74 -0
  33. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  34. package/biome.json +3 -1
  35. package/dashboards/cloudwatch-irc.json +139 -0
  36. package/docs/AWS-Adapter-Architecture.md +494 -0
  37. package/docs/AWS-Deployment.md +1107 -0
  38. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  39. package/docs/Home.md +1 -0
  40. package/docs/Observability.md +87 -0
  41. package/docs/PlanIRCv3Websocket.md +489 -0
  42. package/docs/PlanWebClient.md +451 -0
  43. package/docs/Release-Process.md +440 -0
  44. package/package.json +8 -2
  45. package/packages/aws-adapter/README.md +35 -0
  46. package/packages/aws-adapter/package.json +52 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +783 -0
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
  49. package/packages/aws-adapter/src/config-loader.ts +96 -0
  50. package/packages/aws-adapter/src/dynamo.ts +61 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  52. package/packages/aws-adapter/src/handlers/default.ts +322 -0
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
  54. package/packages/aws-adapter/src/handlers/index.ts +248 -0
  55. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  56. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  57. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  58. package/packages/aws-adapter/src/index.ts +67 -0
  59. package/packages/aws-adapter/src/message-store.ts +34 -0
  60. package/packages/aws-adapter/src/serialize.ts +283 -0
  61. package/packages/aws-adapter/src/tables.ts +53 -0
  62. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  63. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  65. package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
  66. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  67. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  68. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  69. package/packages/aws-adapter/tests/handlers.test.ts +427 -0
  70. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  71. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  72. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  73. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  74. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  76. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  77. package/packages/aws-adapter/tsconfig.build.json +16 -0
  78. package/packages/aws-adapter/tsconfig.test.json +14 -0
  79. package/packages/aws-adapter/vitest.config.ts +23 -0
  80. package/packages/cf-adapter/package.json +2 -1
  81. package/packages/cf-adapter/src/config-loader.ts +106 -0
  82. package/packages/cf-adapter/src/connection-do.ts +34 -0
  83. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  84. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  85. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  86. package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
  87. package/packages/in-memory-runtime/package.json +1 -1
  88. package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
  89. package/packages/in-memory-runtime/src/index.ts +1 -1
  90. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
  91. package/packages/irc-core/package.json +12 -2
  92. package/packages/irc-core/src/admission.ts +216 -0
  93. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  94. package/packages/irc-core/src/cloak.ts +81 -0
  95. package/packages/irc-core/src/commands/cap.ts +1 -2
  96. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  97. package/packages/irc-core/src/commands/index.ts +7 -0
  98. package/packages/irc-core/src/commands/join.ts +59 -7
  99. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  100. package/packages/irc-core/src/commands/registration.ts +71 -14
  101. package/packages/irc-core/src/commands/sasl.ts +251 -0
  102. package/packages/irc-core/src/config.ts +247 -0
  103. package/packages/irc-core/src/flood-control.ts +175 -0
  104. package/packages/irc-core/src/index.ts +5 -0
  105. package/packages/irc-core/src/ports.ts +655 -0
  106. package/packages/irc-core/src/protocol/base64.ts +16 -0
  107. package/packages/irc-core/src/protocol/index.ts +2 -1
  108. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  109. package/packages/irc-core/src/protocol/parser.ts +27 -2
  110. package/packages/irc-core/src/state/connection.ts +41 -0
  111. package/packages/irc-core/src/types.ts +66 -2
  112. package/packages/irc-core/stryker.commands.conf.json +41 -0
  113. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  114. package/packages/irc-core/tests/admission.test.ts +229 -0
  115. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  116. package/packages/irc-core/tests/cloak.test.ts +78 -0
  117. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  118. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  119. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  120. package/packages/irc-core/tests/commands/registration.test.ts +277 -9
  121. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  122. package/packages/irc-core/tests/config.test.ts +646 -0
  123. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  124. package/packages/irc-core/tests/message-store.test.ts +530 -0
  125. package/packages/irc-core/tests/parser.test.ts +44 -1
  126. package/packages/irc-core/tests/ports.test.ts +263 -0
  127. package/packages/irc-server/package.json +1 -1
  128. package/packages/irc-server/src/actor.ts +162 -44
  129. package/packages/irc-server/src/dispatch.ts +89 -5
  130. package/packages/irc-server/src/index.ts +2 -0
  131. package/packages/irc-server/src/routing.ts +151 -0
  132. package/packages/irc-server/tests/actor.test.ts +470 -14
  133. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  134. package/packages/irc-server/tests/routing.test.ts +201 -0
  135. package/packages/irc-test-support/README.md +32 -0
  136. package/packages/irc-test-support/package.json +37 -0
  137. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
  138. package/packages/irc-test-support/src/index.ts +24 -0
  139. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  140. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  141. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  142. package/packages/irc-test-support/tsconfig.build.json +16 -0
  143. package/packages/irc-test-support/tsconfig.test.json +14 -0
  144. package/packages/irc-test-support/vitest.config.ts +20 -0
  145. package/progress.md +107 -0
  146. package/tickets.md +2485 -0
  147. package/tools/ci-hardening/package.json +30 -0
  148. package/tools/ci-hardening/src/index.ts +6 -0
  149. package/tools/ci-hardening/src/validate.ts +103 -0
  150. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  151. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  152. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  153. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  154. package/tools/ci-hardening/tsconfig.build.json +12 -0
  155. package/tools/ci-hardening/tsconfig.test.json +10 -0
  156. package/tools/ci-hardening/vitest.config.ts +21 -0
  157. package/tools/tcp-ws-forwarder/package.json +1 -1
  158. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  159. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  160. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  161. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  162. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  163. package/webircgateway/LICENSE +201 -0
  164. package/webircgateway/Makefile +44 -0
  165. package/webircgateway/README.md +134 -0
  166. package/webircgateway/config.conf.example +135 -0
  167. package/webircgateway/go.mod +16 -0
  168. package/webircgateway/go.sum +89 -0
  169. package/webircgateway/main.go +118 -0
  170. package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
  171. package/webircgateway/pkg/identd/identd.go +86 -0
  172. package/webircgateway/pkg/identd/rpcclient.go +59 -0
  173. package/webircgateway/pkg/irc/isupport.go +56 -0
  174. package/webircgateway/pkg/irc/message.go +217 -0
  175. package/webircgateway/pkg/irc/state.go +79 -0
  176. package/webircgateway/pkg/proxy/proxy.go +129 -0
  177. package/webircgateway/pkg/proxy/server.go +237 -0
  178. package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
  179. package/webircgateway/pkg/webircgateway/client.go +741 -0
  180. package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
  181. package/webircgateway/pkg/webircgateway/config.go +385 -0
  182. package/webircgateway/pkg/webircgateway/gateway.go +278 -0
  183. package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
  184. package/webircgateway/pkg/webircgateway/hooks.go +152 -0
  185. package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
  186. package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
  187. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
  188. package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
  189. package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
  190. package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
  191. package/webircgateway/pkg/webircgateway/utils.go +147 -0
  192. package/webircgateway/plugins/example/plugin.go +11 -0
  193. package/webircgateway/plugins/stats/plugin.go +52 -0
  194. package/webircgateway/staticcheck.conf +1 -0
  195. package/webircgateway/webircgateway.svg +3 -0
  196. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  197. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  198. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -49,6 +49,8 @@ export interface IdFactory {
49
49
  nonce(): string;
50
50
  /** SASL / session-scoped id. */
51
51
  sessionId(): string;
52
+ /** Per-request observability trace id, propagated via {@link Ctx}. */
53
+ traceId(): string;
52
54
  }
53
55
 
54
56
  /**
@@ -67,6 +69,9 @@ export class UuidIdFactory implements IdFactory {
67
69
  sessionId(): string {
68
70
  return uuidV4();
69
71
  }
72
+ traceId(): string {
73
+ return uuidV4();
74
+ }
70
75
  }
71
76
 
72
77
  // RFC 4122 v4 shape (random). Six fixed bits identify the version/variant;
@@ -105,6 +110,9 @@ export class SequentialIdFactory implements IdFactory {
105
110
  sessionId(): string {
106
111
  return `session-${this.counter++}`;
107
112
  }
113
+ traceId(): string {
114
+ return `trace-${this.counter++}`;
115
+ }
108
116
  }
109
117
 
110
118
  /**
@@ -151,3 +159,650 @@ export class StaticMotdProvider implements MotdProvider {
151
159
 
152
160
  /** Convenience: a {@link MotdProvider} that always reports "no MOTD". */
153
161
  export const EmptyMotdProvider: MotdProvider = new StaticMotdProvider([]);
162
+
163
+ /**
164
+ * Parsed SASL credentials handed to an {@link AccountStore}.
165
+ *
166
+ * `PLAIN` is the structured form produced by base64-decoding and splitting
167
+ * the `\0authzid\0authcid\0passwd` payload. `RAW` carries the decoded bytes
168
+ * for any other mechanism the server wants to forward verbatim.
169
+ */
170
+ export type SaslPayload =
171
+ | { kind: 'PLAIN'; username: string; password: string }
172
+ | { kind: 'RAW'; data: string };
173
+
174
+ /**
175
+ * Outcome of an {@link AccountStore.verify} call: either the canonical
176
+ * account name to record on the connection, or a human-readable reason
177
+ * for the failure that surfaces as `904 ERR_SASLFAIL`.
178
+ */
179
+ export type SaslResult = { ok: true; account: string } | { ok: false; reason: string };
180
+
181
+ /**
182
+ * Port that verifies SASL credentials against the deployment's account
183
+ * backend.
184
+ *
185
+ * The method is **synchronous** on purpose: reducers must stay pure and
186
+ * cannot await. Adapters that need an async lookup (D1, DynamoDB, Secrets
187
+ * Manager) pre-load the credentials they need into a synchronously-readable
188
+ * store (mirroring the {@link MotdProvider} pattern). For Phase 1 the
189
+ * in-memory reference adapter and tests inject a fake directly.
190
+ */
191
+ export interface AccountStore {
192
+ verify(mech: string, payload: SaslPayload): SaslResult;
193
+ }
194
+
195
+ // ============================================================================
196
+ // Chat-history persistence (IRCv3 draft/chathistory)
197
+ // ============================================================================
198
+
199
+ /** IRC message commands that chathistory can replay. */
200
+ export type StoredCommand = 'PRIVMSG' | 'NOTICE' | 'TAGMSG';
201
+
202
+ /**
203
+ * One persisted channel message, as recorded by the PRIVMSG/NOTICE/TAGMSG
204
+ * reducers and replayed by chathistory.
205
+ *
206
+ * `chan` is the lowercased channel name (the storage key); the display name
207
+ * is reconstructed by the reducer from the authoritative {@link ChannelState}
208
+ * when building replay lines. `time` is epoch milliseconds from the injected
209
+ * {@link Clock}; `msgid` is a fresh nonce from {@link IdFactory}.
210
+ */
211
+ export interface StoredMessage {
212
+ msgid: string;
213
+ time: number;
214
+ chan: string;
215
+ command: StoredCommand;
216
+ nick: string;
217
+ user?: string;
218
+ host?: string;
219
+ text: string;
220
+ }
221
+
222
+ /** Subcommands of `CHATHISTORY` that select a slice of the backlog. */
223
+ export type HistoryDirection = 'latest' | 'before' | 'after' | 'between' | 'around';
224
+
225
+ /**
226
+ * One chathistory query. The reducer builds this from the parsed command and
227
+ * hands it to {@link MessageStore.query}; results come back synchronously.
228
+ *
229
+ * `msgid` is the pivot for `before` / `after` / `around` and the first bound
230
+ * for `between`; `msgid2` is the second bound for `between`. For `latest`,
231
+ * `msgid` may be omitted or the literal `*` to mean "most recent".
232
+ */
233
+ export interface HistoryQuery {
234
+ chan: string;
235
+ direction: HistoryDirection;
236
+ limit: number;
237
+ msgid?: string;
238
+ msgid2?: string;
239
+ }
240
+
241
+ /** One entry in a `CHATHISTORY TARGETS` reply: a channel with recent activity. */
242
+ export interface TargetEntry {
243
+ /** Lowercased channel name. */
244
+ chan: string;
245
+ /** Epoch ms of the most recent in-window message for this channel. */
246
+ time: number;
247
+ }
248
+
249
+ /**
250
+ * Persistence port for IRCv3 `draft/chathistory` playback.
251
+ *
252
+ * Mirrors the {@link MotdProvider} / {@link AccountStore} pattern: the port
253
+ * is **synchronous** (reducers must stay pure and free of IO) and injected
254
+ * via {@link Ctx}. Adapters that need async storage (D1, R2, DynamoDB)
255
+ * pre-load the relevant slice into a synchronously-readable store; Phase 1
256
+ * ships the in-memory reference implementation only.
257
+ *
258
+ * `query` always returns messages oldest-first (chronological order), the
259
+ * shape `BATCH chathistory` expects on the wire.
260
+ */
261
+ export interface MessageStore {
262
+ /** Appends a message to the channel's history (eviction policy is impl-defined). */
263
+ record(message: StoredMessage): void;
264
+ /** Returns the slice described by `query`, oldest-first. */
265
+ query(q: HistoryQuery): StoredMessage[];
266
+ /**
267
+ * Returns the most recent `limit` messages strictly after `sinceMsgid`
268
+ * (or the most recent `limit` overall when `sinceMsgid` is undefined or
269
+ * no longer retained), oldest-first. This is the JOIN auto-playback query
270
+ * and differs from the `after` direction, which returns the *first* `limit`
271
+ * messages after a pivot.
272
+ */
273
+ recent(chan: string, sinceMsgid: string | undefined, limit: number): StoredMessage[];
274
+ /** Returns true iff `msgid` is currently retained for `chan`. */
275
+ hasMsgid(chan: string, msgid: string): boolean;
276
+ /** Enumerates channels with activity inside `[since, until]`, newest-first. */
277
+ targets(since: number, until: number): TargetEntry[];
278
+ }
279
+
280
+ /** Default per-channel retention cap when none is supplied. */
281
+ const DEFAULT_MAX_PER_CHANNEL = 100;
282
+
283
+ /** Sentinel meaning "most recent" for the `LATEST` direction pivot. */
284
+ const LATEST_WILDCARD = '*';
285
+
286
+ /**
287
+ * Reference in-memory {@link MessageStore} backed by a per-channel ring
288
+ * buffer with a configurable cap.
289
+ *
290
+ * Used by the local CLI and by tests. Each channel keeps its messages in a
291
+ * chronological array; once the array exceeds `maxPerChannel` the oldest
292
+ * entries are dropped. A `msgid → message` index accelerates pivot lookups
293
+ * and {@link hasMsgid}.
294
+ */
295
+ export class InMemoryMessageStore implements MessageStore {
296
+ private readonly byChan = new Map<string, StoredMessage[]>();
297
+ private readonly maxPerChannel: number;
298
+
299
+ constructor(maxPerChannel: number = DEFAULT_MAX_PER_CHANNEL) {
300
+ this.maxPerChannel = maxPerChannel;
301
+ }
302
+
303
+ record(message: StoredMessage): void {
304
+ const key = message.chan.toLowerCase();
305
+ let arr = this.byChan.get(key);
306
+ if (arr === undefined) {
307
+ arr = [];
308
+ this.byChan.set(key, arr);
309
+ }
310
+ arr.push(message);
311
+ if (arr.length > this.maxPerChannel) {
312
+ arr.splice(0, arr.length - this.maxPerChannel);
313
+ }
314
+ }
315
+
316
+ query(q: HistoryQuery): StoredMessage[] {
317
+ if (q.limit <= 0) return [];
318
+ const arr = this.byChan.get(q.chan.toLowerCase());
319
+ if (arr === undefined || arr.length === 0) return [];
320
+
321
+ switch (q.direction) {
322
+ case 'latest':
323
+ return this.queryLatest(arr, q);
324
+ case 'before':
325
+ return this.queryBefore(arr, q);
326
+ case 'after':
327
+ return this.queryAfter(arr, q);
328
+ case 'around':
329
+ return this.queryAround(arr, q);
330
+ case 'between':
331
+ return this.queryBetween(arr, q);
332
+ default:
333
+ return [];
334
+ }
335
+ }
336
+
337
+ hasMsgid(chan: string, msgid: string): boolean {
338
+ const arr = this.byChan.get(chan.toLowerCase());
339
+ if (arr === undefined) return false;
340
+ return arr.some((m) => m.msgid === msgid);
341
+ }
342
+
343
+ recent(chan: string, sinceMsgid: string | undefined, limit: number): StoredMessage[] {
344
+ if (limit <= 0) return [];
345
+ const arr = this.byChan.get(chan.toLowerCase());
346
+ if (arr === undefined || arr.length === 0) return [];
347
+ let start = 0;
348
+ if (sinceMsgid !== undefined) {
349
+ const idx = indexOfMsgid(arr, sinceMsgid);
350
+ // If the marker was evicted, fall back to the most recent `limit`
351
+ // overall rather than skipping playback entirely.
352
+ if (idx !== -1) start = idx + 1;
353
+ }
354
+ return tail(arr.slice(start), limit);
355
+ }
356
+
357
+ targets(since: number, until: number): TargetEntry[] {
358
+ const out: TargetEntry[] = [];
359
+ for (const [chan, arr] of this.byChan) {
360
+ let best = -1;
361
+ for (const m of arr) {
362
+ if (m.time >= since && m.time <= until && m.time > best) best = m.time;
363
+ }
364
+ if (best !== -1) out.push({ chan, time: best });
365
+ }
366
+ out.sort((a, b) => b.time - a.time);
367
+ return out;
368
+ }
369
+
370
+ private queryLatest(arr: StoredMessage[], q: HistoryQuery): StoredMessage[] {
371
+ if (q.msgid !== undefined && q.msgid !== LATEST_WILDCARD) {
372
+ const idx = indexOfMsgid(arr, q.msgid);
373
+ if (idx === -1) return [];
374
+ const slice = arr.slice(0, idx + 1);
375
+ return tail(slice, q.limit);
376
+ }
377
+ return tail(arr, q.limit);
378
+ }
379
+
380
+ private queryBefore(arr: StoredMessage[], q: HistoryQuery): StoredMessage[] {
381
+ const pivot = q.msgid;
382
+ if (pivot === undefined) return [];
383
+ const idx = indexOfMsgid(arr, pivot);
384
+ if (idx === -1) return [];
385
+ return tail(arr.slice(0, idx), q.limit);
386
+ }
387
+
388
+ private queryAfter(arr: StoredMessage[], q: HistoryQuery): StoredMessage[] {
389
+ const pivot = q.msgid;
390
+ if (pivot === undefined) return [];
391
+ const idx = indexOfMsgid(arr, pivot);
392
+ if (idx === -1) return [];
393
+ return arr.slice(idx + 1, idx + 1 + q.limit);
394
+ }
395
+
396
+ private queryAround(arr: StoredMessage[], q: HistoryQuery): StoredMessage[] {
397
+ const pivot = q.msgid;
398
+ if (pivot === undefined) return [];
399
+ const idx = indexOfMsgid(arr, pivot);
400
+ if (idx === -1) return [];
401
+ const half = Math.floor(q.limit / 2);
402
+ // Center a `limit`-sized window on the pivot, then slide it to stay in
403
+ // bounds so boundaries fill from the available side instead of shrinking.
404
+ let start = idx - half;
405
+ if (start < 0) start = 0;
406
+ if (start + q.limit > arr.length) start = arr.length - q.limit;
407
+ if (start < 0) start = 0;
408
+ return arr.slice(start, start + q.limit);
409
+ }
410
+
411
+ private queryBetween(arr: StoredMessage[], q: HistoryQuery): StoredMessage[] {
412
+ const lo = q.msgid;
413
+ const hi = q.msgid2;
414
+ if (lo === undefined || hi === undefined) return [];
415
+ const loIdx = indexOfMsgid(arr, lo);
416
+ const hiIdx = indexOfMsgid(arr, hi);
417
+ if (loIdx === -1 || hiIdx === -1) return [];
418
+ if (loIdx >= hiIdx) return [];
419
+ return arr.slice(loIdx + 1, hiIdx + 1);
420
+ }
421
+ }
422
+
423
+ /** Returns the index of the first message with `msgid`, or -1. */
424
+ function indexOfMsgid(arr: readonly StoredMessage[], msgid: string): number {
425
+ for (let i = 0; i < arr.length; i++) {
426
+ const entry = arr[i];
427
+ if (entry !== undefined && entry.msgid === msgid) return i;
428
+ }
429
+ return -1;
430
+ }
431
+
432
+ /** Returns the last (newest) up-to-`n` entries of `arr`, in original order. */
433
+ function tail<T>(arr: readonly T[], n: number): T[] {
434
+ // Callers (query/recent) pre-validate `n > 0`; the Math.max below also
435
+ // clamps to a valid slice, so an explicit `n <= 0` branch is unreachable.
436
+ const start = Math.max(0, arr.length - n);
437
+ return arr.slice(start);
438
+ }
439
+
440
+ // ============================================================================
441
+ // Observability — structured logger
442
+ // ============================================================================
443
+
444
+ /**
445
+ * Log severity level. Ordering matters: a logger configured with
446
+ * {@link LogLevel.Info} suppresses {@link LogLevel.Debug} records.
447
+ */
448
+ export enum LogLevel {
449
+ Debug = 10,
450
+ Info = 20,
451
+ Warn = 30,
452
+ Error = 40,
453
+ }
454
+
455
+ /**
456
+ * Human-readable label for a {@link LogLevel}, used as `level` in output.
457
+ *
458
+ * Exhaustive over the {@link LogLevel} enum — TypeScript verifies the
459
+ * switch covers every variant. Any unknown value would be a type hole,
460
+ * which is precisely what we want: callers cannot construct a fifth
461
+ * level without updating this mapping.
462
+ */
463
+ function levelLabel(level: LogLevel): string {
464
+ switch (level) {
465
+ case LogLevel.Debug:
466
+ return 'debug';
467
+ case LogLevel.Info:
468
+ return 'info';
469
+ case LogLevel.Warn:
470
+ return 'warn';
471
+ case LogLevel.Error:
472
+ return 'error';
473
+ }
474
+ }
475
+
476
+ /**
477
+ * One structured log record.
478
+ *
479
+ * `traceId` and `connectionId` are the cross-cutting identifiers
480
+ * (observability contract); reducers / dispatch / adapter layers
481
+ * stamp them via the bound {@link Logger} so a single request's path
482
+ * through the actor → dispatch → runtime pipeline can be reconstructed
483
+ * end-to-end.
484
+ *
485
+ * `fields` carries any per-record structured context beyond those two
486
+ * (channel name, command, byte length, error detail, …).
487
+ */
488
+ export interface LogRecord {
489
+ /** Epoch milliseconds from the injected {@link Clock}. */
490
+ ts: number;
491
+ /** Severity. */
492
+ level: LogLevel;
493
+ /** Short human-readable event name (e.g. `"receive"`, `"dispatch"`). */
494
+ msg: string;
495
+ /** Per-request trace id, propagated through {@link Ctx}. */
496
+ traceId?: string;
497
+ /** Connection id the event concerns, when relevant. */
498
+ connectionId?: string;
499
+ /** Per-event structured context. */
500
+ fields?: Record<string, unknown>;
501
+ }
502
+
503
+ /**
504
+ * Sink callback for low-level log routing (used by tests and adapters
505
+ * that want to layer metrics on top of each record).
506
+ */
507
+ export type LogSink = (record: LogRecord) => void;
508
+
509
+ /**
510
+ * Structured logger port.
511
+ *
512
+ * Reducers are pure and therefore must not call this directly; the
513
+ * actor / dispatch / adapter layers do. Each adapter injects a concrete
514
+ * implementation:
515
+ *
516
+ * - tests: {@link CapturingLogger}
517
+ * - local-cli / Lambda: {@link ConsoleLogger}
518
+ * - Cloudflare Workers: a `ConsoleLogger` whose sink writes to `console`
519
+ * (Workers' `console.log` streams into Workers Analytics Observability)
520
+ *
521
+ * `child()` returns a new logger bound to extra context fields — the
522
+ * canonical pattern is to bind `traceId` + `connectionId` once when the
523
+ * actor is constructed, then every record the actor emits carries them
524
+ * automatically.
525
+ */
526
+ export interface Logger {
527
+ debug(msg: string, fields?: Record<string, unknown>): void;
528
+ info(msg: string, fields?: Record<string, unknown>): void;
529
+ warn(msg: string, fields?: Record<string, unknown>): void;
530
+ error(msg: string, fields?: Record<string, unknown>): void;
531
+ /** Logs at the supplied level. Used by plumbing that maps levels dynamically. */
532
+ on(level: LogLevel, msg: string, fields?: Record<string, unknown>): void;
533
+ /** Returns a logger that stamps `fields` onto every record in addition to its parent's. */
534
+ child(fields: LoggerContext): Logger;
535
+ }
536
+
537
+ /** Cross-cutting fields every logger implementation recognises. */
538
+ export interface LoggerContext {
539
+ traceId?: string;
540
+ connectionId?: string;
541
+ // Allow arbitrary structured fields alongside the cross-cutting ones.
542
+ [key: string]: unknown;
543
+ }
544
+
545
+ /** Logger that drops every record. Default for unit tests of pure code. */
546
+ export class NoopLogger implements Logger {
547
+ constructor(private readonly minLevel: LogLevel = LogLevel.Debug) {}
548
+ debug(msg: string, fields?: Record<string, unknown>): void {
549
+ this.on(LogLevel.Debug, msg, fields);
550
+ }
551
+ info(msg: string, fields?: Record<string, unknown>): void {
552
+ this.on(LogLevel.Info, msg, fields);
553
+ }
554
+ warn(msg: string, fields?: Record<string, unknown>): void {
555
+ this.on(LogLevel.Warn, msg, fields);
556
+ }
557
+ error(msg: string, fields?: Record<string, unknown>): void {
558
+ this.on(LogLevel.Error, msg, fields);
559
+ }
560
+ on(level: LogLevel, _msg: string, _fields?: Record<string, unknown>): void {
561
+ if (level < this.minLevel) return;
562
+ // No-op: unit tests of pure code do not want I/O during the run.
563
+ }
564
+ child(_fields: LoggerContext): Logger {
565
+ return this;
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Shared singleton: a {@link NoopLogger} with the lowest possible
571
+ * threshold. The canonical default for reducers / dispatch / actor paths
572
+ * when no concrete logger is supplied — every call becomes a no-op.
573
+ */
574
+ export const NoopLoggerInstance: Logger = new NoopLogger();
575
+
576
+ /**
577
+ * Test logger that retains every record in `records`. The `records` array
578
+ * is intentionally mutable so tests can assert ordering and shape directly.
579
+ *
580
+ * `traceId` / `connectionId` supplied in the constructor are stamped on
581
+ * every record — same pattern as production loggers. `child()` returns a
582
+ * new logger bound to extra context fields that shares the parent's
583
+ * `records` sink, so records emitted on a child are visible from the root.
584
+ */
585
+ export class CapturingLogger implements Logger {
586
+ readonly records: LogRecord[] = [];
587
+ private readonly ctx: LoggerContext;
588
+ private readonly minLevel: LogLevel;
589
+ private readonly clock: Clock;
590
+ /** @internal shared sink so child loggers push into the parent's array. */
591
+ readonly sink: LogRecord[];
592
+
593
+ constructor(ctx: LoggerContext = {}, minLevel: LogLevel = LogLevel.Debug, clock?: Clock) {
594
+ this.ctx = ctx;
595
+ this.minLevel = minLevel;
596
+ this.clock = clock ?? SystemClock;
597
+ this.sink = this.records;
598
+ }
599
+ debug(msg: string, fields?: Record<string, unknown>): void {
600
+ this.capture(LogLevel.Debug, msg, fields);
601
+ }
602
+ info(msg: string, fields?: Record<string, unknown>): void {
603
+ this.capture(LogLevel.Info, msg, fields);
604
+ }
605
+ warn(msg: string, fields?: Record<string, unknown>): void {
606
+ this.capture(LogLevel.Warn, msg, fields);
607
+ }
608
+ error(msg: string, fields?: Record<string, unknown>): void {
609
+ this.capture(LogLevel.Error, msg, fields);
610
+ }
611
+ on(level: LogLevel, msg: string, fields?: Record<string, unknown>): void {
612
+ this.capture(level, msg, fields);
613
+ }
614
+ child(fields: LoggerContext): Logger {
615
+ // Share the parent's records sink so a child's emissions are visible
616
+ // from the root logger the test holds.
617
+ return CapturingLogger.withSink(
618
+ this.sink,
619
+ { ...this.ctx, ...fields },
620
+ this.minLevel,
621
+ this.clock,
622
+ );
623
+ }
624
+ private static withSink(
625
+ sink: LogRecord[],
626
+ ctx: LoggerContext,
627
+ minLevel: LogLevel,
628
+ clock: Clock,
629
+ ): CapturingLogger {
630
+ const inst = new CapturingLogger(ctx, minLevel, clock);
631
+ // Reassign the per-instance `records`/`sink` to the shared parent sink
632
+ // so child records land in the parent's array.
633
+ (inst as { records: LogRecord[] }).records = sink;
634
+ (inst as { sink: LogRecord[] }).sink = sink;
635
+ return inst;
636
+ }
637
+ private capture(level: LogLevel, msg: string, fields?: Record<string, unknown>): void {
638
+ if (level < this.minLevel) return;
639
+ const ids = extractIds(this.ctx, fields);
640
+ const merged = mergeFields(this.ctx, fields);
641
+ const rec: LogRecord = {
642
+ ts: this.clock.now(),
643
+ level,
644
+ msg,
645
+ ...(ids.traceId !== undefined ? { traceId: ids.traceId } : {}),
646
+ ...(ids.connectionId !== undefined ? { connectionId: ids.connectionId } : {}),
647
+ ...(Object.keys(merged).length > 0 ? { fields: merged } : {}),
648
+ };
649
+ this.sink.push(rec);
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Extracts the cross-cutting identifiers from the logger
655
+ * context plus the per-call fields. Per-call values win — a child logger
656
+ * bound to `connectionId: c-1` can still override it on a single record.
657
+ */
658
+ function extractIds(
659
+ ctx: LoggerContext,
660
+ call: Record<string, unknown> | undefined,
661
+ ): { traceId?: string; connectionId?: string } {
662
+ const out: { traceId?: string; connectionId?: string } = {};
663
+ if (typeof ctx.traceId === 'string') out.traceId = ctx.traceId;
664
+ if (typeof ctx.connectionId === 'string') out.connectionId = ctx.connectionId;
665
+ if (call !== undefined) {
666
+ if (typeof call.traceId === 'string') out.traceId = call.traceId;
667
+ if (typeof call.connectionId === 'string') out.connectionId = call.connectionId;
668
+ }
669
+ return out;
670
+ }
671
+
672
+ /**
673
+ * Merges the per-context fields (excluding the cross-cutting ids) with the
674
+ * per-call fields. Call fields win on conflict. Cross-cutting ids are
675
+ * promoted to top-level `LogRecord` fields by the caller, so they are
676
+ * stripped from the merged `fields` bag to avoid duplication.
677
+ */
678
+ function mergeFields(
679
+ ctx: LoggerContext,
680
+ call: Record<string, unknown> | undefined,
681
+ ): Record<string, unknown> {
682
+ const out: Record<string, unknown> = {};
683
+ for (const [k, v] of Object.entries(ctx)) {
684
+ if (k === 'traceId' || k === 'connectionId') continue;
685
+ out[k] = v;
686
+ }
687
+ if (call !== undefined) {
688
+ for (const [k, v] of Object.entries(call)) {
689
+ if (k === 'traceId' || k === 'connectionId') continue;
690
+ out[k] = v;
691
+ }
692
+ }
693
+ return out;
694
+ }
695
+
696
+ /**
697
+ * Sink typealias for the four `console.*` methods. Adapters that want to
698
+ * fan-out to CloudWatch Logs or Workers Analytics can supply a custom
699
+ * sink; the default is the global `console`.
700
+ */
701
+ export interface ConsoleLoggerSinks {
702
+ debug(payload: string): void;
703
+ info(payload: string): void;
704
+ warn(payload: string): void;
705
+ error(payload: string): void;
706
+ }
707
+
708
+ /** Minimal structural shape of the global `console`. */
709
+ type GlobalConsole = Pick<ConsoleLoggerSinks, 'debug' | 'info' | 'warn' | 'error'>;
710
+
711
+ /**
712
+ * Logger that serialises each record as a single-line JSON string and
713
+ * writes it to the matching console sink.
714
+ *
715
+ * Used in production by `apps/local-cli` (Node), AWS Lambda (Node), and
716
+ * Cloudflare Workers (where `console.log` is captured by Workers
717
+ * Analytics Observability and surfaced in theWorkers Logpush / wrangler
718
+ * tail pipeline).
719
+ *
720
+ * Output shape: `{ ts, level, msg, traceId?, connectionId?, ...fields }`
721
+ * — one physical line per record (no embedded newlines) so downstream
722
+ * log shippers can split cleanly.
723
+ */
724
+ export class ConsoleLogger implements Logger {
725
+ private readonly ctx: LoggerContext;
726
+ private readonly minLevel: LogLevel;
727
+ private readonly clock: Clock;
728
+ private readonly sinks: ConsoleLoggerSinks;
729
+
730
+ constructor(
731
+ ctx: LoggerContext = {},
732
+ sinks?: ConsoleLoggerSinks,
733
+ minLevel: LogLevel = LogLevel.Debug,
734
+ clock?: Clock,
735
+ ) {
736
+ this.ctx = ctx;
737
+ this.minLevel = minLevel;
738
+ this.clock = clock ?? SystemClock;
739
+ this.sinks = sinks ?? globalThisConsoleSinks();
740
+ }
741
+ debug(msg: string, fields?: Record<string, unknown>): void {
742
+ this.write(LogLevel.Debug, msg, fields);
743
+ }
744
+ info(msg: string, fields?: Record<string, unknown>): void {
745
+ this.write(LogLevel.Info, msg, fields);
746
+ }
747
+ warn(msg: string, fields?: Record<string, unknown>): void {
748
+ this.write(LogLevel.Warn, msg, fields);
749
+ }
750
+ error(msg: string, fields?: Record<string, unknown>): void {
751
+ this.write(LogLevel.Error, msg, fields);
752
+ }
753
+ on(level: LogLevel, msg: string, fields?: Record<string, unknown>): void {
754
+ this.write(level, msg, fields);
755
+ }
756
+ child(fields: LoggerContext): Logger {
757
+ return new ConsoleLogger({ ...this.ctx, ...fields }, this.sinks, this.minLevel, this.clock);
758
+ }
759
+ private write(level: LogLevel, msg: string, fields?: Record<string, unknown>): void {
760
+ if (level < this.minLevel) return;
761
+ const ids = extractIds(this.ctx, fields);
762
+ const merged = mergeFields(this.ctx, fields);
763
+ const payload: Record<string, unknown> = {
764
+ ts: new Date(this.clock.now()).toISOString(),
765
+ level: levelLabel(level),
766
+ msg,
767
+ ...merged,
768
+ };
769
+ if (ids.traceId !== undefined) payload.traceId = ids.traceId;
770
+ if (ids.connectionId !== undefined) payload.connectionId = ids.connectionId;
771
+ const line = JSON.stringify(payload);
772
+ // Exhaustive over the LogLevel enum; TS verifies every variant is
773
+ // routed to its matching sink.
774
+ switch (level) {
775
+ case LogLevel.Debug:
776
+ this.sinks.debug(line);
777
+ return;
778
+ case LogLevel.Info:
779
+ this.sinks.info(line);
780
+ return;
781
+ case LogLevel.Warn:
782
+ this.sinks.warn(line);
783
+ return;
784
+ case LogLevel.Error:
785
+ this.sinks.error(line);
786
+ return;
787
+ }
788
+ }
789
+ }
790
+
791
+ /** Returns the global `console` as a {@link ConsoleLoggerSinks}. */
792
+ function globalThisConsoleSinks(): ConsoleLoggerSinks {
793
+ // `globalThis.console` is universal across Node 20, Workers, and Lambda.
794
+ // Reference it lazily so tests that stub the logger do not need to
795
+ // replace the global.
796
+ const c = (globalThis as { console?: GlobalConsole }).console;
797
+ if (c === undefined) {
798
+ // Defensive: a runtime without `globalThis.console`. Every level drops.
799
+ const drop = (): void => {};
800
+ return { debug: drop, info: drop, warn: drop, error: drop };
801
+ }
802
+ return {
803
+ debug: (p) => c.debug(p),
804
+ info: (p) => c.info(p),
805
+ warn: (p) => c.warn(p),
806
+ error: (p) => c.error(p),
807
+ };
808
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Typed base64 helpers backed by the global `atob` / `btoa` available on
3
+ * every supported runtime (Node 16+, Cloudflare Workers, browsers). The
4
+ * project pins `lib: ES2022` which omits these names from the type table,
5
+ * so this module re-exports them with proper signatures.
6
+ */
7
+
8
+ /** Decodes a base64 string to a Latin1/binary string (char per byte). */
9
+ export function decodeBase64(input: string): string {
10
+ return (globalThis as unknown as { atob: (s: string) => string }).atob(input);
11
+ }
12
+
13
+ /** Encodes a Latin1/binary string (char per byte, 0–255) to base64. */
14
+ export function encodeBase64(input: string): string {
15
+ return (globalThis as unknown as { btoa: (s: string) => string }).btoa(input);
16
+ }