@btravstack/core 0.45.1 → 0.47.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/README.md CHANGED
@@ -166,6 +166,12 @@ ports hidden. Application starters use it alongside their own runtime exports.
166
166
  - **Probes from the state machine.** `GET /livez` and `GET /readyz` on
167
167
  `PROBE_PORT` (default `9000`) and `PROBE_HOST` (default `0.0.0.0`, so a
168
168
  kubelet `httpGet` probe reaches them), up before the graph is built.
169
+ A `HealthCheck` can opt into gating `/readyz` with `readiness: true`
170
+ (1000 ms cache) or `readiness: { intervalMs: 5000 }`. Concurrent probes and
171
+ `/healthz` share that check's cache; ordinary checks only affect `/healthz`.
172
+ Failed checks can recover without a restart; drain and crash readiness
173
+ remain latched. `RunningApp.ready()` reads only the lifecycle.
174
+ See [probes](https://btravstack.github.io/btravstack/reference/core/probes).
169
175
  - **Configuration from the environment, typed.** The kernel provides
170
176
  [`@btravstack/config`](../config)'s `Env` port to every graph it boots; a
171
177
  bad value is a `ConfigInvalid` naming every fault at once, and exit `78`.
package/dist/index.cjs CHANGED
@@ -332,6 +332,46 @@ const drainApp = (args) => {
332
332
  });
333
333
  };
334
334
  //#endregion
335
+ //#region src/health-monitor.ts
336
+ const cachedCheck = (check, clock) => {
337
+ const interval = typeof check.readiness === "object" ? check.readiness.intervalMs : 1e3;
338
+ let cached;
339
+ let expiresAt = 0;
340
+ let pending;
341
+ return () => {
342
+ if (!Number.isFinite(interval) || interval <= 0) return (0, unthrown.OkAsync)({
343
+ status: "unhealthy",
344
+ components: [{
345
+ name: check.name,
346
+ status: "unhealthy",
347
+ reason: "readiness interval must be a positive finite number"
348
+ }]
349
+ });
350
+ if (cached !== void 0 && clock.now() < expiresAt) return (0, unthrown.OkAsync)(cached);
351
+ pending ??= runHealthChecks([check]).map((report) => {
352
+ cached = report;
353
+ expiresAt = clock.now() + interval;
354
+ pending = void 0;
355
+ return report;
356
+ });
357
+ return pending;
358
+ };
359
+ };
360
+ /** Per-application checks; only readiness contributors retain a cached report. */
361
+ const healthMonitor = (checks, clock) => {
362
+ const entries = checks.map((check) => ({
363
+ readiness: Boolean(check.readiness),
364
+ run: check.readiness ? cachedCheck(check, clock) : () => runHealthChecks([check])
365
+ }));
366
+ return {
367
+ health: () => (0, unthrown.allAsync)(entries.map((entry) => entry.run())).map((reports) => ({
368
+ status: reports.every((report) => report.status === "healthy") ? "healthy" : "unhealthy",
369
+ components: reports.flatMap((report) => report.components)
370
+ })),
371
+ readiness: () => (0, unthrown.allAsync)(entries.filter((entry) => entry.readiness).map((entry) => entry.run())).map((reports) => reports.every((report) => report.status === "healthy"))
372
+ };
373
+ };
374
+ //#endregion
335
375
  //#region src/phase.ts
