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,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
|
+
}
|