mercury-composable 4.12.1

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.
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Event streaming: the multi-shot reply contract and the envelope-mode SSE dialect.
3
+ *
4
+ * The platform's native streaming pattern (all four runtimes): the caller provides
5
+ * a reply address; the callee streams events to it until a terminal signal. Each
6
+ * segment is one event to the caller's reply_to, marked with the reserved envelope
7
+ * header `x-event-stream: data | eof | exception`. On the Event-over-HTTP wire, the
8
+ * peer answers the one POST with a Server-Sent Events response in a hybrid dialect:
9
+ * envelope frames (the reserved SSE event name "envelope", one base64-encoded
10
+ * serialized envelope per frame) wherever envelope semantics matter - the head, the
11
+ * terminals and non-text segments - and raw SSE frames for plain text segments, so
12
+ * token relays stay near-zero overhead.
13
+ *
14
+ * EventStreamWriter is the producer helper - the engines' exact API:
15
+ *
16
+ * const out = EventStreamWriter.fromRequest(event); // an interceptor's envelope
17
+ * out.first(200, 'text/event-stream');
18
+ * out.write('hello'); // data segment
19
+ * out.writeNamed('tokens', { n: 2 }); // named (typed) SSE event
20
+ * out.close({ usage }); // end of transmission
21
+ * // or out.fail(e); // in-band failure
22
+ *
23
+ * Writes after close/fail are dropped (debug log), mirroring the engines. An
24
+ * in-band failure body carries the standard error key-values
25
+ * '{"type": "error", "status": n, "message": text}'.
26
+ */
27
+ import { appConfig } from './config.js';
28
+ import { EventEnvelope } from './envelope.js';
29
+ import { AppException } from './exceptions.js';
30
+ import { getLogger } from './log.js';
31
+ import { getTrace } from './trace.js';
32
+ import { defaultRegistry } from './registry.js';
33
+ /** reserved envelope header (internal protocol, never on the HTTP wire) */
34
+ export const X_EVENT_STREAM = 'x-event-stream';
35
+ /** optional companion on a data event: maps to the SSE "event:" field */
36
+ export const X_EVENT_NAME = 'x-event-name';
37
+ /** marker vocabulary - deliberately the engines' ObjectStream vocabulary */
38
+ export const DATA = 'data';
39
+ export const EOF = 'eof';
40
+ export const EXCEPTION = 'exception';
41
+ /**
42
+ * reserved SSE event name of the envelope-mode wire dialect: a frame with
43
+ * this name carries one base64-encoded serialized EventEnvelope
44
+ */
45
+ export const ENVELOPE = 'envelope';
46
+ export const X_TTL = 'x-ttl';
47
+ export const TEXT_EVENT_STREAM = 'text/event-stream';
48
+ export const STREAM_CALLER_REQUIRED = 'Streaming function requires a caller that accepts text/event-stream';
49
+ /** reserved envelope headers a raw SSE frame may carry without loss */
50
+ const RESERVED_HEADERS = new Set([X_EVENT_STREAM, X_EVENT_NAME, X_TTL]);
51
+ const log = getLogger('mercury.stream');
52
+ /** The x-event-stream marker (lowercased), or undefined for an unmarked envelope. */
53
+ export function streamSignal(event) {
54
+ for (const [key, value] of Object.entries(event.headers)) {
55
+ if (key.toLowerCase() === X_EVENT_STREAM) {
56
+ return value.toLowerCase();
57
+ }
58
+ }
59
+ return undefined;
60
+ }
61
+ /** The x-event-name companion header (the SSE "event:" field), if any. */
62
+ export function streamEventName(event) {
63
+ for (const [key, value] of Object.entries(event.headers)) {
64
+ if (key.toLowerCase() === X_EVENT_NAME) {
65
+ return value;
66
+ }
67
+ }
68
+ return undefined;
69
+ }
70
+ /** The message text of an unmarked error reply (objects render as JSON). */
71
+ export function errorText(body) {
72
+ if (body === undefined || body === null) {
73
+ return 'Stream failed';
74
+ }
75
+ return typeof body === 'string' ? body : JSON.stringify(body);
76
+ }
77
+ /** The standard error key-values: '{"type": "error", "status": n, "message": text}' */
78
+ export function errorBody(status, message) {
79
+ return { type: 'error', status, message };
80
+ }
81
+ /** An in-band exception envelope with the standard error body. */
82
+ export function exceptionEnvelope(status, message) {
83
+ return new EventEnvelope()
84
+ .setHeader(X_EVENT_STREAM, EXCEPTION)
85
+ .setStatus(status)
86
+ .setBody(errorBody(status, message));
87
+ }
88
+ /** One SSE frame: optional "event:" line, one "data:" line per text line. */
89
+ export function sseFrame(eventName, text) {
90
+ const lines = [];
91
+ if (eventName) {
92
+ lines.push(`event: ${eventName}\n`);
93
+ }
94
+ for (const line of text.split('\n')) {
95
+ lines.push(`data: ${line}\n`);
96
+ }
97
+ lines.push('\n');
98
+ return Buffer.from(lines.join(''), 'utf-8');
99
+ }
100
+ /**
101
+ * One envelope-mode wire frame: the envelope serialized verbatim - with the
102
+ * host-internal addressing cleared, because the consuming relay rewrites
103
+ * addressing to the original caller - as base64 under the reserved name.
104
+ */
105
+ export function envelopeFrame(event) {
106
+ const clone = EventEnvelope.fromMap(event.toMap());
107
+ clone.to = undefined;
108
+ clone.replyTo = undefined;
109
+ const encoded = Buffer.from(clone.toBytes()).toString('base64');
110
+ return sseFrame(ENVELOPE, encoded);
111
+ }
112
+ /**
113
+ * A data segment may ride a raw SSE frame only when the frame carries it
114
+ * losslessly: a 200 status, no custom envelope headers, a user event name
115
+ * clear of the reserved word, and a text (or empty) body without a carriage
116
+ * return - SSE normalizes line endings. Everything else takes the
117
+ * envelope-frame escape hatch.
118
+ */
119
+ export function rawStreamable(event) {
120
+ if (event.getStatus() !== 200) {
121
+ return false;
122
+ }
123
+ for (const [key, value] of Object.entries(event.headers)) {
124
+ const lowered = key.toLowerCase();
125
+ if (!RESERVED_HEADERS.has(lowered)) {
126
+ return false;
127
+ }
128
+ if (lowered === X_EVENT_NAME && value === ENVELOPE) {
129
+ return false;
130
+ }
131
+ }
132
+ const body = event.body;
133
+ return body === undefined || body === null ||
134
+ (typeof body === 'string' && !body.includes('\r'));
135
+ }
136
+ /**
137
+ * One envelope-mode data frame: the first event always rides an envelope
138
+ * frame (it carries the head control); a losslessly raw-able text segment
139
+ * rides a raw frame; a bare no-op segment carries nothing.
140
+ */
141
+ export function dataFrame(event, firstFrame) {
142
+ if (firstFrame || !rawStreamable(event)) {
143
+ return envelopeFrame(event);
144
+ }
145
+ if (event.body === undefined || event.body === null) {
146
+ return Buffer.alloc(0);
147
+ }
148
+ return sseFrame(streamEventName(event), event.body);
149
+ }
150
+ /**
151
+ * SSE keep-alive comment interval in ms (`event.stream.keep.alive`,
152
+ * default 30s; 0 disables - the engines' config key).
153
+ */
154
+ export function keepAliveMs() {
155
+ const raw = String(appConfig().getProperty('event.stream.keep.alive', '30s') ?? '30s')
156
+ .trim().toLowerCase();
157
+ if (raw === '0' || raw === '0s' || raw === '0ms' || raw === '0m') {
158
+ return 0;
159
+ }
160
+ const parse = (text) => Number.parseInt(text, 10);
161
+ let value;
162
+ if (raw.endsWith('ms')) {
163
+ value = parse(raw.slice(0, -2));
164
+ }
165
+ else if (raw.endsWith('s')) {
166
+ value = parse(raw.slice(0, -1)) * 1000;
167
+ }
168
+ else if (raw.endsWith('m')) {
169
+ value = parse(raw.slice(0, -1)) * 60_000;
170
+ }
171
+ else {
172
+ value = parse(raw) * 1000;
173
+ }
174
+ return Number.isFinite(value) ? value : 30_000;
175
+ }
176
+ /**
177
+ * Incremental SSE frame parser: byte-level line split (a newline is a single
178
+ * byte, so this is UTF-8 safe), one-leading-space value strip, comment/id/
179
+ * retry suppression, multi-line data joined per the SSE specification.
180
+ * Mirrors the engines' parsers.
181
+ */
182
+ export class SseParser {
183
+ pending = Buffer.alloc(0);
184
+ dataLines = [];
185
+ eventName;
186
+ /** Feed one body chunk; return the completed [event_name, data] events. */
187
+ feed(chunk) {
188
+ const buffer = Buffer.concat([this.pending, Buffer.from(chunk)]);
189
+ const events = [];
190
+ let start = 0;
191
+ for (let i = 0; i < buffer.length; i++) {
192
+ if (buffer[i] !== 0x0a) { // '\n'
193
+ continue;
194
+ }
195
+ const end = i > start && buffer[i - 1] === 0x0d ? i - 1 : i;
196
+ this.onLine(buffer.subarray(start, end).toString('utf-8'), events);
197
+ start = i + 1;
198
+ }
199
+ this.pending = Buffer.from(buffer.subarray(start));
200
+ return events;
201
+ }
202
+ /**
203
+ * One SSE line: a blank line dispatches the pending event; a comment line
204
+ * (leading colon) is consumed, never forwarded; id, retry and unknown
205
+ * fields are ignored (SSE specification).
206
+ */
207
+ onLine(line, events) {
208
+ if (!line) {
209
+ if (this.dataLines.length) {
210
+ events.push([this.eventName, this.dataLines.join('\n')]);
211
+ }
212
+ this.dataLines = [];
213
+ this.eventName = undefined;
214
+ return;
215
+ }
216
+ if (line.startsWith(':')) {
217
+ return;
218
+ }
219
+ const colon = line.indexOf(':');
220
+ const field = colon === -1 ? line : line.slice(0, colon);
221
+ let value = colon === -1 ? '' : line.slice(colon + 1);
222
+ if (value.startsWith(' ')) {
223
+ value = value.slice(1);
224
+ }
225
+ if (field === 'data') {
226
+ this.dataLines.push(value);
227
+ }
228
+ else if (field === 'event') {
229
+ this.eventName = value;
230
+ }
231
+ }
232
+ }
233
+ /**
234
+ * Producer helper for a multi-shot reply - the engines' exact API.
235
+ *
236
+ * Only an interceptor function can stream: it receives the raw envelope, so
237
+ * the caller-provided reply address travels the engines' way
238
+ * (EventStreamWriter.fromRequest(event) reads reply_to and the correlation
239
+ * id). Segments route to the LOCAL reply address through the primitive event
240
+ * bus - simple routing to a local function or reply sink, never across the
241
+ * wire (cross-wire replies ride the Event-over-HTTP SSE response, exactly as
242
+ * on the engines).
243
+ */
244
+ export class EventStreamWriter {
245
+ registry;
246
+ replyTo;
247
+ cid;
248
+ firstStatus = 200;
249
+ firstContentType;
250
+ firstTtlSeconds = 0;
251
+ headSent = false;
252
+ isClosed = false;
253
+ constructor(replyTo, correlationId, registry = defaultRegistry) {
254
+ if (!replyTo) {
255
+ throw new AppException(400, 'Streaming producer requires a reply_to address');
256
+ }
257
+ this.registry = registry;
258
+ this.replyTo = replyTo;
259
+ this.cid = correlationId;
260
+ }
261
+ /**
262
+ * Create a writer from the incoming request envelope (the usual form for
263
+ * an interceptor function).
264
+ */
265
+ static fromRequest(event, registry = defaultRegistry) {
266
+ return new EventStreamWriter(event.replyTo, event.cid, registry);
267
+ }
268
+ /**
269
+ * Optional head control carried by the first outgoing event: response
270
+ * status, content type, and an optional idle-allowance override in seconds
271
+ * between segments.
272
+ */
273
+ first(status, contentType, ttlSeconds) {
274
+ this.firstStatus = Math.trunc(status);
275
+ this.firstContentType = contentType;
276
+ if (ttlSeconds !== undefined) {
277
+ this.firstTtlSeconds = Math.trunc(ttlSeconds);
278
+ }
279
+ return this;
280
+ }
281
+ /** Send one `data` segment (text, bytes, object, array - any payload). */
282
+ write(segment) {
283
+ this.send(DATA, segment, undefined);
284
+ }
285
+ /** Send one named segment - the name maps to the SSE "event:" field. */
286
+ writeNamed(eventName, segment) {
287
+ this.send(DATA, segment, eventName);
288
+ }
289
+ /** Declare end of transmission, with optional trailing metadata. */
290
+ close(trailingMetadata) {
291
+ if (this.isClosed) {
292
+ return;
293
+ }
294
+ this.isClosed = true;
295
+ this.emit(this.envelope(EOF, trailingMetadata, undefined));
296
+ }
297
+ /** Declare an in-band failure and end the stream. */
298
+ fail(error) {
299
+ if (this.isClosed) {
300
+ return;
301
+ }
302
+ this.isClosed = true;
303
+ let status = error instanceof AppException ? error.status : 500;
304
+ status = status >= 400 ? status : 500;
305
+ const message = error.message || error.name;
306
+ const event = this.envelope(EXCEPTION, errorBody(status, message), undefined);
307
+ event.setStatus(status);
308
+ this.emit(event);
309
+ }
310
+ /** True when the stream has been closed or failed. */
311
+ get closed() {
312
+ return this.isClosed;
313
+ }
314
+ send(marker, body, eventName) {
315
+ if (this.isClosed) {
316
+ log.debug(`Segment to ${this.replyTo} dropped - stream already closed`);
317
+ return;
318
+ }
319
+ this.emit(this.envelope(marker, body, eventName));
320
+ }
321
+ envelope(marker, body, eventName) {
322
+ const event = new EventEnvelope(this.replyTo, body).setHeader(X_EVENT_STREAM, marker);
323
+ if (this.cid) {
324
+ event.setCorrelationId(this.cid);
325
+ }
326
+ if (eventName) {
327
+ event.setHeader(X_EVENT_NAME, eventName);
328
+ }
329
+ if (!this.headSent) {
330
+ this.headSent = true;
331
+ event.setStatus(this.firstStatus);
332
+ if (this.firstContentType) {
333
+ event.setHeader('content-type', this.firstContentType);
334
+ }
335
+ if (this.firstTtlSeconds > 0) {
336
+ event.setHeader(X_TTL, String(this.firstTtlSeconds));
337
+ }
338
+ }
339
+ // segments inherit the producer's identity, trace and span, so a
340
+ // consuming engine's per-segment delivery spans parent onto this
341
+ // function (the engines' po.send/touch parity)
342
+ const info = getTrace();
343
+ if (info?.route) {
344
+ event.setFrom(info.route);
345
+ }
346
+ if (info?.traceId) {
347
+ event.setTrace(info.traceId, info.tracePath ?? this.replyTo);
348
+ if (info.spanId) {
349
+ event.setSpanId(info.spanId);
350
+ }
351
+ }
352
+ return event;
353
+ }
354
+ emit(event) {
355
+ if (!this.registry.sendEvent(event)) {
356
+ log.warn(`Event dropped - route ${this.replyTo} not found`);
357
+ }
358
+ }
359
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * AppException mirrors the Java/Rust engines' AppException: an intentional
3
+ * application error carrying an HTTP-style status code and a message. Thrown
4
+ * from a function handler, it becomes the portable error contract on the
5
+ * wire: envelope status (>= 400) + body (the error message).
6
+ */
7
+ export declare class AppException extends Error {
8
+ readonly status: number;
9
+ constructor(status: number, message: string);
10
+ }
11
+ /**
12
+ * The payload used the classic compact wire format (single-character map
13
+ * keys). This implementation speaks the language-neutral standard format
14
+ * only; engines default to standard for Event over HTTP.
15
+ */
16
+ export declare class CompactFormatError extends Error {
17
+ constructor(message: string);
18
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * AppException mirrors the Java/Rust engines' AppException: an intentional
3
+ * application error carrying an HTTP-style status code and a message. Thrown
4
+ * from a function handler, it becomes the portable error contract on the
5
+ * wire: envelope status (>= 400) + body (the error message).
6
+ */
7
+ export class AppException extends Error {
8
+ status;
9
+ constructor(status, message) {
10
+ super(message);
11
+ this.name = 'AppException';
12
+ this.status = Math.trunc(status);
13
+ }
14
+ }
15
+ /**
16
+ * The payload used the classic compact wire format (single-character map
17
+ * keys). This implementation speaks the language-neutral standard format
18
+ * only; engines default to standard for Event over HTTP.
19
+ */
20
+ export class CompactFormatError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = 'CompactFormatError';
24
+ }
25
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Mercury Composable — polyglot functions for Node.js.
3
+ *
4
+ * A lightweight Event-over-HTTP function host and client: write decoupled
5
+ * functions in JavaScript/TypeScript and let Java/Rust Mercury engines
6
+ * orchestrate them from Event Script flows and MiniGraph knowledge graphs
7
+ * through the declarative yaml.event.over.http routing map. Orchestration
8
+ * stays in the engines; this package deliberately provides functions only,
9
+ * plus the minimalist utilities (configuration, logging, telemetry) shared
10
+ * with the engine style.
11
+ */
12
+ export { PostOffice } from './client.js';
13
+ export type { CallOptions } from './client.js';
14
+ export { AppConfig, appConfig, loadConfig } from './config.js';
15
+ export { EventEnvelope, isoUtc } from './envelope.js';
16
+ export { DATA, ENVELOPE, EOF, EventStreamWriter, EXCEPTION, SseParser, streamEventName, streamSignal, X_EVENT_NAME, X_EVENT_STREAM } from './event-stream.js';
17
+ export { AppException, CompactFormatError } from './exceptions.js';
18
+ export { getLogger, Logger } from './log.js';
19
+ export { defaultRegistry, FunctionRegistry, preload, validateRoute } from './registry.js';
20
+ export type { Handler, ServiceDef } from './registry.js';
21
+ export { EventApiServer, Platform, platform } from './server.js';
22
+ export { annotateTrace, getTrace, runWithTrace, updateContext } from './trace.js';
23
+ export type { TraceInfo } from './trace.js';
24
+ export { VERSION } from './version.js';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Mercury Composable — polyglot functions for Node.js.
3
+ *
4
+ * A lightweight Event-over-HTTP function host and client: write decoupled
5
+ * functions in JavaScript/TypeScript and let Java/Rust Mercury engines
6
+ * orchestrate them from Event Script flows and MiniGraph knowledge graphs
7
+ * through the declarative yaml.event.over.http routing map. Orchestration
8
+ * stays in the engines; this package deliberately provides functions only,
9
+ * plus the minimalist utilities (configuration, logging, telemetry) shared
10
+ * with the engine style.
11
+ */
12
+ export { PostOffice } from './client.js';
13
+ export { AppConfig, appConfig, loadConfig } from './config.js';
14
+ export { EventEnvelope, isoUtc } from './envelope.js';
15
+ export { DATA, ENVELOPE, EOF, EventStreamWriter, EXCEPTION, SseParser, streamEventName, streamSignal, X_EVENT_NAME, X_EVENT_STREAM } from './event-stream.js';
16
+ export { AppException, CompactFormatError } from './exceptions.js';
17
+ export { getLogger, Logger } from './log.js';
18
+ export { defaultRegistry, FunctionRegistry, preload, validateRoute } from './registry.js';
19
+ export { EventApiServer, Platform, platform } from './server.js';
20
+ export { annotateTrace, getTrace, runWithTrace, updateContext } from './trace.js';
21
+ export { VERSION } from './version.js';
@@ -0,0 +1,24 @@
1
+ import { TraceInfo } from './trace.js';
2
+ /** Parsed context template: output key -> reserved token or constant. */
3
+ export declare class LogContextConfig {
4
+ readonly enabled: boolean;
5
+ private readonly tokens;
6
+ private readonly constants;
7
+ constructor(template: Record<string, unknown> | undefined);
8
+ /**
9
+ * One template entry: a reserved $token, or a constant (env-resolved value
10
+ * or literal; an unset ${VAR} with no default resolves to undefined and is
11
+ * dropped) - the engines' parseEntry.
12
+ */
13
+ private parseEntry;
14
+ /**
15
+ * The context block for one log line: template tokens resolved live,
16
+ * constants, and the developer's custom key-values. Keys resolving to
17
+ * null/undefined are omitted.
18
+ */
19
+ render(info: TraceInfo): Record<string, unknown>;
20
+ }
21
+ /** The shared context template (loaded on first structured log line). */
22
+ export declare function logContextConfig(): LogContextConfig;
23
+ /** Test seam: reset so the next structured log line reloads the template. */
24
+ export declare function resetLogContextForTest(): void;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Application log context - the engines' app-log-context feature.
3
+ *
4
+ * When enabled (`app.log.context`, default true), the structured log
5
+ * presentations (log.format json/compact) add a `context` block to every log
6
+ * line written inside a traced request, so application logs and the
7
+ * distributed-trace telemetry stream correlate end to end in one aggregation.
8
+ *
9
+ * The context template mirrors the engines' contract exactly:
10
+ * - The built-in default template carries the standard trace context
11
+ * (cid, traceId, tracePath, spanId, parentSpanId, service, timestamp).
12
+ * - An application may replace it entirely with its own app-log-context.yaml
13
+ * in the resources folder (next to application.yml), mapping each output
14
+ * key to a reserved `$token` - resolved live per log line - or a constant
15
+ * (a literal, or `${ENV:default}` resolved once at load).
16
+ * - `app.log.context=false` opts out.
17
+ * - The `cid` token is the BUSINESS correlation-id only (the engine-managed
18
+ * my_cid tag); an internal routing id under the `cid` label would mislead
19
+ * log aggregation.
20
+ * - Developer-supplied key-values (updateContext) merge into the block; keys
21
+ * resolving to null are omitted, never "null".
22
+ */
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import { dirname, join } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { parse } from 'yaml';
27
+ import { appConfig } from './config.js';
28
+ import { isoUtc } from './envelope.js';
29
+ import { getLogger } from './log.js';
30
+ import { RESERVED_CONTEXT_TOKENS } from './trace.js';
31
+ const FEATURE_FLAG = 'app.log.context';
32
+ const CONFIG_FILE = 'app-log-context.yaml';
33
+ // the built-in default template ships as a packaged resource next to this
34
+ // module, exactly like the engines' classpath:/default-log-context.yaml
35
+ const DEFAULT_FILE = 'default-log-context.yaml';
36
+ /** The template's `context:` section, or undefined when absent/malformed. */
37
+ function contextSection(data) {
38
+ const section = data && typeof data === 'object'
39
+ ? data.context : undefined;
40
+ if (typeof section === 'object' && section !== null && !Array.isArray(section)) {
41
+ return section;
42
+ }
43
+ return undefined;
44
+ }
45
+ /** The built-in default template from the packaged default-log-context.yaml. */
46
+ function defaultTemplate() {
47
+ const candidate = join(dirname(fileURLToPath(import.meta.url)), DEFAULT_FILE);
48
+ if (!existsSync(candidate)) {
49
+ return undefined;
50
+ }
51
+ return contextSection(parse(readFileSync(candidate, 'utf-8')) ?? {});
52
+ }
53
+ /** Resolve a reserved token to its live value (undefined when absent). */
54
+ function tokenValue(info, token) {
55
+ switch (token) {
56
+ case 'cid': return info.myCorrelationId;
57
+ case 'traceId': return info.traceId;
58
+ case 'tracePath': return info.tracePath;
59
+ case 'spanId': return info.spanId;
60
+ case 'parentSpanId': return info.parentSpanId;
61
+ case 'service': return info.route;
62
+ case 'utc': return isoUtc();
63
+ default: return undefined;
64
+ }
65
+ }
66
+ /** Parsed context template: output key -> reserved token or constant. */
67
+ export class LogContextConfig {
68
+ enabled;
69
+ tokens = {};
70
+ constants = {};
71
+ constructor(template) {
72
+ for (const [outputKey, raw] of Object.entries(template ?? {})) {
73
+ this.parseEntry(outputKey, typeof raw === 'string' ? raw : String(raw));
74
+ }
75
+ this.enabled = Object.keys(this.tokens).length > 0
76
+ || Object.keys(this.constants).length > 0;
77
+ }
78
+ /**
79
+ * One template entry: a reserved $token, or a constant (env-resolved value
80
+ * or literal; an unset ${VAR} with no default resolves to undefined and is
81
+ * dropped) - the engines' parseEntry.
82
+ */
83
+ parseEntry(outputKey, value) {
84
+ if (value.startsWith('$') && !value.startsWith('${')) {
85
+ const token = value.slice(1);
86
+ if (!RESERVED_CONTEXT_TOKENS.has(token)) {
87
+ throw new Error(`Invalid log context token '${value}' for key ` +
88
+ `'${outputKey}' - allowed tokens: ` +
89
+ [...RESERVED_CONTEXT_TOKENS].sort((a, b) => a.localeCompare(b)).join(', '));
90
+ }
91
+ this.tokens[outputKey] = token;
92
+ return;
93
+ }
94
+ const resolved = appConfig().resolveText(value);
95
+ if (resolved !== undefined && resolved !== null) {
96
+ this.constants[outputKey] = resolved;
97
+ }
98
+ }
99
+ /**
100
+ * The context block for one log line: template tokens resolved live,
101
+ * constants, and the developer's custom key-values. Keys resolving to
102
+ * null/undefined are omitted.
103
+ */
104
+ render(info) {
105
+ const out = {};
106
+ for (const [outputKey, token] of Object.entries(this.tokens)) {
107
+ const value = tokenValue(info, token);
108
+ if (value !== undefined && value !== null) {
109
+ out[outputKey] = value;
110
+ }
111
+ }
112
+ Object.assign(out, this.constants);
113
+ for (const [key, value] of Object.entries(info.customContext ?? {})) {
114
+ if (value !== undefined && value !== null) {
115
+ out[key] = value;
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+ }
121
+ /**
122
+ * Resolve the active template; never logs itself - the caller emits the
123
+ * warning AFTER installing the config, so the log line (which renders through
124
+ * this feature) cannot re-enter.
125
+ */
126
+ function load() {
127
+ const config = appConfig();
128
+ if ((config.getProperty(FEATURE_FLAG, 'true') ?? 'true').toLowerCase() === 'false') {
129
+ return { config: new LogContextConfig(undefined) };
130
+ }
131
+ // an application override replaces the default entirely - same resources
132
+ // convention as application.yml
133
+ const source = config.source;
134
+ const folder = source !== 'none' ? dirname(source) : 'resources';
135
+ const candidate = join(folder || 'resources', CONFIG_FILE);
136
+ if (existsSync(candidate)) {
137
+ const section = contextSection(parse(readFileSync(candidate, 'utf-8')) ?? {});
138
+ if (!section) {
139
+ // the engines log a warning and disable; mirror the outcome
140
+ return {
141
+ config: new LogContextConfig(undefined),
142
+ warning: `Log context config has no 'context' section - feature disabled (${candidate})`
143
+ };
144
+ }
145
+ return { config: new LogContextConfig(section) };
146
+ }
147
+ const template = defaultTemplate();
148
+ if (!template) {
149
+ return {
150
+ config: new LogContextConfig(undefined),
151
+ warning: `Built-in ${DEFAULT_FILE} missing - log context feature disabled`
152
+ };
153
+ }
154
+ return { config: new LogContextConfig(template) };
155
+ }
156
+ let instance;
157
+ /** The shared context template (loaded on first structured log line). */
158
+ export function logContextConfig() {
159
+ if (!instance) {
160
+ const { config, warning } = load();
161
+ instance = config;
162
+ if (warning) {
163
+ // safe: the instance is installed first, so this line cannot re-enter
164
+ getLogger('mercury.log').warn(warning);
165
+ }
166
+ }
167
+ return instance;
168
+ }
169
+ /** Test seam: reset so the next structured log line reloads the template. */
170
+ export function resetLogContextForTest() {
171
+ instance = undefined;
172
+ }
@@ -0,0 +1,14 @@
1
+ declare const LEVELS: readonly ["DEBUG", "INFO", "WARN", "ERROR"];
2
+ export type Level = (typeof LEVELS)[number];
3
+ type LogMessage = string | Record<string, unknown>;
4
+ export declare class Logger {
5
+ private readonly name?;
6
+ constructor(name?: string | undefined);
7
+ debug(message: LogMessage, ...args: unknown[]): void;
8
+ info(message: LogMessage, ...args: unknown[]): void;
9
+ warn(message: LogMessage, ...args: unknown[]): void;
10
+ error(message: LogMessage, ...args: unknown[]): void;
11
+ }
12
+ /** A logger writing engine-consistent log lines. */
13
+ export declare function getLogger(name?: string): Logger;
14
+ export {};