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/bus.js
ADDED
|
@@ -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
|
+
}
|
package/dist/src/cli.js
ADDED
|
@@ -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
|
+
}
|