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,386 @@
1
+ /**
2
+ * The primitive in-process event bus - the single dispatch pipeline.
3
+ *
4
+ * Every invocation reaches a function the same way: through a per-route FIFO
5
+ * mailbox consumed by `instances` worker loops (the engines' semantics - the
6
+ * parameter is faithful). The HTTP host and the local side of PostOffice are
7
+ * thin ingress adapters over this bus; neither has its own invocation path.
8
+ *
9
+ * Deliberately primitive, riding the runtime's native event loop:
10
+ * - Two operations only: deliver (RPC - ttl-bounded, with a dead-work skip
11
+ * for queued calls whose caller already timed out) and publish
12
+ * (drop-n-forget - returns the 202-shape acknowledgement).
13
+ * - No spill tier and no queue cap: back-pressure belongs to the tier that
14
+ * owns recovery - the engines' flows and graphs. A leaf host fails fast by
15
+ * deadline (the 408 envelope) instead of hoarding work.
16
+ * - In-memory only; no orchestration, no flows, no persistence, no broadcast.
17
+ *
18
+ * Why a hand-built Mailbox instead of Node's EventEmitter: this contract is
19
+ * an ANYCAST WORK QUEUE - each delivery goes to exactly one of N workers and
20
+ * waits its FIFO turn while all are busy. EventEmitter is a broadcast
21
+ * notifier - emit() invokes every listener synchronously and buffers
22
+ * nothing - so a bounded-concurrency bus would still need this queue in
23
+ * front of it (the emitter demoted to a wake-up bell), and once()-based
24
+ * bridging re-registers a listener per iteration, can drop emissions
25
+ * between iterations, and trips MaxListenersExceededWarning right at the
26
+ * default instances=10. Bare promise waiters also hold no event-loop
27
+ * handles, which is what makes the lifecycle contract exactly true (an idle
28
+ * bus lets the process exit; only an in-flight RPC's deadline timer holds
29
+ * it). The Mailbox is node's missing asyncio.Queue, keeping the python and
30
+ * node twins structurally identical.
31
+ *
32
+ * The bus is internal: application code uses preload() and PostOffice, never
33
+ * this module - the same way engine developers never touch the engine bus.
34
+ */
35
+ import { EventEnvelope, isoUtc } from './envelope.js';
36
+ import { AppException } from './exceptions.js';
37
+ import { getLogger } from './log.js';
38
+ import { randomBytes } from 'node:crypto';
39
+ import { appOrigin } from './actuator.js';
40
+ import { MY_CID_TAG, MY_CORRELATION_ID, RPC_TAG, runWithTrace } from './trace.js';
41
+ const log = getLogger('mercury.bus');
42
+ /** An RPC delivery missed its deadline; adapters shape the 408 for their protocol. */
43
+ export class DeliveryTimeout extends Error {
44
+ ttlMs;
45
+ constructor(ttlMs) {
46
+ super(`Timeout for ${ttlMs} ms`);
47
+ this.name = 'DeliveryTimeout';
48
+ this.ttlMs = ttlMs;
49
+ }
50
+ }
51
+ /** The 202 drop-n-forget acknowledgement (EventApiService shape). */
52
+ export function asyncAck() {
53
+ return new EventEnvelope().setStatus(202)
54
+ .setBody({ type: 'async', delivered: true, time: isoUtc() });
55
+ }
56
+ /**
57
+ * The caller's business correlation-id at delivery: the engine-managed my_cid
58
+ * envelope tag, else a my_correlation_id view already injected by an HTTP host
59
+ * (the engines' WorkerHandler resolution order).
60
+ */
61
+ function businessCid(delivery) {
62
+ return delivery.envelope?.tags[MY_CID_TAG] ?? delivery.headers[MY_CORRELATION_ID];
63
+ }
64
+ /**
65
+ * The handler's header view, with the read-only business correlation-id
66
+ * injected at delivery (engine parity).
67
+ */
68
+ function headersView(delivery, myCid) {
69
+ if (myCid && !(MY_CORRELATION_ID in delivery.headers)) {
70
+ return { ...delivery.headers, [MY_CORRELATION_ID]: myCid };
71
+ }
72
+ return delivery.headers;
73
+ }
74
+ // the engines' distributed-trace log stream (Java Telemetry parity)
75
+ const telemetryLog = getLogger('distributed.tracing');
76
+ /**
77
+ * The execution's trace context. Under a trace, every execution mints its own
78
+ * 16-hex span and records the caller's span (from the inbound envelope) as
79
+ * its parent - the engines' WorkerHandler model.
80
+ */
81
+ function traceInfoOf(delivery, myCid) {
82
+ return {
83
+ route: delivery.service.route,
84
+ traceId: delivery.traceId,
85
+ tracePath: delivery.tracePath,
86
+ cid: delivery.cid,
87
+ myCorrelationId: myCid,
88
+ spanId: delivery.traceId ? randomBytes(8).toString('hex') : undefined,
89
+ parentSpanId: delivery.envelope?.spanId,
90
+ annotations: {}
91
+ };
92
+ }
93
+ /**
94
+ * True for an RPC round-trip: a local reply resolver, or the engines' rpc
95
+ * envelope tag transported over the wire. RPC legs emit no trace dataset
96
+ * (engine parity) - their metrics fold into the caller's view.
97
+ */
98
+ function isRpc(delivery) {
99
+ return Boolean(delivery.resolve) || Boolean(delivery.envelope?.tags[RPC_TAG]);
100
+ }
101
+ /**
102
+ * Emit the engines' distributed-trace dataset for a traced, non-RPC
103
+ * execution - the same record shape the Java reference engine logs
104
+ * (message = {"trace": {...}, "annotations": {...}}), so polyglot log
105
+ * aggregation and stdout log-ingest agents stitch spans across runtimes.
106
+ */
107
+ function emitTrace(delivery, info, start, execTime, status, success, exception) {
108
+ if (!info.traceId || isRpc(delivery)) {
109
+ return;
110
+ }
111
+ const trace = {
112
+ origin: appOrigin(),
113
+ id: info.traceId,
114
+ path: info.tracePath,
115
+ service: delivery.service.route,
116
+ start,
117
+ success,
118
+ from: delivery.envelope?.sender ?? 'unknown',
119
+ exec_time: execTime,
120
+ status
121
+ };
122
+ if (!success && exception) {
123
+ trace.exception = exception;
124
+ }
125
+ if (info.spanId) {
126
+ trace.span_id = info.spanId;
127
+ }
128
+ if (info.parentSpanId) {
129
+ trace.parent_span_id = info.parentSpanId;
130
+ }
131
+ const dataset = { trace };
132
+ if (Object.keys(info.annotations).length) {
133
+ dataset.annotations = { ...info.annotations };
134
+ }
135
+ telemetryLog.info(dataset);
136
+ }
137
+ const CLOSED = Symbol('bus-closed');
138
+ /** Unbounded FIFO handing items to awaiting consumers (node's missing asyncio.Queue). */
139
+ export class Mailbox {
140
+ items = [];
141
+ waiters = [];
142
+ push(item) {
143
+ const waiter = this.waiters.shift();
144
+ if (waiter) {
145
+ waiter(item);
146
+ }
147
+ else {
148
+ this.items.push(item);
149
+ }
150
+ }
151
+ next() {
152
+ const item = this.items.shift();
153
+ if (item !== undefined) {
154
+ return Promise.resolve(item);
155
+ }
156
+ return new Promise((resolve) => this.waiters.push(resolve));
157
+ }
158
+ }
159
+ /**
160
+ * Race one PENDING promise against a timeout; null on expiry. The pending
161
+ * promise survives a lost race (a bare promise cannot be cancelled), so a
162
+ * caller that keeps waiting must keep reusing THE SAME promise until it
163
+ * resolves - a fresh queue.next() per cycle would leave an abandoned waiter
164
+ * in the mailbox that steals and drops the next item. A caller that
165
+ * terminates on expiry may race a fresh promise each time.
166
+ */
167
+ export async function raceMs(pending, timeoutMs) {
168
+ let timer;
169
+ const expiry = new Promise((resolve) => {
170
+ timer = setTimeout(() => resolve(null), Math.max(1, timeoutMs));
171
+ });
172
+ try {
173
+ return await Promise.race([pending, expiry]);
174
+ }
175
+ finally {
176
+ clearTimeout(timer);
177
+ }
178
+ }
179
+ /** Per-registry bus: one FIFO mailbox and N workers per registered route. */
180
+ export class EventBus {
181
+ mailboxes = new Map();
182
+ // per-request reply sinks (the engines' inbox idea): generated local route
183
+ // names backed by queues - the reply_to addressing of interceptor dispatch
184
+ // and of streaming responses. Local-only by design.
185
+ sinks = new Map();
186
+ // reply routing for the interceptor error contract (registry owns this bus)
187
+ router;
188
+ sinkSequence = 0;
189
+ bindRouter(router) {
190
+ this.router = router;
191
+ }
192
+ /** Open a per-request reply sink under a generated local route name. */
193
+ openSink() {
194
+ const route = `inbox.${++this.sinkSequence}.${Date.now().toString(36)}`;
195
+ const queue = new Mailbox();
196
+ this.sinks.set(route, queue);
197
+ return [route, queue];
198
+ }
199
+ closeSink(route) {
200
+ this.sinks.delete(route);
201
+ }
202
+ /**
203
+ * Deliver an envelope to a reply sink; false when the sink is gone (a
204
+ * completed, timed-out or disconnected request) - late segments are no-op
205
+ * drops, the engines' semantics.
206
+ */
207
+ offerSink(route, event) {
208
+ const queue = this.sinks.get(route);
209
+ if (!queue) {
210
+ return false;
211
+ }
212
+ queue.push(event);
213
+ return true;
214
+ }
215
+ mailbox(service) {
216
+ let mailbox = this.mailboxes.get(service.route);
217
+ if (!mailbox) {
218
+ // lazy: the mailbox and its workers start on first use
219
+ const created = new Mailbox();
220
+ mailbox = created;
221
+ this.mailboxes.set(service.route, created);
222
+ for (let n = 0; n < service.instances; n++) {
223
+ // workers are long-lived loops created lazily on first use, so they
224
+ // would inherit the creating caller's trace store - detach so nothing
225
+ // from an arbitrary first caller leaks into later executions' logs
226
+ void runWithTrace(undefined, () => this.runWorker(created));
227
+ }
228
+ }
229
+ return mailbox;
230
+ }
231
+ /** RPC: enqueue and await the reply envelope within the ttl. */
232
+ deliver(service, headers, body, ttlMs, trace = {}) {
233
+ const delivery = { service, headers, body, settled: false, ...trace };
234
+ this.mailbox(service).push(delivery);
235
+ return new Promise((resolve, reject) => {
236
+ const timer = setTimeout(() => {
237
+ if (!delivery.settled) {
238
+ // dead-work mark: a worker reaching this delivery later will skip it
239
+ delivery.settled = true;
240
+ reject(new DeliveryTimeout(ttlMs));
241
+ }
242
+ }, Math.max(100, ttlMs));
243
+ // deliberately referenced: an in-flight RPC is pending work and holds the
244
+ // process open (at most until its deadline); an idle bus holds nothing
245
+ delivery.resolve = (reply) => {
246
+ clearTimeout(timer);
247
+ resolve(reply);
248
+ };
249
+ });
250
+ }
251
+ /** Drop-n-forget: enqueue and return the 202-shape acknowledgement. */
252
+ publish(service, headers, body, trace = {}) {
253
+ this.mailbox(service).push({ service, headers, body, settled: false, ...trace });
254
+ return asyncAck();
255
+ }
256
+ /**
257
+ * Route one envelope to a local function (the reply_to mechanism):
258
+ * drop-n-forget delivery carrying the raw envelope, so an interceptor
259
+ * handler receives reply_to and the correlation id the engines' way.
260
+ */
261
+ publishEnvelope(service, event) {
262
+ this.mailbox(service).push({
263
+ service, headers: { ...event.headers }, body: event.body, settled: false,
264
+ traceId: event.traceId, tracePath: event.tracePath, cid: event.cid,
265
+ envelope: event
266
+ });
267
+ }
268
+ /** Stop all workers (orderly shutdown; worker promises hold no OS handle). */
269
+ close() {
270
+ for (const mailbox of this.mailboxes.values()) {
271
+ mailbox.push(CLOSED);
272
+ }
273
+ this.mailboxes.clear();
274
+ }
275
+ async runWorker(mailbox) {
276
+ for (;;) {
277
+ const delivery = await mailbox.next();
278
+ if (delivery === CLOSED) {
279
+ mailbox.push(CLOSED); // release the next worker on the same mailbox
280
+ return;
281
+ }
282
+ // dead-work check: the caller of a queued RPC already gave up (408 sent)
283
+ if (delivery.settled) {
284
+ continue;
285
+ }
286
+ const reply = delivery.service.interceptor
287
+ ? await this.executeInterceptor(delivery)
288
+ : await EventBus.execute(delivery);
289
+ if (delivery.resolve) {
290
+ if (!delivery.settled) {
291
+ delivery.settled = true;
292
+ delivery.resolve(reply);
293
+ }
294
+ }
295
+ else if (reply.hasError()) {
296
+ log.warn(`Async event ${delivery.service.route} ended with status ` +
297
+ `${reply.getStatus()} - ${reply.body}`);
298
+ }
299
+ }
300
+ }
301
+ /** Run the handler under its trace context and shape the outcome as a reply. */
302
+ static async execute(delivery) {
303
+ const myCid = businessCid(delivery);
304
+ const headers = headersView(delivery, myCid);
305
+ const info = traceInfoOf(delivery, myCid);
306
+ const startIso = isoUtc();
307
+ const start = process.hrtime.bigint();
308
+ let reply;
309
+ try {
310
+ const result = await runWithTrace(info, async () => delivery.service.handler(headers, delivery.body));
311
+ reply = result instanceof EventEnvelope ? result : new EventEnvelope(undefined, result);
312
+ }
313
+ catch (e) {
314
+ if (e instanceof AppException) {
315
+ reply = new EventEnvelope().setStatus(e.status).setBody(e.message);
316
+ }
317
+ else {
318
+ // any handler failure becomes the portable error contract
319
+ // (status 500 + message + stack), mirroring the engines
320
+ const error = e;
321
+ reply = new EventEnvelope().setStatus(500).setBody(error.message ?? String(e));
322
+ if (error.stack) {
323
+ reply.stack = error.stack;
324
+ }
325
+ }
326
+ }
327
+ reply.sender = reply.sender ?? delivery.service.route;
328
+ reply.execTime = Math.round(Number(process.hrtime.bigint() - start) / 1000) / 1000;
329
+ if (Object.keys(info.annotations).length) {
330
+ reply.annotations = { ...info.annotations, ...reply.annotations };
331
+ }
332
+ emitTrace(delivery, info, startIso, reply.execTime, reply.getStatus(), !reply.hasError(), reply.hasError() ? String(reply.body) : undefined);
333
+ return reply;
334
+ }
335
+ /**
336
+ * Run an interceptor handler: it receives the raw envelope, replies
337
+ * manually through reply_to (the engines' EventInterceptor contract), and
338
+ * its return value is discarded. An uncaught exception becomes an error
339
+ * envelope to the delivery's reply_to - so a caller waiting on a reply
340
+ * sink sees it - and a streaming host renders it in-band.
341
+ */
342
+ async executeInterceptor(delivery) {
343
+ const event = delivery.envelope ??
344
+ new EventEnvelope(delivery.service.route, delivery.body, delivery.headers);
345
+ const myCid = businessCid(delivery);
346
+ const headers = headersView(delivery, myCid);
347
+ const info = traceInfoOf(delivery, myCid);
348
+ const startIso = isoUtc();
349
+ const start = process.hrtime.bigint();
350
+ let error;
351
+ try {
352
+ await runWithTrace(info, async () => delivery.service.handler(headers, event));
353
+ }
354
+ catch (e) {
355
+ error = e;
356
+ this.replyInterceptorError(delivery.service.route, event, e);
357
+ }
358
+ const status = error instanceof AppException ? error.status : (error ? 500 : 200);
359
+ const execTime = Math.round(Number(process.hrtime.bigint() - start) / 1000) / 1000;
360
+ emitTrace(delivery, info, startIso, execTime, status, !error, error ? String(error.message ?? error) : undefined);
361
+ // an interceptor's own outcome is never auto-replied
362
+ return new EventEnvelope();
363
+ }
364
+ replyInterceptorError(route, event, e) {
365
+ let error;
366
+ if (e instanceof AppException) {
367
+ error = new EventEnvelope().setStatus(e.status).setBody(e.message);
368
+ }
369
+ else {
370
+ const raw = e;
371
+ error = new EventEnvelope().setStatus(500).setBody(raw.message ?? String(e));
372
+ if (raw.stack) {
373
+ error.stack = raw.stack;
374
+ }
375
+ }
376
+ error.sender = route;
377
+ if (event.cid) {
378
+ error.setCorrelationId(event.cid);
379
+ }
380
+ const delivered = Boolean(event.replyTo && this.router?.(error.setTo(event.replyTo)));
381
+ if (!delivered) {
382
+ log.warn(`Interceptor ${route} ended with status ` +
383
+ `${error.getStatus()} - ${error.body}`);
384
+ }
385
+ }
386
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Developer runner: serve a polyglot function module with one command.
4
+ *
5
+ * mercury-serve app.mjs --port 8087
6
+ * mercury-serve app.mjs --config application.yml
7
+ */
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import { pathToFileURL } from 'node:url';
11
+ function parseArgs(argv) {
12
+ const result = {
13
+ host: '127.0.0.1'
14
+ };
15
+ for (let i = 0; i < argv.length; i++) {
16
+ const arg = argv[i];
17
+ if (arg === '--port')
18
+ result.port = Number.parseInt(argv[++i], 10);
19
+ else if (arg === '--host')
20
+ result.host = argv[++i];
21
+ else if (arg === '--config')
22
+ result.config = argv[++i];
23
+ else if (!arg.startsWith('-') && !result.app)
24
+ result.app = arg;
25
+ }
26
+ return result;
27
+ }
28
+ async function main() {
29
+ const args = parseArgs(process.argv.slice(2));
30
+ if (!args.app) {
31
+ process.stderr.write('Usage: mercury-serve <app.mjs> [--port n] [--host addr] [--config file]\n');
32
+ return 1;
33
+ }
34
+ const { DEFAULT_CANDIDATES, loadConfig } = await import('./config.js');
35
+ const appPath = path.resolve(args.app);
36
+ if (!fs.existsSync(appPath)) {
37
+ process.stderr.write(`Application file not found - ${appPath}\n`);
38
+ return 1;
39
+ }
40
+ let configPath = args.config;
41
+ if (!configPath && !DEFAULT_CANDIDATES.some((c) => fs.existsSync(c))) {
42
+ // fall back to a resources folder next to the application file
43
+ const appDir = path.dirname(appPath);
44
+ for (const candidate of DEFAULT_CANDIDATES) {
45
+ const probe = path.join(appDir, candidate);
46
+ if (fs.existsSync(probe)) {
47
+ configPath = probe;
48
+ break;
49
+ }
50
+ }
51
+ }
52
+ loadConfig(configPath); // before logging/server setup so their keys apply
53
+ // -Dkey=value runtime overrides are consumed by AppConfig from process.argv
54
+ await import(pathToFileURL(appPath).href);
55
+ const { defaultRegistry } = await import('./registry.js');
56
+ const { platform } = await import('./server.js');
57
+ if (defaultRegistry.routes().length === 0) {
58
+ process.stderr.write('No functions registered - use preload(route, options, handler)\n');
59
+ return 1;
60
+ }
61
+ await platform.run({ port: args.port, host: args.host });
62
+ return 0;
63
+ }
64
+ try {
65
+ const code = await main();
66
+ if (code !== 0)
67
+ process.exit(code);
68
+ }
69
+ catch (e) {
70
+ process.stderr.write(`${e.message}\n`);
71
+ process.exit(1);
72
+ }
@@ -0,0 +1,48 @@
1
+ import { EventEnvelope } from './envelope.js';
2
+ import { FunctionRegistry } from './registry.js';
3
+ export interface CallOptions {
4
+ headers?: Record<string, string>;
5
+ timeoutMs?: number;
6
+ endpoint?: string;
7
+ fromRoute?: string;
8
+ cid?: string;
9
+ }
10
+ export declare class PostOffice {
11
+ private readonly endpoint?;
12
+ private readonly securityHeaders;
13
+ private readonly registry;
14
+ constructor(endpoint?: string | undefined, securityHeaders?: Record<string, string>, registry?: FunctionRegistry);
15
+ /** In-app delivery through the primitive event bus (private OR public). */
16
+ private callLocal;
17
+ private httpHeaders;
18
+ private buildEvent;
19
+ private call;
20
+ /** RPC call: returns the target function's reply envelope. */
21
+ request(route: string, body?: unknown, options?: CallOptions): Promise<EventEnvelope>;
22
+ /** Drop-n-forget: returns the peer's 202 delivery acknowledgement envelope. */
23
+ send(route: string, body?: unknown, options?: CallOptions): Promise<EventEnvelope>;
24
+ /**
25
+ * Consume a streaming function progressively - the same decoded envelopes
26
+ * an engine reply route receives: `data` segments, then the `eof` or
27
+ * `exception` terminal. A non-streaming target yields its one classic reply
28
+ * (opting in is always safe). `timeoutMs` is the idle allowance between
29
+ * segments; expiry, a truncated stream and a malformed dialect yield the
30
+ * in-band exception envelope, then end.
31
+ *
32
+ * Remote (an endpoint is given, or set on the constructor): the peer's
33
+ * /api/event answers the one POST with the envelope-mode SSE dialect.
34
+ * Local (no endpoint): the same first-envelope classification through a
35
+ * per-request reply sink on the primitive bus.
36
+ */
37
+ stream(route: string, body?: unknown, options?: CallOptions): AsyncGenerator<EventEnvelope, void, void>;
38
+ /**
39
+ * The relay form of stream() for composition: every decoded envelope
40
+ * forwards verbatim to the LOCAL replyTo route (typically the caller's own
41
+ * reply address, handed through by an interceptor), so segments flow
42
+ * remote peer -> this application -> the original caller with no
43
+ * buffering. Awaits and returns the last envelope (normally the terminal).
44
+ */
45
+ streamTo(route: string, body: unknown, replyTo: string, options?: CallOptions): Promise<EventEnvelope>;
46
+ private streamLocal;
47
+ private streamRemote;
48
+ }