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.
- package/.github/workflows/ci.yml +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +67 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +427 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +66 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +162 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/progress.md +107 -0
- package/tickets.md +2485 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /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
|
+
}
|