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