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.
- package/LICENSE +202 -0
- package/README.md +195 -0
- package/dist/src/actuator.d.ts +36 -0
- package/dist/src/actuator.js +295 -0
- package/dist/src/bus.d.ts +110 -0
- package/dist/src/bus.js +386 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +72 -0
- package/dist/src/client.d.ts +48 -0
- package/dist/src/client.js +433 -0
- package/dist/src/config.d.ts +27 -0
- package/dist/src/config.js +161 -0
- package/dist/src/default-log-context.yaml +19 -0
- package/dist/src/envelope.d.ts +44 -0
- package/dist/src/envelope.js +227 -0
- package/dist/src/event-stream.d.ts +120 -0
- package/dist/src/event-stream.js +359 -0
- package/dist/src/exceptions.d.ts +18 -0
- package/dist/src/exceptions.js +25 -0
- package/dist/src/index.d.ts +24 -0
- package/dist/src/index.js +21 -0
- package/dist/src/log-context.d.ts +24 -0
- package/dist/src/log-context.js +172 -0
- package/dist/src/log.d.ts +14 -0
- package/dist/src/log.js +113 -0
- package/dist/src/registry.d.ts +64 -0
- package/dist/src/registry.js +80 -0
- package/dist/src/server.d.ts +43 -0
- package/dist/src/server.js +343 -0
- package/dist/src/trace.d.ts +39 -0
- package/dist/src/trace.js +73 -0
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.js +2 -0
- package/package.json +55 -0
|
@@ -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 {};
|