@btravstack/core 0.3.0

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/dist/index.cjs ADDED
@@ -0,0 +1,580 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let unthrown = require("unthrown");
3
+ let _btravstack_di = require("@btravstack/di");
4
+ let _btravstack_config = require("@btravstack/config");
5
+ let node_http = require("node:http");
6
+ let node_async_hooks = require("node:async_hooks");
7
+ //#region src/clock.ts
8
+ const systemClock = {
9
+ now: () => Date.now(),
10
+ sleep: (ms, signal) => (0, unthrown.fromSafePromise)(new Promise((resolve) => {
11
+ if (signal?.aborted === true) {
12
+ resolve();
13
+ return;
14
+ }
15
+ const timer = setTimeout(() => {
16
+ signal?.removeEventListener("abort", onAbort);
17
+ resolve();
18
+ }, ms);
19
+ timer.unref?.();
20
+ function onAbort() {
21
+ clearTimeout(timer);
22
+ resolve();
23
+ }
24
+ signal?.addEventListener("abort", onAbort, { once: true });
25
+ }))
26
+ };
27
+ //#endregion
28
+ //#region src/events.ts
29
+ const withErrors = (_key, value) => value instanceof Error ? {
30
+ name: value.name,
31
+ message: value.message,
32
+ stack: value.stack,
33
+ cause: value.cause
34
+ } : value;
35
+ const render = (event) => {
36
+ try {
37
+ return JSON.stringify(event, withErrors);
38
+ } catch {
39
+ return JSON.stringify({
40
+ type: event.type,
41
+ cause: "[unserialisable]"
42
+ });
43
+ }
44
+ };
45
+ const stderrSink = (event) => {
46
+ process.stderr.write(`${render(event)}\n`);
47
+ };
48
+ const safeSink = (sink) => (event) => {
49
+ try {
50
+ sink(event);
51
+ } catch {}
52
+ };
53
+ //#endregion
54
+ //#region src/observability.ts
55
+ /** The levels in order, least severe first — what `isEnabled` compares through. */
56
+ const LEVELS = [
57
+ "trace",
58
+ "debug",
59
+ "info",
60
+ "warn",
61
+ "error",
62
+ "fatal"
63
+ ];
64
+ /**
65
+ * The application's logger, as a port — declared here and implemented in
66
+ * `@btravstack/observability`, because a contract every package may depend on
67
+ * has to be reachable without installing an implementation, and because the
68
+ * correlation an implementation stamps per line is the kernel's own ambient
69
+ * record.
70
+ *
71
+ * An implementation must not throw: a logger that throws turns an observability
72
+ * problem into an outage. Synchronous `void`, not an `AsyncResult` — thesis 6's
73
+ * one deliberate exemption, since a lost line is not a modeled error.
74
+ */
75
+ var Logger = class extends (0, _btravstack_di.Port)("Logger") {};
76
+ /** A span's outcome: unset until something says otherwise, then `ok` or `error`. */
77
+ const SPAN_STATUS = {
78
+ unset: 0,
79
+ ok: 1,
80
+ error: 2
81
+ };
82
+ /**
83
+ * The application's tracer, as a port — declared without naming OpenTelemetry,
84
+ * because a port typed as a vendor's type points the dependency arrow outwards
85
+ * and makes the whole family install that vendor.
86
+ */
87
+ var Tracer = class extends (0, _btravstack_di.Port)("Tracer") {};
88
+ /** The application's meter, as a port. See {@link Tracer} for why it does not name a vendor. */
89
+ var Meter = class extends (0, _btravstack_di.Port)("Meter") {};
90
+ //#endregion
91
+ //#region src/runtime.ts
92
+ var RuntimeStartFailed = class extends (0, unthrown.TaggedError)("RuntimeStartFailed") {
93
+ message = `the ${this.runtime} runtime failed to start`;
94
+ };
95
+ /**
96
+ * The port the kernel resolves its runtime from. A runtime is a **service the
97
+ * module provides**, not an option handed to `start`: a runtime package
98
+ * declares its own port over this one — `class HttpRuntime extends
99
+ * RuntimePort<Runtime<never, HttpInfo>> {}` — so it is built by di like
100
+ * everything else, and the kernel owns nothing but the lifecycle.
101
+ *
102
+ * Left generic on purpose: every runtime port is one id at runtime, since a
103
+ * process boots exactly one, while each carries its own `Resolves`/`Info` in
104
+ * the type.
105
+ */
106
+ const RuntimePort = (0, _btravstack_di.Port)("Runtime");
107
+ /**
108
+ * `running`, but no later than the kernel's drain deadline — the primitive a
109
+ * `Serving.drain` needs when the work it awaits settles on somebody else's
110
+ * clock (Temporal's `shutdownForceTime`, a broker library's close) and so
111
+ * cannot honour `signal` itself.
112
+ *
113
+ * **The losing branch's `Result` is dropped**, which is the point rather than
114
+ * an oversight: once the deadline wins, the kernel has already moved on and
115
+ * the eventual outcome has no consumer left. What that costs is the runtime's
116
+ * own business — an un-acked AMQP delivery is redelivered, so abandoning one
117
+ * repeats work rather than losing it, while a Temporal activity is retried on
118
+ * another worker.
119
+ *
120
+ * Deliberately **`Clock`-agnostic**: there is no duration here, only a signal,
121
+ * so it behaves identically under `@btravstack/testing`'s fake clock. Racing
122
+ * work against a *timeout* is a different primitive and belongs on `Clock`
123
+ * (`drain.ts` uses `clock.sleep` for exactly that, so a fake clock can control
124
+ * it) — do not fold the two together.
125
+ */
126
+ const releasedBy = (signal, running) => (0, unthrown.fromSafePromise)(Promise.race([running, whenAborted(signal)])).flatMap((settled) => settled);
127
+ const whenAborted = (signal) => signal.aborted ? (0, unthrown.OkAsync)() : (0, unthrown.fromSafePromise)(new Promise((resolve) => {
128
+ signal.addEventListener("abort", () => resolve(), { once: true });
129
+ }));
130
+ //#endregion
131
+ //#region src/deferred.ts
132
+ const createDeferred = () => {
133
+ let resolve;
134
+ return {
135
+ promise: new Promise((r) => {
136
+ resolve = r;
137
+ }),
138
+ resolve
139
+ };
140
+ };
141
+ //#endregion
142
+ //#region src/drain.ts
143
+ const drainApp = (args) => {
144
+ args.onUnready();
145
+ const inFlightAtStart = args.registry.inFlight();
146
+ const closedAtStart = args.registry.closed();
147
+ const deadline = new AbortController();
148
+ return args.clock.sleep(args.preDrainDelayMs, args.skip).flatMap(() => {
149
+ const drainStopped = args.serving.drain(deadline.signal);
150
+ return (0, unthrown.fromSafePromise)(Promise.race([drainStopped.flatMap(() => args.registry.awaitIdle()), args.clock.sleep(args.drainTimeoutMs, args.skip)])).flatMap((raced) => {
151
+ deadline.abort();
152
+ return raced.map(() => {
153
+ const abandoned = args.registry.inFlight();
154
+ if (abandoned > 0) args.registry.abortAll();
155
+ return {
156
+ inFlightAtStart,
157
+ completed: args.registry.closed() - closedAtStart,
158
+ abandoned
159
+ };
160
+ });
161
+ });
162
+ });
163
+ };
164
+ //#endregion
165
+ //#region src/phase.ts
166
+ const ORDER = [
167
+ "building",
168
+ "starting",
169
+ "serving",
170
+ "draining",
171
+ "stopping",
172
+ "exited"
173
+ ];
174
+ const rank = (phase) => ORDER.indexOf(phase);
175
+ const createPhaseTracker = (onChange) => {
176
+ let phase = "building";
177
+ return {
178
+ current: () => phase,
179
+ advanceTo: (next) => {
180
+ if (rank(next) <= rank(phase)) return false;
181
+ phase = next;
182
+ onChange(next);
183
+ return true;
184
+ }
185
+ };
186
+ };
187
+ //#endregion
188
+ //#region src/probes.ts
189
+ const startProbeServer = (args) => (0, unthrown.fromSafePromise)(new Promise((resolve) => {
190
+ const server = (0, node_http.createServer)((request, response) => {
191
+ if (request.url === "/livez") {
192
+ respond(response, args.live(), "ok");
193
+ return;
194
+ }
195
+ if (request.url === "/readyz") {
196
+ respond(response, args.ready(), "ready");
197
+ return;
198
+ }
199
+ response.writeHead(404).end();
200
+ });
201
+ const onBindError = (cause) => {
202
+ resolve((0, unthrown.Err)(new RuntimeStartFailed({
203
+ runtime: "probes",
204
+ cause
205
+ })));
206
+ };
207
+ const ignoreServingError = () => {};
208
+ server.once("error", onBindError);
209
+ try {
210
+ server.listen(args.port, "127.0.0.1", () => {
211
+ server.removeListener("error", onBindError);
212
+ server.on("error", ignoreServingError);
213
+ const address = server.address();
214
+ const port = typeof address === "object" && address !== null ? address.port : args.port;
215
+ resolve((0, unthrown.Ok)({
216
+ port,
217
+ close: () => (0, unthrown.fromSafePromise)(new Promise((done) => server.close(() => done())))
218
+ }));
219
+ });
220
+ } catch (cause) {
221
+ onBindError(cause);
222
+ }
223
+ server.unref();
224
+ })).flatMap((result) => result);
225
+ const respond = (response, healthy, body) => {
226
+ if (healthy) {
227
+ response.writeHead(200).end(body);
228
+ return;
229
+ }
230
+ response.writeHead(503).end("unavailable");
231
+ };
232
+ //#endregion
233
+ //#region src/process-handlers.ts
234
+ const SIGNALS = ["SIGTERM", "SIGINT"];
235
+ const installSignalHandlers = (handlers) => {
236
+ let seen = 0;
237
+ const onSignal = () => {
238
+ seen += 1;
239
+ if (seen === 1) {
240
+ handlers.onFirst();
241
+ return;
242
+ }
243
+ handlers.onSecond();
244
+ };
245
+ for (const signal of SIGNALS) process.on(signal, onSignal);
246
+ return () => {
247
+ for (const signal of SIGNALS) process.off(signal, onSignal);
248
+ };
249
+ };
250
+ const installUncaughtHandlers = (onUncaught) => {
251
+ let reported = false;
252
+ const report = (cause) => {
253
+ if (reported) return;
254
+ reported = true;
255
+ onUncaught(cause);
256
+ };
257
+ const onException = (error) => report(error);
258
+ const onRejection = (reason) => report(reason);
259
+ process.on("uncaughtException", onException);
260
+ process.on("unhandledRejection", onRejection);
261
+ return () => {
262
+ process.off("uncaughtException", onException);
263
+ process.off("unhandledRejection", onRejection);
264
+ };
265
+ };
266
+ //#endregion
267
+ //#region src/units.ts
268
+ const storage = new node_async_hooks.AsyncLocalStorage();
269
+ const runWithUnit = (record, fn) => storage.run(record, fn);
270
+ const currentUnit = () => storage.getStore();
271
+ let counter = 0;
272
+ const nextId = () => {
273
+ counter += 1;
274
+ return `u${counter}`;
275
+ };
276
+ const createUnitRegistry = () => {
277
+ const open = /* @__PURE__ */ new Set();
278
+ const idleWaiters = /* @__PURE__ */ new Set();
279
+ let closedCount = 0;
280
+ const settleIfIdle = () => {
281
+ if (open.size > 0) return;
282
+ for (const waiter of idleWaiters) waiter();
283
+ idleWaiters.clear();
284
+ };
285
+ return {
286
+ run: (meta, work) => {
287
+ const controller = new AbortController();
288
+ open.add(controller);
289
+ const record = {
290
+ unitId: nextId(),
291
+ traceId: meta.traceId ?? meta.id,
292
+ tenantId: meta.tenantId,
293
+ deadline: meta.deadline,
294
+ signal: controller.signal
295
+ };
296
+ return (0, unthrown.fromSafePromise)(runWithUnit(record, async () => {
297
+ try {
298
+ return await work(controller.signal);
299
+ } finally {
300
+ open.delete(controller);
301
+ closedCount += 1;
302
+ settleIfIdle();
303
+ }
304
+ })).flatMap((result) => result);
305
+ },
306
+ inFlight: () => open.size,
307
+ closed: () => closedCount,
308
+ abortAll: () => {
309
+ for (const controller of open) controller.abort();
310
+ },
311
+ awaitIdle: () => open.size === 0 ? (0, unthrown.OkAsync)() : (0, unthrown.fromSafePromise)(new Promise((resolve) => {
312
+ idleWaiters.add(resolve);
313
+ }))
314
+ };
315
+ };
316
+ //#endregion
317
+ //#region src/start.ts
318
+ const providesEnv = (module) => module.provides.some((provider) => provider.port.portId === _btravstack_config.Env.portId) || module.imports.some(providesEnv);
319
+ const start = (module, options = {}) => {
320
+ const clock = options.clock ?? systemClock;
321
+ const env = options.env ?? process.env;
322
+ const emit = safeSink(options.onEvent ?? stderrSink);
323
+ let runtimeName = "";
324
+ let servingInfo = void 0;
325
+ let probeBoundPort = void 0;
326
+ const tracker = createPhaseTracker((phase) => {
327
+ if (phase === "serving") emit({
328
+ type: "serving",
329
+ runtime: runtimeName,
330
+ info: servingInfo,
331
+ probePort: probeBoundPort
332
+ });
333
+ if (phase === "stopping") emit({ type: "stopping" });
334
+ if (phase === "exited") emit({ type: "exited" });
335
+ });
336
+ const registry = createUnitRegistry();
337
+ const shutdown = createDeferred();
338
+ const teardownErrors = [];
339
+ const startedAt = clock.now();
340
+ const preDrainDelayMs = options.preDrainDelayMs ?? 5e3;
341
+ const drainTimeoutMs = options.drainTimeoutMs ?? 2e4;
342
+ const skipDrain = new AbortController();
343
+ let shutdownRequestedAt = startedAt;
344
+ let shutdownRequested = false;
345
+ const sinceShutdownRequested = () => clock.now() - shutdownRequestedAt;
346
+ const requestShutdown = (reason) => {
347
+ if (!shutdownRequested) {
348
+ shutdownRequested = true;
349
+ shutdownRequestedAt = clock.now();
350
+ }
351
+ shutdown.resolve(reason);
352
+ };
353
+ let forcedUnready = false;
354
+ const onUnready = () => {
355
+ forcedUnready = true;
356
+ };
357
+ const live = () => tracker.current() !== "exited";
358
+ const ready = () => tracker.current() === "serving" && !forcedUnready;
359
+ const disposeSignals = options.signals === false ? () => {} : installSignalHandlers({
360
+ onFirst: () => requestShutdown("signal"),
361
+ onSecond: () => skipDrain.abort()
362
+ });
363
+ const disposeUncaught = options.signals === false ? () => {} : installUncaughtHandlers((cause) => {
364
+ emit({
365
+ type: "uncaught",
366
+ cause
367
+ });
368
+ onUnready();
369
+ skipDrain.abort();
370
+ requestShutdown("uncaught");
371
+ });
372
+ let disposeProbes = () => {};
373
+ const probeBound = createDeferred();
374
+ const runtimePublished = createDeferred();
375
+ emit({ type: "building" });
376
+ const probesOptions = options.probes === void 0 ? _btravstack_config.Config.port("PROBE_PORT", { default: 9e3 }).parse(env["PROBE_PORT"]).map((port) => ({ port })).mapErrCases((matcher) => matcher.with(unthrown.P.tag("ConfigFieldInvalid"), ({ reason }) => new RuntimeStartFailed({
377
+ runtime: "probes",
378
+ cause: new _btravstack_config.ConfigInvalid({
379
+ port: "probes",
380
+ issues: [{
381
+ message: reason,
382
+ path: ["PROBE_PORT"]
383
+ }]
384
+ })
385
+ }))) : (0, unthrown.Ok)(options.probes);
386
+ if (options.probes === false) probeBound.resolve(void 0);
387
+ const probesStarted = probesOptions.toAsync().flatMap((probes) => probes === false ? (0, unthrown.OkAsync)() : startProbeServer({
388
+ port: probes.port,
389
+ live,
390
+ ready
391
+ }).tap((server) => {
392
+ probeBoundPort = server.port;
393
+ probeBound.resolve(server.port);
394
+ disposeProbes = () => {
395
+ server.close();
396
+ };
397
+ }).discard()).tapFailure((failure) => {
398
+ emit({
399
+ type: "startFailed",
400
+ cause: failure.tag === "Err" ? failure.error : failure.cause
401
+ });
402
+ probeBound.resolve(void 0);
403
+ runtimePublished.resolve(void 0);
404
+ tracker.advanceTo("stopping");
405
+ disposeSignals();
406
+ disposeUncaught();
407
+ tracker.advanceTo("exited");
408
+ });
409
+ const root = (0, _btravstack_di.Module)("Kernel")({
410
+ imports: providesEnv(module) ? [module] : [module, (0, _btravstack_di.Module)("Environment")({
411
+ provides: [(0, _btravstack_di.Provider)(_btravstack_config.Env)({ value: env })],
412
+ exports: [_btravstack_config.Env]
413
+ })],
414
+ exports: [module]
415
+ });
416
+ const runDrain = (serving) => {
417
+ tracker.advanceTo("draining");
418
+ emit({
419
+ type: "draining",
420
+ inFlight: registry.inFlight()
421
+ });
422
+ return drainApp({
423
+ serving,
424
+ registry,
425
+ clock,
426
+ preDrainDelayMs: Math.max(0, preDrainDelayMs - sinceShutdownRequested()),
427
+ drainTimeoutMs,
428
+ skip: skipDrain.signal,
429
+ onUnready
430
+ }).tap((report) => emit({
431
+ type: "drained",
432
+ report
433
+ }));
434
+ };
435
+ const finish = (serving, reason) => {
436
+ if (reason !== "signal") registry.abortAll();
437
+ return (reason === "signal" ? runDrain(serving) : (0, unthrown.OkAsync)(void 0)).flatMap((report) => {
438
+ tracker.advanceTo("stopping");
439
+ return serving.stop().map(() => {
440
+ disposeSignals();
441
+ disposeUncaught();
442
+ tracker.advanceTo("exited");
443
+ disposeProbes();
444
+ return {
445
+ reason,
446
+ drain: report,
447
+ teardownErrors,
448
+ uptimeMs: clock.now() - startedAt
449
+ };
450
+ });
451
+ });
452
+ };
453
+ return {
454
+ exited: probesStarted.flatMap(() => _btravstack_di.Module.scoped(root, (ctx) => {
455
+ tracker.advanceTo("starting");
456
+ const runtime = ctx.get(RuntimePort);
457
+ runtimeName = runtime.name;
458
+ const runtimeCtx = ctx;
459
+ const unit = options.unit;
460
+ const run = (meta, work) => registry.run(meta, (signal) => {
461
+ if (unit === void 0) return work(runtimeCtx, signal);
462
+ const fork = _btravstack_di.Module.forkScope;
463
+ return fork(ctx, unit, (forked) => (0, unthrown.fromSafePromise)((async () => await work(forked, signal))()).flatMap((result) => result), { onTeardownError: (port, cause) => emit({
464
+ type: "teardownError",
465
+ port,
466
+ cause
467
+ }) });
468
+ });
469
+ const host = {
470
+ ctx: runtimeCtx,
471
+ run
472
+ };
473
+ return runtime.start(host).flatMap((serving) => {
474
+ servingInfo = serving.info;
475
+ tracker.advanceTo("serving");
476
+ runtimePublished.resolve(serving.info);
477
+ return (0, unthrown.fromSafePromise)(shutdown.promise).flatMap((reason) => finish(serving, reason));
478
+ });
479
+ }, { onTeardownError: (port, cause) => {
480
+ teardownErrors.push({
481
+ port,
482
+ cause
483
+ });
484
+ emit({
485
+ type: "teardownError",
486
+ port,
487
+ cause
488
+ });
489
+ } }).tapFailure((failure) => {
490
+ if (tracker.current() !== "stopping") emit({
491
+ type: "startFailed",
492
+ cause: failure.tag === "Err" ? failure.error : failure.cause
493
+ });
494
+ runtimePublished.resolve(void 0);
495
+ tracker.advanceTo("stopping");
496
+ disposeSignals();
497
+ disposeUncaught();
498
+ tracker.advanceTo("exited");
499
+ disposeProbes();
500
+ })),
501
+ stop: () => requestShutdown("runtimeStopped"),
502
+ requestDrain: () => requestShutdown("signal"),
503
+ phase: tracker.current,
504
+ ready,
505
+ probePort: () => (0, unthrown.fromSafePromise)(probeBound.promise),
506
+ runtimeInfo: () => (0, unthrown.fromSafePromise)(runtimePublished.promise)
507
+ };
508
+ };
509
+ //#endregion
510
+ //#region src/run-main.ts
511
+ const EX_SOFTWARE = 70;
512
+ const EX_CONFIG = 78;
513
+ const isConfig = (error) => error instanceof _btravstack_config.ConfigInvalid || error instanceof RuntimeStartFailed && error.cause instanceof _btravstack_config.ConfigInvalid;
514
+ const codeFor = (report) => {
515
+ if (report.reason === "uncaught") return EX_SOFTWARE;
516
+ return (report.drain?.abandoned ?? 0) > 0 || report.teardownErrors.length > 0 ? 2 : 0;
517
+ };
518
+ /**
519
+ * The exit-code half of `runMain`, on its own so the code table can be asserted
520
+ * against hand-built reports without booting a kernel. Exported for
521
+ * `run-main.spec.ts` only, not from `index.ts`.
522
+ */
523
+ const awaitExit = async (app, exit) => {
524
+ exit((await app.exited).match({
525
+ ok: codeFor,
526
+ errCases: (matcher) => matcher.with(unthrown.P._, (error) => isConfig(error) ? EX_CONFIG : 1),
527
+ defect: () => EX_SOFTWARE
528
+ }));
529
+ };
530
+ /**
531
+ * Boot a module and turn its outcome into a process exit code — the front
532
+ * door, and the one sanctioned place this package decides a process's fate.
533
+ * `start` composed with the wait for `exited`: use `start` instead when the
534
+ * `RunningApp` itself is wanted (a test, an embedder, a dev runner booting
535
+ * two applications — none of which may claim `process.exitCode`).
536
+ *
537
+ * `exit` is injectable and defaults to setting `process.exitCode`: `runMain`
538
+ * never calls `process.exit()`, so pending output is flushed, an embedding
539
+ * host keeps control of its own lifetime, and a test can observe the code
540
+ * without ending the run.
541
+ *
542
+ * | outcome | code |
543
+ * | --- | --- |
544
+ * | exited cleanly | `0` |
545
+ * | startup failure (a modeled `Err`) | `1` |
546
+ * | a configuration port that could not be bound (`ConfigInvalid`, `PROBE_PORT` included) | `78` |
547
+ * | drained with work abandoned | `2` |
548
+ * | exited with teardown errors | `2` |
549
+ * | stopped by an uncaught exception or unhandled rejection | `70` |
550
+ * | a defect | `70` |
551
+ *
552
+ * The two `70`s are the same statement reached through the two channels a bug
553
+ * can take. A crash takes precedence over abandoned work.
554
+ *
555
+ * @example
556
+ * ```ts
557
+ * // `OrderApi` imports the application next to `http()` and exports
558
+ * // `HttpRuntime`. `PORT`, `HOST` and `PROBE_PORT` are read inside the graph.
559
+ * await runMain(OrderApi);
560
+ * ```
561
+ */
562
+ const runMain = async (module, options = {}, exit = (code) => {
563
+ process.exitCode = code;
564
+ }) => {
565
+ await awaitExit(start(module, options), exit);
566
+ };
567
+ //#endregion
568
+ exports.LEVELS = LEVELS;
569
+ exports.Logger = Logger;
570
+ exports.Meter = Meter;
571
+ exports.RuntimePort = RuntimePort;
572
+ exports.RuntimeStartFailed = RuntimeStartFailed;
573
+ exports.SPAN_STATUS = SPAN_STATUS;
574
+ exports.Tracer = Tracer;
575
+ exports.currentUnit = currentUnit;
576
+ exports.releasedBy = releasedBy;
577
+ exports.runMain = runMain;
578
+ exports.start = start;
579
+ exports.stderrSink = stderrSink;
580
+ exports.systemClock = systemClock;