@btravstack/core

中文文档
0.58.0发布于 2026/11/08MITTypeScriptESM + CJS零依赖构建来源已验证近 30 天下载 1.2万

The application kernel: boot a di module into a running process, and stop it without losing work

原始使用文档
文档目录6 节
  1. A worked example
  2. For a runtime author
  3. Application infrastructure exports
  4. Diagnostic disclosure
  5. What you get
  6. License

@btravstack/core

The application kernel: boot a @btravstack/di module into a running process, and stop it again without losing work — one lifecycle state machine, one unit-of-work registry, one Runtime contract. It knows nothing about HTTP, AMQP or Temporal, never throws, and never calls process.exit.

📖 Documentation · Get Started · Reference · API Reference

pnpm add @btravstack/core @btravstack/config @btravstack/di unthrown

@btravstack/config, @btravstack/di and unthrown are peer dependencies — install all four. The kernel itself depends on node: builtins only. Node >=22.

A worked example

An application is a di module. A runtime is a service of that module, provided on a port declared over RuntimePort; start finds it there, builds the graph, and drives what it finds. The transport starters (@btravstack/http-server, @btravstack/temporal-worker, @btravstack/amqp-worker) provide real runtimes; this one is a timer, so the sample stays self-contained:

import { Module, Port, Provider } from "@btravstack/di";
import {
  RuntimePort,
  runMain,
  start,
  type Runtime,
  type Serving,
} from "@btravstack/core";
import { Ok, OkAsync } from "unthrown";

class Greeter extends Port("Greeter")<{
  readonly greet: (name: string) => string;
}> {}

const AppModule = Module("App")({
  provides: [
    Provider(Greeter)({
      inject: {},
      value: { greet: (name: string) => `hello, ${name}` },
    }),
  ],
  exports: [Greeter],
});

// A runtime owns the transport; the kernel owns the lifecycle. This one is a
// timer, so the sample stays self-contained — no published runtime models a
// timer, and `@btravstack/http-server` would pull in a real dependency this
// sample doesn't need.
const ticker: Runtime<typeof Greeter> = {
  name: "ticker",
  resolves: [Greeter],
  start: (host) => {
    const timer = setInterval(() => {
      // Every piece of work goes through `host.run`: that is what makes it
      // count towards the drain, and what gives it an `AbortSignal`.
      //
      // The unit's `Result` is the runtime's to map — the kernel hands it back
      // and stays out of it. A timer has nowhere to return one, so it observes
      // it instead; dropping it would hide the work's `Err` *and* a `Defect`.
      void host
        .run({ kind: "tick", id: `${Date.now()}` }, (unit, signal) =>
          signal.aborted ? Ok("") : Ok(unit.ctx.get(Greeter).greet("world")),
        )
        .tapFailure((failure) => {
          process.stderr.write(`${JSON.stringify({ tick: failure.tag })}\n`);
        });
    }, 1_000);

    const serving: Serving = {
      // Stop accepting new work. In-flight units are the kernel's business.
      drain: () => {
        clearInterval(timer);
        return OkAsync();
      },
      stop: () => OkAsync(),
    };

    return OkAsync(serving);
  },
};

// A runtime is a service the module provides, on a port declared over
// `RuntimePort` — `start` finds it by that port in the module's exports. The
// composition root is what differs between an `api`, a `worker` and a
// `consumer` process; the application module is the same in all three.
class Ticker extends RuntimePort<Runtime<typeof Greeter>> {}

const TickerApp = Module("TickerApp")({
  imports: [AppModule],
  provides: [Provider(Ticker)({ inject: {}, value: ticker })],
  exports: [Greeter, Ticker],
});

await runMain(TickerApp);

runMain is the front door: it boots the module, awaits the application's exit and turns the outcome into a process exit code — one call, and the whole of a main.ts. Underneath it is start, which returns immediately with a RunningApp and decides nothing about the process. The runtime's declared needs are checked against the module's exports at compile time — a TickerApp that does not export Greeter is a type error at the call, and so is a module that exports no runtime port at all.

For a runtime author

Serving carries an optional third member, stopped — the channel a runtime reports its own death on, () => AsyncResult<void, never>. Without it the lifecycle only ever moves on a signal or a stop() call, so a worker whose poll loop died stays alive with /readyz answering 200: a pod in a Service's endpoints, consuming nothing. A runtime that can stop on its own account should implement it; the ticker above does not, because a setInterval has no third state to report, and that is why the field is optional rather than required.

The obligation is the half a signature cannot express: withdraw after a stop the kernel asked for. An arm that settles on the ordinary path races every clean shutdown. @btravstack/temporal-worker is the worked implementation, and the runtime reference states the contract in full.

Two helpers sit beside the Runtime contract, because every transport needs them and none should own a private copy:

  • releasedBy(signal, running) — running, but no later than the kernel's drain deadline, for a Serving.drain whose work settles on somebody else's clock.
  • traceIdOfTraceparent(header) — the trace-id field of a W3C traceparent, and nothing else of it: the parent's span id is dropped, since UnitMeta.traceId is a correlation id rather than a span context. Answers undefined for anything the specification calls invalid — a malformed header, an all-zero trace id, an all-zero parent id, the reserved version ff — so your runtime falls back to its own id instead of adopting one that means nothing. A higher version's extra fields are read rather than refused.

