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,113 @@
1
+ /**
2
+ * Minimalist logging, presentation-consistent with the Mercury engines.
3
+ *
4
+ * Text lines follow the Java reference engine's log4j2 pattern:
5
+ *
6
+ * %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %logger:%line - %msg
7
+ *
8
+ * Level comes from the LOG_LEVEL environment variable when set (mirroring the
9
+ * engines), else the log.level configuration key, else INFO. log.format
10
+ * carries the engines' three presentations: text (default), json
11
+ * (pretty-printed) and compact (the same object on a single line - JSONL -
12
+ * for log aggregators). Inside a traced request, the JSON presentations add
13
+ * the application log "context" block (the engines' app-log-context feature -
14
+ * see log-context.ts).
15
+ */
16
+ import { appConfig } from './config.js';
17
+ import { logContextConfig } from './log-context.js';
18
+ import { getTrace } from './trace.js';
19
+ const LEVELS = ['DEBUG', 'INFO', 'WARN', 'ERROR'];
20
+ let configured = false;
21
+ let minLevel = 1; // INFO
22
+ let logFormat = 'text';
23
+ function setup() {
24
+ if (configured)
25
+ return;
26
+ const config = appConfig();
27
+ const levelName = (process.env.LOG_LEVEL ?? String(config.get('log.level', 'INFO'))).toUpperCase();
28
+ const idx = LEVELS.indexOf(levelName);
29
+ minLevel = idx === -1 ? 1 : idx;
30
+ logFormat = String(config.get('log.format', 'text')).toLowerCase();
31
+ configured = true;
32
+ }
33
+ function timestamp() {
34
+ const d = new Date();
35
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
36
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
37
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
38
+ }
39
+ function callSite() {
40
+ // frame 0 = Error, 1 = callSite, 2 = Logger method, 3 = the caller
41
+ const stack = new Error('log call site').stack?.split('\n') ?? [];
42
+ const frame = stack[4] ?? stack[3] ?? '';
43
+ // right-to-left parse of ".../name.js:LINE:COL[)]" - linear, no regex scanning
44
+ const end = frame.endsWith(')') ? frame.length - 1 : frame.length;
45
+ const colCut = frame.lastIndexOf(':', end - 1);
46
+ const lineCut = colCut > 0 ? frame.lastIndexOf(':', colCut - 1) : -1;
47
+ if (lineCut <= 0)
48
+ return 'unknown:0';
49
+ const line = frame.slice(lineCut + 1, colCut);
50
+ if (!/^\d+$/.test(line))
51
+ return 'unknown:0';
52
+ const start = Math.max(frame.lastIndexOf('/', lineCut), frame.lastIndexOf('\\', lineCut), frame.lastIndexOf('(', lineCut), frame.lastIndexOf(' ', lineCut)) + 1;
53
+ const name = frame.slice(start, lineCut).replace(/\.[cm]?js$/, '');
54
+ return `${name}:${line}`;
55
+ }
56
+ function renderMessage(message, args) {
57
+ // a structured (object) message stays structural in the JSON presentations
58
+ // and renders as compact JSON in text mode - used by the distributed-trace
59
+ // dataset records, which stdout log-ingest agents parse
60
+ if (typeof message === 'object' && message !== null && !args.length) {
61
+ return message;
62
+ }
63
+ const head = typeof message === 'string' ? message : JSON.stringify(message);
64
+ return args.length
65
+ ? `${head} ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`
66
+ : head;
67
+ }
68
+ function write(level, name, message, args) {
69
+ setup();
70
+ if (LEVELS.indexOf(level) < minLevel)
71
+ return;
72
+ const rendered = renderMessage(message, args);
73
+ const logger = name ?? callSite();
74
+ if (logFormat === 'json' || logFormat === 'compact') {
75
+ const entry = {
76
+ time: timestamp(),
77
+ level,
78
+ logger,
79
+ message: rendered
80
+ };
81
+ // the application log context (the engines' app-log-context feature):
82
+ // a "context" block on every structured line inside a traced request,
83
+ // correlating app logs with the distributed-trace telemetry stream
84
+ const info = getTrace();
85
+ if (info?.traceId) {
86
+ const contextConfig = logContextConfig();
87
+ if (contextConfig.enabled) {
88
+ entry.context = contextConfig.render(info);
89
+ }
90
+ }
91
+ // engine presentations: json = pretty-printed, compact = one line (JSONL)
92
+ const indent = logFormat === 'json' ? 2 : undefined;
93
+ process.stdout.write(JSON.stringify(entry, null, indent) + '\n');
94
+ }
95
+ else {
96
+ const text = typeof rendered === 'string' ? rendered : JSON.stringify(rendered);
97
+ process.stdout.write(`${timestamp()} ${level.padEnd(5)} ${logger} - ${text}\n`);
98
+ }
99
+ }
100
+ export class Logger {
101
+ name;
102
+ constructor(name) {
103
+ this.name = name;
104
+ }
105
+ debug(message, ...args) { write('DEBUG', this.name, message, args); }
106
+ info(message, ...args) { write('INFO', this.name, message, args); }
107
+ warn(message, ...args) { write('WARN', this.name, message, args); }
108
+ error(message, ...args) { write('ERROR', this.name, message, args); }
109
+ }
110
+ /** A logger writing engine-consistent log lines. */
111
+ export function getLogger(name) {
112
+ return new Logger(name);
113
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Function registry and the preload() registration, mirroring the engines'
3
+ * PreLoad vocabulary: route name, instance count (concurrency limit) and a
4
+ * private flag. Handlers take (headers, body) — the same two-part input as a
5
+ * TypedLambdaFunction — and return the reply body (or an EventEnvelope for
6
+ * full control of status and reply headers).
7
+ */
8
+ import { EventBus } from './bus.js';
9
+ import type { EventEnvelope } from './envelope.js';
10
+ /**
11
+ * A function handler returns the reply body, an EventEnvelope for full
12
+ * control of status and reply headers, or a promise of either - the bus
13
+ * awaits the result and discriminates with instanceof, so the honest
14
+ * static type is simply unknown.
15
+ *
16
+ * An INTERCEPTOR handler (the engines' EventInterceptor contract) receives
17
+ * the raw EventEnvelope as its second argument - reply_to and the
18
+ * correlation id travel the engines' way - replies manually via reply_to,
19
+ * and its return value is discarded. Streaming producers and relay
20
+ * functions are interceptors. The runtime discriminates by the service's
21
+ * interceptor flag, so one structural type covers both flavors.
22
+ */
23
+ export type Handler = (headers: Record<string, string>, body: unknown) => unknown;
24
+ export interface ServiceDef {
25
+ route: string;
26
+ handler: Handler;
27
+ instances: number;
28
+ isPrivate: boolean;
29
+ interceptor: boolean;
30
+ }
31
+ export declare function validateRoute(route: string): string;
32
+ export declare class FunctionRegistry {
33
+ readonly bus: EventBus;
34
+ private readonly services;
35
+ constructor();
36
+ register(route: string, handler: Handler, options?: {
37
+ instances?: number;
38
+ isPrivate?: boolean;
39
+ interceptor?: boolean;
40
+ }): ServiceDef;
41
+ /**
42
+ * The reply_to mechanism: deliver one envelope to a LOCAL reply sink or
43
+ * registered function, drop-n-forget (simple routing, never across the
44
+ * wire - cross-wire replies ride the Event-over-HTTP SSE response).
45
+ * Returns false when the target no longer exists, so a late segment is a
46
+ * no-op drop, the engines' semantics.
47
+ */
48
+ sendEvent(event: EventEnvelope): boolean;
49
+ get(route: string): ServiceDef | undefined;
50
+ exists(route: string): boolean;
51
+ routes(): ServiceDef[];
52
+ }
53
+ /** the default registry used by preload() and platform.run() */
54
+ export declare const defaultRegistry: FunctionRegistry;
55
+ /**
56
+ * Register a function handler under a route name (engine PreLoad analog).
57
+ *
58
+ * preload('hello.node', { instances: 10 }, async (headers, body) => ({ ok: true }));
59
+ */
60
+ export declare function preload(route: string, options: {
61
+ instances?: number;
62
+ isPrivate?: boolean;
63
+ interceptor?: boolean;
64
+ }, handler: Handler): void;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Function registry and the preload() registration, mirroring the engines'
3
+ * PreLoad vocabulary: route name, instance count (concurrency limit) and a
4
+ * private flag. Handlers take (headers, body) — the same two-part input as a
5
+ * TypedLambdaFunction — and return the reply body (or an EventEnvelope for
6
+ * full control of status and reply headers).
7
+ */
8
+ import { EventBus } from './bus.js';
9
+ const ROUTE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
10
+ export function validateRoute(route) {
11
+ const trimmed = (route ?? '').trim();
12
+ if (!ROUTE_PATTERN.test(trimmed) || !trimmed.includes('.')) {
13
+ throw new Error(`Invalid route name '${trimmed}' - use lowercase letters, digits, ` +
14
+ 'period, hyphen or underscore with at least one period');
15
+ }
16
+ return trimmed;
17
+ }
18
+ export class FunctionRegistry {
19
+ // the registry's own dispatch pipeline (see bus.ts) - shared by the
20
+ // HTTP host and the local side of PostOffice
21
+ bus = new EventBus();
22
+ services = new Map();
23
+ constructor() {
24
+ // reply routing for the bus's interceptor error contract
25
+ this.bus.bindRouter((event) => this.sendEvent(event));
26
+ }
27
+ register(route, handler, options = {}) {
28
+ const validated = validateRoute(route);
29
+ const service = {
30
+ route: validated,
31
+ handler,
32
+ instances: Math.max(1, Math.trunc(options.instances ?? 10)),
33
+ isPrivate: options.isPrivate ?? false,
34
+ interceptor: options.interceptor ?? false
35
+ };
36
+ this.services.set(validated, service);
37
+ return service;
38
+ }
39
+ /**
40
+ * The reply_to mechanism: deliver one envelope to a LOCAL reply sink or
41
+ * registered function, drop-n-forget (simple routing, never across the
42
+ * wire - cross-wire replies ride the Event-over-HTTP SSE response).
43
+ * Returns false when the target no longer exists, so a late segment is a
44
+ * no-op drop, the engines' semantics.
45
+ */
46
+ sendEvent(event) {
47
+ const route = event.to;
48
+ if (!route) {
49
+ return false;
50
+ }
51
+ if (this.bus.offerSink(route, event)) {
52
+ return true;
53
+ }
54
+ const service = this.services.get(route);
55
+ if (!service) {
56
+ return false;
57
+ }
58
+ this.bus.publishEnvelope(service, event);
59
+ return true;
60
+ }
61
+ get(route) {
62
+ return this.services.get(route);
63
+ }
64
+ exists(route) {
65
+ return this.services.has(route);
66
+ }
67
+ routes() {
68
+ return [...this.services.values()].sort((a, b) => a.route.localeCompare(b.route));
69
+ }
70
+ }
71
+ /** the default registry used by preload() and platform.run() */
72
+ export const defaultRegistry = new FunctionRegistry();
73
+ /**
74
+ * Register a function handler under a route name (engine PreLoad analog).
75
+ *
76
+ * preload('hello.node', { instances: 10 }, async (headers, body) => ({ ok: true }));
77
+ */
78
+ export function preload(route, options, handler) {
79
+ defaultRegistry.register(route, handler, options);
80
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The Event API host: POST /api/event, exactly as the engines speak it.
3
+ *
4
+ * Mirrors the Java engine's EventApiService: request body = envelope bytes;
5
+ * x-ttl (ms, floor 1000) bounds handler execution; x-async: true means
6
+ * drop-n-forget (HTTP 202 with an ack envelope). Transport-level failures
7
+ * (400 / 403 / 404 / 408) set the HTTP status; a handler's own outcome —
8
+ * including AppException and unexpected errors — rides HTTP 200 with the
9
+ * status inside the envelope, exactly like an engine function's reply.
10
+ * Reserved header hygiene: x-event-api and transported my_* keys are removed
11
+ * from the handler's header view; the my_cid tag is injected as the
12
+ * read-only my_correlation_id header.
13
+ * The host also serves the engines' actuator endpoints (/info, /info/routes,
14
+ * /env, /health, /livenessprobe) for operations and Kubernetes probes - see
15
+ * actuator.ts.
16
+ */
17
+ import * as http from 'node:http';
18
+ import { FunctionRegistry } from './registry.js';
19
+ export declare class EventApiServer {
20
+ readonly registry: FunctionRegistry;
21
+ private readonly actuator;
22
+ /** Thin ingress: protocol guards + header hygiene, then the registry's bus. */
23
+ constructor(registry?: FunctionRegistry);
24
+ private handleEvent;
25
+ /**
26
+ * Dispatch to an interceptor and classify its first reply exactly like the
27
+ * engines: unmarked = the classic single-shot response, byte-identical;
28
+ * marked = the envelope-mode SSE dialect for a caller that accepts
29
+ * text/event-stream, or the pinned 406 refusal for one that does not.
30
+ */
31
+ private dispatchInterceptor;
32
+ createServer(): http.Server;
33
+ }
34
+ export declare class Platform {
35
+ readonly registry: FunctionRegistry;
36
+ constructor(registry?: FunctionRegistry);
37
+ /** Start the Event API host. Resolves once listening; runs until stopped. */
38
+ run(options?: {
39
+ port?: number;
40
+ host?: string;
41
+ }): Promise<http.Server>;
42
+ }
43
+ export declare const platform: Platform;
@@ -0,0 +1,343 @@
1
+ /**
2
+ * The Event API host: POST /api/event, exactly as the engines speak it.
3
+ *
4
+ * Mirrors the Java engine's EventApiService: request body = envelope bytes;
5
+ * x-ttl (ms, floor 1000) bounds handler execution; x-async: true means
6
+ * drop-n-forget (HTTP 202 with an ack envelope). Transport-level failures
7
+ * (400 / 403 / 404 / 408) set the HTTP status; a handler's own outcome —
8
+ * including AppException and unexpected errors — rides HTTP 200 with the
9
+ * status inside the envelope, exactly like an engine function's reply.
10
+ * Reserved header hygiene: x-event-api and transported my_* keys are removed
11
+ * from the handler's header view; the my_cid tag is injected as the
12
+ * read-only my_correlation_id header.
13
+ * The host also serves the engines' actuator endpoints (/info, /info/routes,
14
+ * /env, /health, /livenessprobe) for operations and Kubernetes probes - see
15
+ * actuator.ts.
16
+ */
17
+ import * as http from 'node:http';
18
+ import { Actuator, sendError } from './actuator.js';
19
+ import { DeliveryTimeout, raceMs } from './bus.js';
20
+ import { appConfig } from './config.js';
21
+ import { EventEnvelope } from './envelope.js';
22
+ import { DATA, dataFrame, envelopeFrame, EOF, errorText, EXCEPTION, exceptionEnvelope, keepAliveMs, STREAM_CALLER_REQUIRED, streamSignal, TEXT_EVENT_STREAM, X_TTL } from './event-stream.js';
23
+ import { CompactFormatError } from './exceptions.js';
24
+ import { getLogger } from './log.js';
25
+ import { defaultRegistry } from './registry.js';
26
+ import { MY_CID_TAG, MY_CORRELATION_ID } from './trace.js';
27
+ const OCTET_STREAM = 'application/octet-stream';
28
+ // the engines' reserved route name for the Event-over-HTTP ingress
29
+ const EVENT_API_SERVICE = 'event.api.service';
30
+ const log = getLogger('mercury.server');
31
+ function transportError(res, status, message) {
32
+ const reply = new EventEnvelope().setStatus(status).setBody(message);
33
+ const bytes = reply.toBytes();
34
+ res.writeHead(status, { 'content-type': OCTET_STREAM, 'content-length': bytes.length });
35
+ res.end(bytes);
36
+ }
37
+ function handlerHeaders(event) {
38
+ const headers = {};
39
+ for (const [k, v] of Object.entries(event.headers)) {
40
+ const key = k.toLowerCase();
41
+ if (key !== 'x-event-api' && !key.startsWith('my_')) {
42
+ headers[k] = v;
43
+ }
44
+ }
45
+ const myCid = event.tags[MY_CID_TAG];
46
+ if (myCid) {
47
+ headers[MY_CORRELATION_ID] = myCid;
48
+ }
49
+ return headers;
50
+ }
51
+ export class EventApiServer {
52
+ registry;
53
+ actuator;
54
+ /** Thin ingress: protocol guards + header hygiene, then the registry's bus. */
55
+ constructor(registry = defaultRegistry) {
56
+ this.registry = registry;
57
+ this.actuator = new Actuator(registry);
58
+ }
59
+ async handleEvent(req, res, raw) {
60
+ const ttl = Math.max(1000, Number.parseInt(String(req.headers['x-ttl'] ?? '0'), 10) || 0);
61
+ const isAsync = req.headers['x-async'] === 'true';
62
+ let event;
63
+ try {
64
+ event = EventEnvelope.fromBytes(raw);
65
+ }
66
+ catch (e) {
67
+ if (e instanceof CompactFormatError || e instanceof Error) {
68
+ transportError(res, 400, e.message);
69
+ return;
70
+ }
71
+ transportError(res, 400, String(e));
72
+ return;
73
+ }
74
+ if (!event.to) {
75
+ transportError(res, 400, 'Missing routing path');
76
+ return;
77
+ }
78
+ const service = this.registry.get(event.to);
79
+ if (!service) {
80
+ transportError(res, 404, `Route ${event.to} not found`);
81
+ return;
82
+ }
83
+ if (service.isPrivate) {
84
+ transportError(res, 403, `${event.to} is private`);
85
+ return;
86
+ }
87
+ if (!event.sender) {
88
+ // the engines' EventApiService parity: its PostOffice fills the sender
89
+ // with its own route when the wire envelope carries none
90
+ event.setFrom(EVENT_API_SERVICE);
91
+ }
92
+ const headers = handlerHeaders(event);
93
+ const bus = this.registry.bus;
94
+ const trace = {
95
+ traceId: event.traceId, tracePath: event.tracePath, cid: event.cid,
96
+ envelope: event
97
+ };
98
+ if (isAsync) {
99
+ const ack = bus.publish(service, headers, event.body, trace);
100
+ const bytes = ack.toBytes();
101
+ res.writeHead(202, { 'content-type': OCTET_STREAM, 'content-length': bytes.length });
102
+ res.end(bytes);
103
+ return;
104
+ }
105
+ if (service.interceptor) {
106
+ // interceptor dispatch (the reply_to mechanism): the handler receives
107
+ // the raw envelope with a per-request reply sink as its reply address
108
+ // and answers manually - single-shot or streaming
109
+ const capable = String(req.headers['accept'] ?? '').includes(TEXT_EVENT_STREAM);
110
+ await this.dispatchInterceptor(res, service, event, headers, ttl, capable);
111
+ return;
112
+ }
113
+ try {
114
+ const reply = await bus.deliver(service, headers, event.body, ttl, trace);
115
+ log.info(`Handled ${event.to} status=${reply.getStatus()} ` +
116
+ `exec_time=${reply.execTime}ms trace_id=${event.traceId ?? 'none'}`);
117
+ const bytes = reply.toBytes();
118
+ res.writeHead(200, { 'content-type': OCTET_STREAM, 'content-length': bytes.length });
119
+ res.end(bytes);
120
+ }
121
+ catch (e) {
122
+ if (e instanceof DeliveryTimeout) {
123
+ log.warn(`Event ${event.to} timeout for ${ttl} ms (trace_id=${event.traceId ?? 'none'})`);
124
+ transportError(res, 408, `Timeout for ${ttl} ms`);
125
+ }
126
+ else {
127
+ transportError(res, 500, e.message ?? String(e));
128
+ }
129
+ }
130
+ }
131
+ /**
132
+ * Dispatch to an interceptor and classify its first reply exactly like the
133
+ * engines: unmarked = the classic single-shot response, byte-identical;
134
+ * marked = the envelope-mode SSE dialect for a caller that accepts
135
+ * text/event-stream, or the pinned 406 refusal for one that does not.
136
+ */
137
+ async dispatchInterceptor(res, service, event, headers, ttl, capable) {
138
+ const bus = this.registry.bus;
139
+ const [sinkRoute, queue] = bus.openSink();
140
+ try {
141
+ const handlerEvent = new EventEnvelope(event.to, event.body, headers);
142
+ handlerEvent.setReplyTo(sinkRoute);
143
+ if (Object.keys(event.tags).length) {
144
+ // engine-managed tags (e.g. the business correlation-id) ride the
145
+ // delivered envelope verbatim, the engines' way
146
+ handlerEvent.tags = { ...event.tags };
147
+ }
148
+ if (event.cid) {
149
+ handlerEvent.setCorrelationId(event.cid);
150
+ }
151
+ if (event.traceId) {
152
+ handlerEvent.setTrace(event.traceId, event.tracePath ?? service.route);
153
+ }
154
+ if (event.spanId) {
155
+ // the caller's span - the handler's span parents onto it
156
+ handlerEvent.setSpanId(event.spanId);
157
+ }
158
+ if (event.sender) {
159
+ handlerEvent.setFrom(event.sender);
160
+ }
161
+ bus.publishEnvelope(service, handlerEvent);
162
+ const first = await raceMs(queue.next(), Math.max(100, ttl));
163
+ if (!first) {
164
+ log.warn(`Event ${event.to} timeout for ${ttl} ms ` +
165
+ `(trace_id=${event.traceId ?? 'none'})`);
166
+ transportError(res, 408, `Timeout for ${ttl} ms`);
167
+ return;
168
+ }
169
+ const marker = streamSignal(first);
170
+ if (marker === undefined) {
171
+ // the classic single-shot reply (a manual answer, or the bus's error
172
+ // contract for an uncaught interceptor exception)
173
+ log.info(`Handled ${event.to} status=${first.getStatus()} ` +
174
+ `exec_time=${first.execTime ?? 0}ms trace_id=${event.traceId ?? 'none'}`);
175
+ const bytes = first.toBytes();
176
+ res.writeHead(200, { 'content-type': OCTET_STREAM, 'content-length': bytes.length });
177
+ res.end(bytes);
178
+ return;
179
+ }
180
+ if (!capable) {
181
+ // a streaming reply cannot ride a single-shot response
182
+ transportError(res, 406, STREAM_CALLER_REQUIRED);
183
+ return;
184
+ }
185
+ await streamResponse(res, queue, first, marker, ttl);
186
+ }
187
+ finally {
188
+ bus.closeSink(sinkRoute);
189
+ }
190
+ }
191
+ createServer() {
192
+ return http.createServer((req, res) => {
193
+ const url = new URL(req.url ?? '/', 'http://localhost');
194
+ if (req.method === 'GET') {
195
+ this.actuator.handle(url.pathname, res).then((handled) => {
196
+ if (!handled) {
197
+ sendError(res, 404, 'Resource not found');
198
+ }
199
+ }).catch((e) => {
200
+ sendError(res, 500, e.message ?? String(e));
201
+ });
202
+ return;
203
+ }
204
+ if (req.method === 'POST' && url.pathname === '/api/event') {
205
+ const chunks = [];
206
+ req.on('data', (chunk) => chunks.push(chunk));
207
+ req.on('end', () => {
208
+ this.handleEvent(req, res, Buffer.concat(chunks)).catch((e) => {
209
+ transportError(res, 500, e.message ?? String(e));
210
+ });
211
+ });
212
+ req.on('error', () => res.destroy());
213
+ return;
214
+ }
215
+ sendError(res, 404, 'Resource not found');
216
+ });
217
+ }
218
+ }
219
+ /**
220
+ * Render the envelope-mode SSE dialect: envelope frames for the head, the
221
+ * terminals and non-text segments; raw frames for plain text. The x-ttl
222
+ * allowance (overridable by the producer's head control, in seconds) is the
223
+ * per-segment idle; expiry fails the stream in-band with the standard 408
224
+ * error body. Keep-alive comments ride while the producer is quiet
225
+ * (event.stream.keep.alive, the engines' key).
226
+ */
227
+ async function streamResponse(res, queue, first, firstMarker, ttl) {
228
+ let idleMs = ttl;
229
+ for (const [key, value] of Object.entries(first.headers)) {
230
+ if (key.toLowerCase() === X_TTL) {
231
+ const seconds = Number.parseInt(String(value).trim(), 10);
232
+ if (Number.isFinite(seconds) && seconds > 0) {
233
+ idleMs = seconds * 1000;
234
+ }
235
+ }
236
+ }
237
+ res.writeHead(first.getStatus(), {
238
+ 'content-type': TEXT_EVENT_STREAM,
239
+ 'cache-control': 'no-cache'
240
+ });
241
+ res.write(envelopeFrame(first));
242
+ if (firstMarker === DATA) {
243
+ await streamSegments(res, queue, idleMs);
244
+ }
245
+ res.end();
246
+ }
247
+ async function streamSegments(res, queue, idleMs) {
248
+ const pingMs = keepAliveMs();
249
+ // a disconnected client ends the stream; late segments are no-op drops
250
+ for (;;) {
251
+ if (res.writableEnded || res.destroyed) {
252
+ log.debug('Client disconnected from event stream');
253
+ return;
254
+ }
255
+ const event = await nextSegment(res, queue, idleMs, pingMs);
256
+ if (!event) {
257
+ // idle expiry - fail in-band (the engines' housekeeper parity)
258
+ const seconds = Math.trunc(idleMs / 1000);
259
+ res.write(envelopeFrame(exceptionEnvelope(408, `Timeout for ${seconds} seconds`)));
260
+ return;
261
+ }
262
+ const action = classifySegment(event);
263
+ if (action.warn) {
264
+ log.warn('Dropping event - invalid x-event-stream signal');
265
+ }
266
+ if (action.frame?.length) {
267
+ res.write(action.frame);
268
+ }
269
+ if (action.end) {
270
+ return;
271
+ }
272
+ }
273
+ }
274
+ /**
275
+ * The wire consequence of one sink envelope in envelope mode: a data frame
276
+ * (raw or escape-hatch), a terminal envelope frame that ends the response
277
+ * cleanly (no cosmetic frames on this wire), the in-band terminal for the
278
+ * bus's uncaught-interceptor-exception contract, or a warned drop.
279
+ */
280
+ function classifySegment(event) {
281
+ const marker = streamSignal(event);
282
+ if (marker === DATA) {
283
+ return { frame: dataFrame(event, false), end: false };
284
+ }
285
+ if (marker === EOF || marker === EXCEPTION) {
286
+ return { frame: envelopeFrame(event), end: true };
287
+ }
288
+ if (marker === undefined && event.hasError()) {
289
+ // fail in-band with the exact status
290
+ const frame = envelopeFrame(exceptionEnvelope(event.getStatus(), errorText(event.body)));
291
+ return { frame, end: true };
292
+ }
293
+ return { end: false, warn: true };
294
+ }
295
+ /**
296
+ * Wait for the next segment within the idle allowance, emitting SSE
297
+ * keep-alive comments while the producer is quiet (best-effort; pings never
298
+ * extend the idle allowance).
299
+ */
300
+ async function nextSegment(res, queue, idleMs, pingMs) {
301
+ const deadline = Date.now() + idleMs;
302
+ // ONE pending waiter reused across ping cycles (see raceMs)
303
+ const pending = queue.next();
304
+ for (;;) {
305
+ const remaining = deadline - Date.now();
306
+ if (remaining <= 0) {
307
+ return null;
308
+ }
309
+ const wait = pingMs > 0 ? Math.min(remaining, pingMs) : remaining;
310
+ const event = await raceMs(pending, wait);
311
+ if (event) {
312
+ return event;
313
+ }
314
+ if (Date.now() >= deadline) {
315
+ return null;
316
+ }
317
+ if (!res.writableEnded && !res.destroyed) {
318
+ res.write(': ping\n\n');
319
+ }
320
+ }
321
+ }
322
+ export class Platform {
323
+ registry;
324
+ constructor(registry = defaultRegistry) {
325
+ this.registry = registry;
326
+ }
327
+ /** Start the Event API host. Resolves once listening; runs until stopped. */
328
+ async run(options = {}) {
329
+ const config = appConfig();
330
+ const appName = config.getProperty('application.name', 'application');
331
+ const port = options.port ?? Number(config.get('rest.server.port', 8085));
332
+ const host = options.host ?? '127.0.0.1';
333
+ for (const service of this.registry.routes()) {
334
+ const visibility = service.isPrivate ? 'PRIVATE' : 'PUBLIC';
335
+ log.info(`Loaded ${visibility} ${service.route}, instances=${service.instances}`);
336
+ }
337
+ const server = new EventApiServer(this.registry).createServer();
338
+ await new Promise((resolve) => server.listen(port, host, resolve));
339
+ log.info(`${appName} - Event API service started on port ${port}`);
340
+ return server;
341
+ }
342
+ }
343
+ export const platform = new Platform();
@@ -0,0 +1,39 @@
1
+ export declare const MY_CID_TAG = "my_cid";
2
+ export declare const MY_CORRELATION_ID = "my_correlation_id";
3
+ export declare const RPC_TAG = "rpc";
4
+ export declare const RESERVED_CONTEXT_TOKENS: Set<string>;
5
+ export interface TraceInfo {
6
+ route?: string;
7
+ traceId?: string;
8
+ tracePath?: string;
9
+ cid?: string;
10
+ myCorrelationId?: string;
11
+ spanId?: string;
12
+ parentSpanId?: string;
13
+ annotations: Record<string, unknown>;
14
+ customContext?: Record<string, unknown>;
15
+ }
16
+ /** The trace context of the event being handled, if any. */
17
+ export declare function getTrace(): TraceInfo | undefined;
18
+ /** Attach an annotation to the current trace (returned on the reply envelope). */
19
+ export declare function annotateTrace(key: string, value: unknown): void;
20
+ /**
21
+ * Add (or remove, when value is null/undefined) a custom key-value in the
22
+ * application log context - the engines' PostOffice.updateContext twin.
23
+ *
24
+ * The key-value is rendered into the "context" block of structured log output
25
+ * (log.format json/compact) when the app-log-context feature is enabled.
26
+ * Unlike annotateTrace (which feeds the distributed-trace telemetry), this is
27
+ * a logging-only sink. No-op outside a hosted request.
28
+ *
29
+ * @throws Error if key is one of the reserved context tokens
30
+ */
31
+ export declare function updateContext(key: string, value: unknown): void;
32
+ /**
33
+ * Run fn under a trace context - the python trace_context twin. Useful for
34
+ * callers outside a hosted function (batch jobs, tests) whose PostOffice
35
+ * calls should carry a trace: the client inherits the context into the
36
+ * outbound envelope, including the business correlation-id as the
37
+ * engine-managed my_cid tag.
38
+ */
39
+ export declare function runWithTrace<T>(info: TraceInfo | undefined, fn: () => T): T;