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,433 @@
1
+ /**
2
+ * Thin Event-over-HTTP client — the PostOffice analog.
3
+ *
4
+ * Sends an event envelope to a peer's /api/event endpoint (a Java or Rust
5
+ * engine application, or another polyglot function host) with the same HTTP
6
+ * contract as the engines' relay: content-type application/octet-stream,
7
+ * accept */*, x-no-stream, x-ttl (ms), x-async for drop-n-forget, optional
8
+ * security headers, and trace headers (X-Trace-Id plus a W3C traceparent when
9
+ * the trace id is W3C-shaped).
10
+ *
11
+ * The decoded reply envelope is authoritative: an error from the target rides
12
+ * back as a normal envelope with status >= 400 — inspect reply.getStatus().
13
+ */
14
+ import { asyncAck, DeliveryTimeout, raceMs } from './bus.js';
15
+ import { EventEnvelope } from './envelope.js';
16
+ import { DATA, ENVELOPE, EOF, errorText, EXCEPTION, exceptionEnvelope, SseParser, STREAM_CALLER_REQUIRED, streamSignal, TEXT_EVENT_STREAM, X_EVENT_NAME, X_EVENT_STREAM } from './event-stream.js';
17
+ import { AppException } from './exceptions.js';
18
+ import { defaultRegistry } from './registry.js';
19
+ import { getTrace, MY_CID_TAG, RPC_TAG } from './trace.js';
20
+ const W3C_TRACE_ID = /^[0-9a-f]{32}$/;
21
+ const W3C_SPAN_ID = /^[0-9a-f]{16}$/;
22
+ export class PostOffice {
23
+ endpoint;
24
+ securityHeaders;
25
+ registry;
26
+ constructor(endpoint, securityHeaders = {}, registry = defaultRegistry) {
27
+ this.endpoint = endpoint;
28
+ this.securityHeaders = securityHeaders;
29
+ this.registry = registry;
30
+ }
31
+ /** In-app delivery through the primitive event bus (private OR public). */
32
+ async callLocal(route, body, options, isAsync) {
33
+ const service = this.registry.get(route);
34
+ if (!service) {
35
+ return new EventEnvelope().setStatus(404).setBody(`Route ${route} not found`);
36
+ }
37
+ const timeoutMs = options.timeoutMs ?? 30000;
38
+ const event = this.buildEvent(route, body, options);
39
+ if (!isAsync) {
40
+ // the engines' RPC round-trip marker: an RPC leg emits no trace
41
+ // dataset - its metrics fold into the caller's view
42
+ event.tags[RPC_TAG] ??= String(timeoutMs);
43
+ }
44
+ const trace = {
45
+ traceId: event.traceId, tracePath: event.tracePath, cid: event.cid,
46
+ envelope: event
47
+ };
48
+ const bus = this.registry.bus;
49
+ if (service.interceptor) {
50
+ if (isAsync) {
51
+ bus.publishEnvelope(service, event);
52
+ return asyncAck();
53
+ }
54
+ // RPC to an interceptor: a per-request reply sink is the reply address;
55
+ // the first envelope classifies exactly like the engines - unmarked =
56
+ // the reply; marked = a streaming target refusing a single-shot caller
57
+ const [sinkRoute, queue] = bus.openSink();
58
+ try {
59
+ bus.publishEnvelope(service, event.setReplyTo(sinkRoute));
60
+ const first = await raceMs(queue.next(), Math.max(100, timeoutMs));
61
+ if (!first) {
62
+ return new EventEnvelope().setStatus(408).setBody(`Timeout for ${timeoutMs} ms`);
63
+ }
64
+ if (streamSignal(first) !== undefined) {
65
+ return new EventEnvelope().setStatus(406).setBody(STREAM_CALLER_REQUIRED);
66
+ }
67
+ return first;
68
+ }
69
+ finally {
70
+ bus.closeSink(sinkRoute);
71
+ }
72
+ }
73
+ if (isAsync) {
74
+ return bus.publish(service, event.headers, event.body, trace);
75
+ }
76
+ try {
77
+ return await bus.deliver(service, event.headers, event.body, timeoutMs, trace);
78
+ }
79
+ catch (e) {
80
+ if (e instanceof DeliveryTimeout) {
81
+ return new EventEnvelope().setStatus(408).setBody(`Timeout for ${timeoutMs} ms`);
82
+ }
83
+ throw e;
84
+ }
85
+ }
86
+ httpHeaders(timeoutMs, isAsync, event) {
87
+ const headers = {
88
+ 'content-type': 'application/octet-stream',
89
+ 'accept': '*/*',
90
+ 'x-no-stream': 'true',
91
+ 'x-ttl': String(Math.max(100, timeoutMs))
92
+ };
93
+ if (isAsync)
94
+ headers['x-async'] = 'true';
95
+ for (const [key, value] of Object.entries(this.securityHeaders)) {
96
+ if (key.toLowerCase() !== 'x-event-format')
97
+ headers[key] = value;
98
+ }
99
+ if (event.traceId) {
100
+ headers['X-Trace-Id'] = event.traceId;
101
+ if (W3C_TRACE_ID.test(event.traceId) && event.spanId && W3C_SPAN_ID.test(event.spanId)) {
102
+ headers['traceparent'] = `00-${event.traceId}-${event.spanId}-01`;
103
+ }
104
+ }
105
+ return headers;
106
+ }
107
+ buildEvent(route, body, options) {
108
+ const event = new EventEnvelope(route, body, options.headers);
109
+ if (options.fromRoute)
110
+ event.setFrom(options.fromRoute);
111
+ const info = getTrace();
112
+ // fill the sender with the executing function's route (touch parity)
113
+ if (info?.route && !event.sender) {
114
+ event.setFrom(info.route);
115
+ }
116
+ if (info?.traceId)
117
+ event.setTrace(info.traceId, info.tracePath ?? route);
118
+ const cid = options.cid ?? info?.cid;
119
+ if (cid)
120
+ event.setCorrelationId(cid);
121
+ // propagate the business correlation-id to the next touch point as the
122
+ // engine-managed my_cid tag (the engines' PostOffice.touch parity) - the
123
+ // receiving host injects it as the read-only my_correlation_id header
124
+ if (info?.myCorrelationId && !(MY_CID_TAG in event.tags)) {
125
+ event.tags[MY_CID_TAG] = info.myCorrelationId;
126
+ }
127
+ // carry this execution's span so the receiver stores it as its
128
+ // parent_span_id (touch parity) - also lights up the traceparent header
129
+ if (info?.spanId) {
130
+ event.setSpanId(info.spanId);
131
+ }
132
+ return event;
133
+ }
134
+ async call(route, body, options, isAsync) {
135
+ const url = options.endpoint ?? this.endpoint;
136
+ if (!url) {
137
+ // no endpoint = local: the engines' semantics for an in-app po call
138
+ return this.callLocal(route, body, options, isAsync);
139
+ }
140
+ const timeoutMs = options.timeoutMs ?? 30000;
141
+ const event = this.buildEvent(route, body, options);
142
+ if (!isAsync) {
143
+ // the engines' RPC round-trip marker (see callLocal)
144
+ event.tags[RPC_TAG] ??= String(timeoutMs);
145
+ }
146
+ // +100 ms cushion so the HTTP client does not time out before the target
147
+ const response = await fetch(url, {
148
+ method: 'POST',
149
+ headers: this.httpHeaders(timeoutMs, isAsync, event),
150
+ body: event.toBytes(),
151
+ signal: AbortSignal.timeout(Math.max(100, timeoutMs) + 100)
152
+ });
153
+ const payload = new Uint8Array(await response.arrayBuffer());
154
+ try {
155
+ return EventEnvelope.fromBytes(payload);
156
+ }
157
+ catch (e) {
158
+ throw new AppException(response.status, `Invalid event-over-http response - ${e.message}`);
159
+ }
160
+ }
161
+ /** RPC call: returns the target function's reply envelope. */
162
+ async request(route, body, options = {}) {
163
+ return this.call(route, body, options, false);
164
+ }
165
+ /** Drop-n-forget: returns the peer's 202 delivery acknowledgement envelope. */
166
+ async send(route, body, options = {}) {
167
+ return this.call(route, body, options, true);
168
+ }
169
+ /**
170
+ * Consume a streaming function progressively - the same decoded envelopes
171
+ * an engine reply route receives: `data` segments, then the `eof` or
172
+ * `exception` terminal. A non-streaming target yields its one classic reply
173
+ * (opting in is always safe). `timeoutMs` is the idle allowance between
174
+ * segments; expiry, a truncated stream and a malformed dialect yield the
175
+ * in-band exception envelope, then end.
176
+ *
177
+ * Remote (an endpoint is given, or set on the constructor): the peer's
178
+ * /api/event answers the one POST with the envelope-mode SSE dialect.
179
+ * Local (no endpoint): the same first-envelope classification through a
180
+ * per-request reply sink on the primitive bus.
181
+ */
182
+ async *stream(route, body, options = {}) {
183
+ const url = options.endpoint ?? this.endpoint;
184
+ const timeoutMs = options.timeoutMs ?? 30000;
185
+ const event = this.buildEvent(route, body, options);
186
+ if (!url) {
187
+ yield* this.streamLocal(route, event, timeoutMs);
188
+ return;
189
+ }
190
+ yield* this.streamRemote(url, event, timeoutMs);
191
+ }
192
+ /**
193
+ * The relay form of stream() for composition: every decoded envelope
194
+ * forwards verbatim to the LOCAL replyTo route (typically the caller's own
195
+ * reply address, handed through by an interceptor), so segments flow
196
+ * remote peer -> this application -> the original caller with no
197
+ * buffering. Awaits and returns the last envelope (normally the terminal).
198
+ */
199
+ async streamTo(route, body, replyTo, options = {}) {
200
+ let last = new EventEnvelope().setStatus(500).setBody('Stream produced no events');
201
+ for await (const segment of this.stream(route, body, options)) {
202
+ last = segment;
203
+ const forward = EventEnvelope.fromMap(segment.toMap()).setTo(replyTo);
204
+ if (!this.registry.sendEvent(forward)) {
205
+ // the local consumer is gone - late segments are no-op drops
206
+ break;
207
+ }
208
+ }
209
+ return last;
210
+ }
211
+ async *streamLocal(route, event, timeoutMs) {
212
+ const service = this.registry.get(route);
213
+ if (!service) {
214
+ yield new EventEnvelope().setStatus(404).setBody(`Route ${route} not found`);
215
+ return;
216
+ }
217
+ if (!service.interceptor) {
218
+ // a plain function cannot stream - its single reply is the stream
219
+ yield await this.callLocal(route, event.body, {
220
+ headers: event.headers, timeoutMs, fromRoute: event.sender, cid: event.cid
221
+ }, false);
222
+ return;
223
+ }
224
+ const bus = this.registry.bus;
225
+ const [sinkRoute, queue] = bus.openSink();
226
+ try {
227
+ bus.publishEnvelope(service, event.setReplyTo(sinkRoute));
228
+ const idleMs = Math.max(100, timeoutMs);
229
+ let streaming = false;
230
+ for (;;) {
231
+ // a lost race terminates the wait, so a fresh next() per cycle is safe
232
+ const reply = await raceMs(queue.next(), idleMs);
233
+ if (!reply) {
234
+ const seconds = Math.trunc(idleMs / 1000);
235
+ yield exceptionEnvelope(408, `Timeout for ${seconds} seconds`);
236
+ return;
237
+ }
238
+ const [out, done] = classifySinkReply(reply, streaming);
239
+ streaming = true;
240
+ yield out;
241
+ if (done) {
242
+ return;
243
+ }
244
+ }
245
+ }
246
+ finally {
247
+ bus.closeSink(sinkRoute);
248
+ }
249
+ }
250
+ async *streamRemote(url, event, timeoutMs) {
251
+ const effectiveCid = event.cid;
252
+ const headers = this.httpHeaders(timeoutMs, false, event);
253
+ headers['accept'] = TEXT_EVENT_STREAM;
254
+ // no total limit - a healthy stream may outlive any fixed total; the
255
+ // per-read race below is the idle allowance between segments
256
+ const idleMs = Math.max(1000, timeoutMs);
257
+ const response = await fetch(url, {
258
+ method: 'POST',
259
+ headers,
260
+ body: event.toBytes()
261
+ });
262
+ const contentType = response.headers.get('content-type') ?? '';
263
+ if (!contentType.startsWith(TEXT_EVENT_STREAM)) {
264
+ // the peer answered single-shot (a non-streaming target, or an edge
265
+ // error) - the classic reply, decoded tolerantly
266
+ const payload = new Uint8Array(await response.arrayBuffer());
267
+ yield decodeSingleShot(payload, response.status);
268
+ return;
269
+ }
270
+ const body = response.body;
271
+ if (!body) {
272
+ yield relayGuard(500, 'Event stream ended without eof', effectiveCid);
273
+ return;
274
+ }
275
+ const reader = body.getReader();
276
+ try {
277
+ yield* relayFrames(reader, idleMs, effectiveCid);
278
+ }
279
+ catch (e) {
280
+ yield relayGuard(500, e.message ?? String(e), effectiveCid);
281
+ }
282
+ finally {
283
+ await reader.cancel().catch(() => undefined);
284
+ }
285
+ }
286
+ }
287
+ /**
288
+ * Pump the envelope-mode SSE frames of one response body: decoded envelopes
289
+ * out, ending at the terminal (frames after it are discarded); idle expiry
290
+ * and a transport end without a decoded terminal fail in-band.
291
+ */
292
+ async function* relayFrames(reader, idleMs, cid) {
293
+ const parser = new SseParser();
294
+ let headSeen = false;
295
+ for (;;) {
296
+ // a lost race terminates the stream, so a fresh read() per cycle is safe
297
+ const result = await raceMs(reader.read(), idleMs);
298
+ if (!result) {
299
+ const seconds = Math.trunc(idleMs / 1000);
300
+ yield relayGuard(408, `Timeout for ${seconds} seconds`, cid);
301
+ return;
302
+ }
303
+ if (result.done) {
304
+ // the dialect ends with a decoded terminal - a bare transport end is a
305
+ // truncation
306
+ yield relayGuard(500, 'Event stream ended without eof', cid);
307
+ return;
308
+ }
309
+ for (const [name, text] of parser.feed(result.value)) {
310
+ const [reply, terminal] = decodeFrame(name, text, headSeen, cid);
311
+ if (!reply) {
312
+ continue;
313
+ }
314
+ headSeen = true;
315
+ yield reply;
316
+ if (terminal) {
317
+ return; // frames after the terminal are discarded
318
+ }
319
+ }
320
+ }
321
+ }
322
+ /**
323
+ * Classify one reply-sink envelope exactly like the engines: unmarked before
324
+ * any segment = the classic single-shot answer; unmarked mid-stream = the
325
+ * bus's error contract for an uncaught interceptor exception (fails
326
+ * in-band); marked = a stream segment, terminal on eof/exception.
327
+ */
328
+ function classifySinkReply(reply, streaming) {
329
+ const marker = streamSignal(reply);
330
+ if (marker === undefined) {
331
+ if (streaming) {
332
+ return [exceptionEnvelope(reply.getStatus(), errorText(reply.body)), true];
333
+ }
334
+ return [reply, true];
335
+ }
336
+ return [reply, marker === EOF || marker === EXCEPTION];
337
+ }
338
+ /** An in-band exception envelope synthesized by the consuming relay. */
339
+ function relayGuard(status, message, cid) {
340
+ const event = exceptionEnvelope(status, message);
341
+ if (cid) {
342
+ event.setCorrelationId(cid);
343
+ }
344
+ return event;
345
+ }
346
+ /**
347
+ * Decode one SSE frame of the envelope-mode dialect: an "envelope" frame is
348
+ * one base64-encoded serialized envelope (the head, the terminals and
349
+ * non-text segments); any other frame is a raw text segment. Returns
350
+ * [envelope-or-null, terminal]. Dialect guards fail in-band: the first frame
351
+ * must be an envelope frame, and a malformed frame ends the stream.
352
+ */
353
+ function decodeFrame(name, text, headSeen, cid) {
354
+ if (name === ENVELOPE) {
355
+ return decodeEnvelopeFrame(text, cid);
356
+ }
357
+ if (!headSeen) {
358
+ // the dialect guarantees an envelope frame first (conformance guard)
359
+ return [relayGuard(500, 'Invalid event stream - missing envelope head', cid), true];
360
+ }
361
+ const segment = new EventEnvelope(undefined, text).setHeader(X_EVENT_STREAM, DATA);
362
+ if (name) {
363
+ segment.setHeader(X_EVENT_NAME, name);
364
+ }
365
+ if (cid) {
366
+ segment.setCorrelationId(cid);
367
+ }
368
+ return [segment, false];
369
+ }
370
+ /**
371
+ * One base64-encoded serialized envelope: the head, a terminal, or a
372
+ * non-text segment - addressing restored to the original caller; a
373
+ * malformed frame ends the stream in-band.
374
+ */
375
+ function decodeEnvelopeFrame(text, cid) {
376
+ let decoded;
377
+ try {
378
+ decoded = EventEnvelope.fromBytes(Buffer.from(text, 'base64'));
379
+ }
380
+ catch {
381
+ return [relayGuard(500, 'Invalid event stream - malformed envelope frame', cid), true];
382
+ }
383
+ decoded.to = undefined;
384
+ decoded.replyTo = undefined;
385
+ if (cid) {
386
+ decoded.setCorrelationId(cid);
387
+ }
388
+ const marker = streamSignal(decoded);
389
+ return [decoded, marker === EOF || marker === EXCEPTION];
390
+ }
391
+ /**
392
+ * Decode a single-shot Event-over-HTTP reply: a serialized envelope
393
+ * normally, with the classic tolerant handling of an edge-level REST error
394
+ * body ('{"type": "error", "status": n, "message": text}' JSON) and of a
395
+ * payload that is not a serialized envelope at all.
396
+ */
397
+ function decodeSingleShot(payload, httpStatus) {
398
+ if (!payload.length) {
399
+ return new EventEnvelope().setStatus(httpStatus);
400
+ }
401
+ try {
402
+ const reply = EventEnvelope.fromBytes(payload);
403
+ reply.replyTo = undefined;
404
+ return reply;
405
+ }
406
+ catch (e) {
407
+ const restError = restErrorReply(payload, httpStatus);
408
+ return restError ?? new EventEnvelope().setStatus(400)
409
+ .setBody(`Invalid event-over-http response - ${e.message}`);
410
+ }
411
+ }
412
+ /**
413
+ * An edge-level REST error arrives as JSON, not as a serialized envelope -
414
+ * unwrap it exactly as the classic relay does; null when it is not one.
415
+ */
416
+ function restErrorReply(payload, httpStatus) {
417
+ if (httpStatus < 400) {
418
+ return null;
419
+ }
420
+ try {
421
+ const data = JSON.parse(Buffer.from(payload).toString('utf-8'));
422
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
423
+ const record = data;
424
+ if (record['type'] === 'error' && typeof record['message'] === 'string') {
425
+ return new EventEnvelope().setStatus(httpStatus).setBody(record['message']);
426
+ }
427
+ }
428
+ }
429
+ catch {
430
+ // not a REST error body
431
+ }
432
+ return null;
433
+ }
@@ -0,0 +1,27 @@
1
+ export declare const DEFAULT_CANDIDATES: string[];
2
+ /** Extract -Dkey=value runtime overrides (Java/Rust engine syntax). */
3
+ export declare function parseDArgs(argv: string[]): Map<string, unknown>;
4
+ export declare class AppConfig {
5
+ private store;
6
+ private readonly overrides;
7
+ readonly source: string;
8
+ constructor(path?: string, argv?: string[]);
9
+ private load;
10
+ /** Runtime override, checked first on every read (f:setConfig analog). */
11
+ set(key: string, value: unknown): void;
12
+ get(key: string, defaultValue?: unknown): unknown;
13
+ getProperty(key: string, defaultValue?: string): string | undefined;
14
+ /**
15
+ * Resolve ${ENV:default} substitution in a text value - the same rules as
16
+ * configuration values (used by companion config files such as
17
+ * app-log-context.yaml).
18
+ */
19
+ resolveText(value: string): unknown;
20
+ exists(key: string): boolean;
21
+ private substitute;
22
+ private resolveRef;
23
+ }
24
+ /** The shared AppConfig singleton (created on first use). */
25
+ export declare function appConfig(): AppConfig;
26
+ /** Replace the shared AppConfig (used by the CLI before startup). */
27
+ export declare function loadConfig(path?: string): AppConfig;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Minimalist configuration management, consistent with the Mercury engines.
3
+ *
4
+ * Same conventions as the Java/Rust AppConfigReader: configuration lives in
5
+ * the resources folder (resources/application.yml | .yaml | .properties) with
6
+ * dotted keys; `-Dkey=value` command-line arguments are runtime parameter
7
+ * overrides checked first on every read — the same syntax as the Java
8
+ * engine's JVM system properties and the Rust port's -D arguments (the
9
+ * f:setConfig analog); `${ENV_VAR:default}` substitution resolves the
10
+ * environment first, then a base configuration key, then the default.
11
+ *
12
+ * Well-known keys shared with the engines: application.name,
13
+ * rest.server.port (default 8085), log.format (text | json pretty-printed |
14
+ * compact single-line JSONL), log.level (LOG_LEVEL environment variable
15
+ * wins).
16
+ */
17
+ import * as fs from 'node:fs';
18
+ import YAML from 'yaml';
19
+ import { asText } from './envelope.js';
20
+ const REF = /\$\{([^}]+)\}/g;
21
+ const WHOLE_REF = /^\$\{([^}]+)\}$/;
22
+ export const DEFAULT_CANDIDATES = [
23
+ 'resources/application.yml',
24
+ 'resources/application.yaml',
25
+ 'resources/application.properties'
26
+ ];
27
+ /** Extract -Dkey=value runtime overrides (Java/Rust engine syntax). */
28
+ export function parseDArgs(argv) {
29
+ const overrides = new Map();
30
+ for (const arg of argv) {
31
+ if (arg.startsWith('-D') && arg.includes('=')) {
32
+ const idx = arg.indexOf('=');
33
+ const key = arg.slice(2, idx).trim();
34
+ if (key)
35
+ overrides.set(key, arg.slice(idx + 1));
36
+ }
37
+ }
38
+ return overrides;
39
+ }
40
+ function flatten(prefix, node, out) {
41
+ if (node !== null && typeof node === 'object' && !Array.isArray(node)) {
42
+ for (const [k, v] of Object.entries(node)) {
43
+ flatten(prefix ? `${prefix}.${k}` : k, v, out);
44
+ }
45
+ }
46
+ else {
47
+ out.set(prefix, node);
48
+ }
49
+ }
50
+ function parseProperties(text) {
51
+ const result = new Map();
52
+ for (const rawLine of text.split(/\r?\n/)) {
53
+ const line = rawLine.trim();
54
+ if (!line || line.startsWith('#') || !line.includes('='))
55
+ continue;
56
+ const idx = line.indexOf('=');
57
+ result.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
58
+ }
59
+ return result;
60
+ }
61
+ export class AppConfig {
62
+ store = new Map();
63
+ overrides;
64
+ source;
65
+ constructor(path, argv) {
66
+ this.overrides = parseDArgs(argv ?? process.argv.slice(2));
67
+ const candidates = path ? [path] : DEFAULT_CANDIDATES;
68
+ let loaded = 'none';
69
+ for (const candidate of candidates) {
70
+ if (candidate && fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
71
+ this.load(candidate);
72
+ loaded = candidate;
73
+ break;
74
+ }
75
+ }
76
+ if (path && loaded === 'none') {
77
+ throw new Error(`Configuration file not found - ${path}`);
78
+ }
79
+ this.source = loaded;
80
+ }
81
+ load(path) {
82
+ const text = fs.readFileSync(path, 'utf-8');
83
+ if (path.endsWith('.yml') || path.endsWith('.yaml')) {
84
+ const data = YAML.parse(text) ?? {};
85
+ const flat = new Map();
86
+ flatten('', data, flat);
87
+ this.store = flat;
88
+ }
89
+ else {
90
+ this.store = parseProperties(text);
91
+ }
92
+ }
93
+ /** Runtime override, checked first on every read (f:setConfig analog). */
94
+ set(key, value) {
95
+ if (!key?.trim()) {
96
+ throw new Error('Config key must not be empty');
97
+ }
98
+ this.overrides.set(key, value);
99
+ }
100
+ get(key, defaultValue = undefined) {
101
+ if (this.overrides.has(key))
102
+ return this.overrides.get(key);
103
+ if (this.store.has(key)) {
104
+ const value = this.store.get(key);
105
+ return typeof value === 'string' ? this.substitute(value, defaultValue) : value;
106
+ }
107
+ return defaultValue;
108
+ }
109
+ getProperty(key, defaultValue) {
110
+ const value = this.get(key, defaultValue);
111
+ return value === undefined || value === null ? undefined : asText(value);
112
+ }
113
+ /**
114
+ * Resolve ${ENV:default} substitution in a text value - the same rules as
115
+ * configuration values (used by companion config files such as
116
+ * app-log-context.yaml).
117
+ */
118
+ resolveText(value) {
119
+ return this.substitute(value, undefined);
120
+ }
121
+ exists(key) {
122
+ return this.overrides.has(key) || this.store.has(key);
123
+ }
124
+ substitute(value, defaultValue) {
125
+ const whole = WHOLE_REF.exec(value.trim());
126
+ if (whole) {
127
+ const resolved = this.resolveRef(whole[1]);
128
+ return resolved === undefined ? defaultValue : resolved;
129
+ }
130
+ return value.replace(REF, (_m, ref) => {
131
+ const resolved = this.resolveRef(ref);
132
+ return resolved === undefined || resolved === null ? '' : asText(resolved);
133
+ });
134
+ }
135
+ resolveRef(ref) {
136
+ const idx = ref.indexOf(':');
137
+ const name = (idx === -1 ? ref : ref.slice(0, idx)).trim();
138
+ const fallback = idx === -1 ? undefined : ref.slice(idx + 1);
139
+ if (name in process.env)
140
+ return process.env[name];
141
+ if (this.store.has(name)) {
142
+ const base = this.store.get(name);
143
+ if (typeof base === 'string' && /\$\{[^}]+\}/.test(base)) {
144
+ return this.substitute(base, undefined);
145
+ }
146
+ return base;
147
+ }
148
+ return fallback;
149
+ }
150
+ }
151
+ let instance;
152
+ /** The shared AppConfig singleton (created on first use). */
153
+ export function appConfig() {
154
+ instance ??= new AppConfig();
155
+ return instance;
156
+ }
157
+ /** Replace the shared AppConfig (used by the CLI before startup). */
158
+ export function loadConfig(path) {
159
+ instance = new AppConfig(path);
160
+ return instance;
161
+ }
@@ -0,0 +1,19 @@
1
+ #
2
+ # Built-in default application log context (the engines' default-log-context.yaml twin).
3
+ #
4
+ # The log-context feature is ON by default using this template. It applies to the
5
+ # structured JSON presentations (log.format=json or compact).
6
+ #
7
+ # To customize the context block, provide your own app-log-context.yaml in the
8
+ # application's resources folder (next to application.yml) - it replaces this
9
+ # default entirely.
10
+ # To turn the feature off, set app.log.context=false in the configuration.
11
+ #
12
+ context:
13
+ cid: $cid
14
+ traceId: $traceId
15
+ tracePath: $tracePath
16
+ spanId: $spanId
17
+ parentSpanId: $parentSpanId
18
+ service: $service
19
+ timestamp: $utc
@@ -0,0 +1,44 @@
1
+ /** ISO-8601 UTC with millisecond precision, e.g. 2026-07-21T12:00:00.000Z */
2
+ export declare function isoUtc(date?: Date): string;
3
+ /** Render a scalar as text: primitives via String, structures via JSON. */
4
+ export declare function asText(value: unknown): string;
5
+ export declare class EventEnvelope {
6
+ id: string;
7
+ to?: string;
8
+ sender?: string;
9
+ replyTo?: string;
10
+ cid?: string;
11
+ traceId?: string;
12
+ tracePath?: string;
13
+ spanId?: string;
14
+ status?: number;
15
+ headers: Record<string, string>;
16
+ body: unknown;
17
+ execTime?: number;
18
+ roundTrip?: number;
19
+ tags: Record<string, string>;
20
+ annotations: Record<string, unknown>;
21
+ stack?: string;
22
+ objType?: string;
23
+ exception?: Uint8Array;
24
+ constructor(to?: string, body?: unknown, headers?: Record<string, string>);
25
+ setTo(route: string): this;
26
+ setFrom(route: string): this;
27
+ setHeader(key: string, value: unknown): this;
28
+ setBody(body: unknown): this;
29
+ setStatus(status: number): this;
30
+ setCorrelationId(cid: string): this;
31
+ setTrace(traceId: string, tracePath: string): this;
32
+ setSpanId(spanId: string): this;
33
+ setReplyTo(route?: string): this;
34
+ getStatus(): number;
35
+ hasError(): boolean;
36
+ toMap(): Record<string, unknown>;
37
+ toBytes(): Uint8Array<ArrayBuffer>;
38
+ /** The optional wire fields that arrive as strings (wire key -> assignment). */
39
+ private static copyStringFields;
40
+ /** The optional numeric fields (absent and nil are equivalent on the wire). */
41
+ private static copyNumericFields;
42
+ static fromMap(data: Record<string, unknown>): EventEnvelope;
43
+ static fromBytes(data: Uint8Array): EventEnvelope;
44
+ }