Application infrastructure exports

infrastructureExports(imports, provides) returns the publicly available Logger and HealthChecks ports from imported modules and root providers. InfrastructureExports<I, P> describes those ports at the type level. It follows whole-module re-exports while keeping private providers and business ports hidden. Application starters use it alongside their own runtime exports.

Diagnostic disclosure

DiagnosticPolicy is the shared structural contract for rendering diagnostic messages, attributes and causes. Pass the same policy object explicitly to logging, tracing and Temporal adapters. Its message, attributes and cause methods must be synchronous and must not throw; rendering leaves the original Result and its failure channel unchanged.

@btravstack/observability implements the production policy as diagnosticPolicy() and exposes sensitive debugging as an explicit separate choice. The kernel declares the type without implementing disclosure rules or adding a policy port. See the observability contracts.

What you get

  • A drain that survives Kubernetes. On SIGTERM: readiness flips false, the kernel waits preDrainDelayMs (PRE_DRAIN_DELAY_MS, default 5_000) before telling the runtime to stop accepting — endpoint removal is eventually consistent — then in-flight work gets drainTimeoutMs (DRAIN_TIMEOUT_MS, default 20_000); whatever is still open is aborted and reported abandoned. The teardown that follows gets stopTimeoutMs (STOP_TIMEOUT_MS, default 5_000), so a release that never settles is reported as abandonedAt: "stop" rather than leaving the process in stopping with no exit report at all. A second signal stops every wait at once. The three sum to the pod's terminationGracePeriodSeconds default of 30 s, which is why the deployment sets them and the options only pin them.
  • Probes from the state machine. GET /livez and GET /readyz on PROBE_PORT (default 9000) and PROBE_HOST (default 0.0.0.0, so a kubelet httpGet probe reaches them), up before the graph is built. A HealthCheck can opt into gating /readyz with readiness: true (1000 ms cache) or readiness: { intervalMs: 5000 }. Concurrent probes and /healthz share that check's cache; ordinary checks only affect /healthz. Failed checks can recover without a restart; drain and crash readiness remain latched. RunningApp.ready() reads only the lifecycle. See probes.
  • Configuration from the environment, typed. The kernel provides @btravstack/config's Env port to every graph it boots; a bad value is a ConfigInvalid naming every fault at once, and exit 78.
  • Teardown on every path, with failing finalisers in ExitReport.teardownErrors, never masking the exit reason.
  • A per-unit scope no handler manages — the runtime calls unit.fork(module, seed) from inside host.run's work callback, and the kernel closes the scope when the unit settles.
  • An ambient record, not an ambient container — currentUnit() reads { unitId, traceId, tenantId, signal }; services never travel there. signal is the very AbortSignal the unit's work callback is handed, so a runtime whose work is a library's next() — a Temporal activity, an AMQP delivery — can still honour the drain deadline.
  • Observation as a set port, not a logger in every starter. A starter hands each operation to Observers — observe(observers, operation) for a span-shaped start-then-settle, observed(observers, operation, call) around one AsyncResult-returning call — and holds no Logger, Meter or Tracer of its own. Composing @btravstack/observability is what turns those operations into lines, spans and instruments; composing nothing costs an inert call.
  • Nothing throws. Every async surface is an unthrown AsyncResult; runMain sets process.exitCode — 0 clean, 1 a modeled startup error, 2 abandoned work or a failed finaliser, 70 a crash, 78 a bad environment.

Every sample above is compiled by src/docs-examples.test-d.ts. The rest — the Runtime contract, the drain, embedding without runMain — is on the documentation site. Testing what the kernel boots is @btravstack/testing's job — a bootFixture for test.extend, tapped to read services out of a running app, an in-memory runtime and a fake clock — kept out of this package so a production bundle never pulls the fakes in.

License

MIT © Benoit TRAVERS

版本与发布记录

@btravstack/core 共发布 62 个版本,首次发布于 2026/08/26;过去 12 个月发布了 62 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.58.0latest2026/11/08今天
0.57.02026/11/071 天前
0.56.12026/11/062 天前
0.56.02026/11/062 天前
0.55.12026/11/035 天前
0.55.02026/11/035 天前

关于 @btravstack/core 的常见问题

@btravstack/core 是什么?

The application kernel: boot a di module into a running process, and stop it without losing work

如何安装 @btravstack/core?

在项目目录运行 npm install @btravstack/core,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:unthrown(^5.0.0)、@btravstack/di(^0.58.0)、@btravstack/config(^0.58.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@btravstack/core 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@btravstack/core 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@btravstack/core 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@btravstack/core 可以商用吗?

@btravstack/core 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@btravstack/core 的下载量有多少?

根据 npm 官方下载统计,@btravstack/core 在 2026/10/09 – 2026/11/07 这 30 天内被下载 11,736 次,日均约 391 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@btravstack/core 还在维护吗?

最新版本 0.58.0 发布于 2026/11/08(今天);至今共发布 62 个版本,其中过去 12 个月发布了 62 个;首次发布于 2026/08/26。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。