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
package/dist/src/log.js
ADDED
|
@@ -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;
|