336
376
  const ORDER = [
337
377
  "building",
@@ -363,7 +403,11 @@ const startProbeServer = (args) => (0, unthrown.fromSafePromise)(new Promise((re
363
403
  return;
364
404
  }
365
405
  if (request.url === "/readyz") {
366
- respond(response, args.ready(), "ready");
406
+ if (!args.ready() || args.readiness === void 0) {
407
+ respond(response, args.ready(), "ready");
408
+ return;
409
+ }
410
+ args.readiness().map((healthy) => respond(response, args.ready() && healthy, "ready"));
367
411
  return;
368
412
  }
369
413
  if (request.url === "/healthz") {
@@ -716,8 +760,9 @@ const start = (module, options = {}) => {
716
760
  uptimeMs: clock.now() - startedAt,
717
761
  ...abandonedAt === void 0 ? {} : { abandonedAt }
718
762
  });
719
- let healthChecks = [];
720
- const health = () => runHealthChecks(healthChecks);
763
+ let monitor = healthMonitor([], clock);
764
+ const health = () => monitor.health();
765
+ const readiness = () => monitor.readiness();
721
766
  const probeBound = Promise.withResolvers();
722
767
  const runtimePublished = Promise.withResolvers();
723
768
  const leaveStartup = () => {
@@ -774,6 +819,7 @@ const start = (module, options = {}) => {
774
819
  hostname: probes.hostname,
775
820
  live,
776
821
  ready,
822
+ readiness,
777
823
  health
778
824
  }).tap((server) => {
779
825
  probeBoundPort = server.port;
@@ -840,7 +886,7 @@ const start = (module, options = {}) => {
840
886
  tracker.advanceTo("starting");
841
887
  const runtime = ctx.get(RuntimePort);
842
888
  runtimeName = runtime.name;
843
- healthChecks = ctx.get(HealthChecks);
889
+ monitor = healthMonitor(ctx.get(HealthChecks), clock);
844
890
  const runtimeCtx = ctx;
845
891
  const rebuilt = rebuildGuard(runtime.name, root);
846
892
  for (const unit of Object.values(runtime.units ?? {})) {
package/dist/index.d.cts CHANGED
@@ -58,6 +58,15 @@ type HealthCheck = {
58
58
  * its own latency omits it.
59
59
  */
60
60
  readonly timeoutMs?: number;
61
+ /**
62
+ * Opt into gating `/readyz`. `true` caches the result for 1000 ms;
63
+ * an object selects a positive, finite interval. Concurrent probes share
64
+ * one evaluation, and `/healthz` uses the same cache for this check.
65
+ * Omitted or `false` leaves readiness independent of this component.
66
+ */
67
+ readonly readiness?: boolean | {
68
+ readonly intervalMs: number;
69
+ };
61
70
  };
62
71
  declare const HealthChecks_base: import("@btravstack/di").ManyPortClass<"HealthChecks">;
63
72
  /**
@@ -864,9 +873,9 @@ type RunningApp<E, Info = never> = {
864
873
  readonly requestDrain: () => void;
865
874
  readonly phase: () => Phase;
866
875
  /**
867
- * The predicate `/readyz` answers from — serving, and not forced unready by
868
- * a drain or an uncaught exception — read synchronously, which the probe
869
- * endpoint is not.
876
+ * The synchronous lifecycle predicate: serving, and not forced unready by
877
+ * a drain or an uncaught exception. `/readyz` additionally consults opted-in
878
+ * dependency checks; this method does not evaluate them.
870
879
  */
871
880
  readonly ready: () => boolean;
872
881
  /**
package/dist/index.d.mts CHANGED
@@ -58,6 +58,15 @@ type HealthCheck = {
58
58
  * its own latency omits it.
59
59
  */
60
60
  readonly timeoutMs?: number;
61
+ /**
62
+ * Opt into gating `/readyz`. `true` caches the result for 1000 ms;
63
+ * an object selects a positive, finite interval. Concurrent probes share
64
+ * one evaluation, and `/healthz` uses the same cache for this check.
65
+ * Omitted or `false` leaves readiness independent of this component.
66
+ */
67
+ readonly readiness?: boolean | {
68
+ readonly intervalMs: number;
69
+ };
61
70
  };
62
71
  declare const HealthChecks_base: import("@btravstack/di").ManyPortClass<"HealthChecks">;
63
72
  /**
@@ -864,9 +873,9 @@ type RunningApp<E, Info = never> = {
864
873
  readonly requestDrain: () => void;
865
874
  readonly phase: () => Phase;
866
875
  /**
867
- * The predicate `/readyz` answers from — serving, and not forced unready by
868
- * a drain or an uncaught exception — read synchronously, which the probe
869
- * endpoint is not.
876
+ * The synchronous lifecycle predicate: serving, and not forced unready by
877
+ * a drain or an uncaught exception. `/readyz` additionally consults opted-in
878
+ * dependency checks; this method does not evaluate them.
870
879
  */
871
880
  readonly ready: () => boolean;
872
881
  /**
package/dist/index.mjs CHANGED
@@ -331,6 +331,46 @@ const drainApp = (args) => {
331
331
  });
332
332
  };
333
333
  //#endregion
334
+ //#region src/health-monitor.ts
335
+ const cachedCheck = (check, clock) => {
336
+ const interval = typeof check.readiness === "object" ? check.readiness.intervalMs : 1e3;
337
+ let cached;
338
+ let expiresAt = 0;
339
+ let pending;
340
+ return () => {
341
+ if (!Number.isFinite(interval) || interval <= 0) return OkAsync({
342
+ status: "unhealthy",
343
+ components: [{
344
+ name: check.name,
345
+ status: "unhealthy",
346
+ reason: "readiness interval must be a positive finite number"
347
+ }]
348
+ });
349
+ if (cached !== void 0 && clock.now() < expiresAt) return OkAsync(cached);
350
+ pending ??= runHealthChecks([check]).map((report) => {
351
+ cached = report;
352
+ expiresAt = clock.now() + interval;
353
+ pending = void 0;
354
+ return report;
355
+ });
356
+ return pending;
357
+ };
358
+ };
359
+ /** Per-application checks; only readiness contributors retain a cached report. */
360
+ const healthMonitor = (checks, clock) => {
361
+ const entries = checks.map((check) => ({
362
+ readiness: Boolean(check.readiness),
363
+ run: check.readiness ? cachedCheck(check, clock) : () => runHealthChecks([check])
364
+ }));
365
+ return {
366
+ health: () => allAsync(entries.map((entry) => entry.run())).map((reports) => ({
367
+ status: reports.every((report) => report.status === "healthy") ? "healthy" : "unhealthy",
368
+ components: reports.flatMap((report) => report.components)
369
+ })),
370
+ readiness: () => allAsync(entries.filter((entry) => entry.readiness).map((entry) => entry.run())).map((reports) => reports.every((report) => report.status === "healthy"))
371
+ };
372
+ };
373
+ //#endregion
334
374
  //#region src/phase.ts
335
375
  const ORDER = [
336
376
  "building",
@@ -362,7 +402,11 @@ const startProbeServer = (args) => fromSafePromise(new Promise((resolve) => {
362
402
  return;
363
403
  }
364
404
  if (request.url === "/readyz") {
365
- respond(response, args.ready(), "ready");
405
+ if (!args.ready() || args.readiness === void 0) {
406
+ respond(response, args.ready(), "ready");
407
+ return;
408
+ }
409
+ args.readiness().map((healthy) => respond(response, args.ready() && healthy, "ready"));
366
410
  return;
367
411
  }
368
412
  if (request.url === "/healthz") {
@@ -715,8 +759,9 @@ const start = (module, options = {}) => {
715
759
  uptimeMs: clock.now() - startedAt,
716
760
  ...abandonedAt === void 0 ? {} : { abandonedAt }
717
761
  });
718
- let healthChecks = [];
719
- const health = () => runHealthChecks(healthChecks);
762
+ let monitor = healthMonitor([], clock);
763
+ const health = () => monitor.health();
764
+ const readiness = () => monitor.readiness();
720
765
  const probeBound = Promise.withResolvers();
721
766
  const runtimePublished = Promise.withResolvers();
722
767
  const leaveStartup = () => {
@@ -773,6 +818,7 @@ const start = (module, options = {}) => {
773
818
  hostname: probes.hostname,
774
819
  live,
775
820
  ready,
821
+ readiness,
776
822
  health
777
823
  }).tap((server) => {
778
824
  probeBoundPort = server.port;
@@ -839,7 +885,7 @@ const start = (module, options = {}) => {
839
885
  tracker.advanceTo("starting");
840
886
  const runtime = ctx.get(RuntimePort);
841
887
  runtimeName = runtime.name;
842
- healthChecks = ctx.get(HealthChecks);
888
+ monitor = healthMonitor(ctx.get(HealthChecks), clock);
843
889
  const runtimeCtx = ctx;
844
890
  const rebuilt = rebuildGuard(runtime.name, root);
845
891
  for (const unit of Object.values(runtime.units ?? {})) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/core",
3
- "version": "0.45.1",
3
+ "version": "0.47.0",
4
4
  "description": "The application kernel: boot a di module into a running process, and stop it without losing work",
5
5
  "keywords": [
6
6
  "application",
@@ -45,8 +45,8 @@
45
45
  "./package.json": "./package.json"
46
46
  },
47
47
  "devDependencies": {
48
- "@btravstack/config": "0.45.1",
49
- "@btravstack/di": "0.45.1",
48
+ "@btravstack/config": "0.47.0",
49
+ "@btravstack/di": "0.47.0",
50
50
  "@btravstack/tsconfig": "0.4.0",
51
51
  "@stryker-mutator/core": "10.0.0",
52
52
  "@stryker-mutator/vitest-runner": "10.0.0",
@@ -60,8 +60,8 @@
60
60
  "vitest": "5.0.3"
61
61
  },
62
62
  "peerDependencies": {
63
- "@btravstack/config": "^0.45.1",
64
- "@btravstack/di": "^0.45.1",
63
+ "@btravstack/config": "^0.47.0",
64
+ "@btravstack/di": "^0.47.0",
65
65
  "unthrown": "^5.0.0"
66
66
  },
67
67
  "engines": {