@btravstack/temporal-worker

中文文档
0.58.0发布于 2026/11/08MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 7730

The Temporal worker runtime for @btravstack/core: one unit per activity attempt, and a drain that honours the kernel's deadline

原始使用文档
文档目录8 节
  1. A worked example
  2. Diagnostic disclosure
  3. Schedules
  4. Testing
  5. Options
  6. Securing the connection
  7. The drain
  8. License

@btravstack/temporal-worker

The worker half of Temporal for @btravstack/core — starting workflows is a client's job, and belongs to @temporal-contract/client. One module providing the runtime, its configuration and its connection; one unit per activity attempt; and a drain that releases the kernel at the kernel's deadline, not Temporal's shutdownForceTime.

📖 Documentation · Reference · API Reference

pnpm add @btravstack/temporal-worker @btravstack/core @btravstack/config @btravstack/di unthrown \
  @temporalio/worker @temporalio/activity @temporalio/common \
  @temporal-contract/worker@^8.0.0-beta.7 @temporal-contract/contract@^8.0.0-beta.7

All nine are peer dependencies — install them. Node >=22.22. This package is ESM-only.

A worked example

import { runMain, Logger } from "@btravstack/core";
import { Provider } from "@btravstack/di";
import {
  ActivityInput,
  TemporalActivities,
  TemporalModule,
} from "@btravstack/temporal-worker";
import { P } from "unthrown";

// The module forked around every activity attempt, SEEDED with the validated
// input on `ActivityInput(contract)`. The tenant the contract carries is
// claimed here once — not at each activity — and the application's vertical
// is composed over it, so `PlaceOrder` is bound before any activity runs.
const ActivityUnit = Module("ActivityUnit")({
  needs: [ActivityInput(contract)],
  imports: [OrderApplicationModule],
  provides: [
    Provider(Tenant)({
      inject: { input: ActivityInput(contract) },
      sync: ({ input }) => TenantId(input.tenantId),
    }),
  ],
  exports: [Tenant, PlaceOrder],
});

// The application's half: its activities, on the starter's own activities
// port, typed by the contract (a worker serves one activities record, so
// there is nothing to name). What the leaves read off `context.unit` is
// declared once, beside `inject`.
const orderActivities = TemporalActivities(contract)({
  unit: { place: PlaceOrder },
  sync: () => ({
    placeOrder: {
      place: ({ errors, context, input }) =>
        context.unit.place
          .execute(input.orderId, input.quantity)
          .mapErrCases((matcher) =>
            matcher
              .with(P.tag("DuplicateOrder"), (error) =>
                errors.OrderAlreadyPlaced({ id: error.id }),
              )
              .with(P.tag("InvalidOrderId"), (error) =>
                errors.InvalidOrderId({ id: error.id }),
              )
              .with(P.tag("InvalidQuantity"), (error) =>
                errors.InvalidQuantity({ id: error.id }),
              ),
          ),
    },
  }),
});

// The composition root: a di module, plus the contract, the activities
// provider and the workflow source — and nothing else to know.
const OrderWorker = TemporalModule("OrderWorker")({
  contract,
  activities: orderActivities,
  workflows: {
    workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"),
  },
  imports: [AppModule],
  unit: { activity: ActivityUnit },
});

await runMain(OrderWorker);

TemporalModule imports the starter — TemporalRuntime, TemporalConfig bound from TEMPORAL_ADDRESS (default 127.0.0.1:7233) and TEMPORAL_NAMESPACE (default default), TemporalConnection opened as a resource of the graph and closed on every exit path — provides the activities and exports the runtime port. A service that will not answer is a modeled TemporalUnreachable, exit 1; the starter calls declareActivitiesHandler itself, inside its own error qualifier, so a contract it cannot satisfy is a startup Err, not a defect. runtimeInfo() reads { taskQueue, namespace } back once the worker is polling.

Activity-piece DI identities include the contract task queue. Separate worker contracts can reuse keys without sharing a port; wire activity names stay unchanged.

A worker polling for several workflows can be several slices instead of one record: TemporalWorkflowActivities(contract, key)({ inject: { name: Dep }, unit, fork, sync }) mints a provider for ONE workflow's activities (or a contract-global activity), typed by the key alone, and TemporalActivities(contract)([...]) composes an array of them into the same activities provider TemporalModule takes — the array must cover every key the contract declares, and each piece's own port must still be discharged (provides), since the composed provider's deps are the pieces' ports, not what they close over. A workflow's activities belong to exactly one slice, and the compiler holds only half of that: coverage is checked, injectivity is not, so two slices claiming one key type-check together. It is di's duplicate-provider defect at build once both pieces are discharged as providers in one graph — wire in only one and the other's implementation is silently never registered.

A piece's unit names the ports its activities read off context.unit, resolved out of the per-attempt fork of the module bound on unit.activity — which is seeded with the validated input on ActivityInput(contract), so a unit module derives a tenant from the invocation rather than from an ambient record. The record arm above takes the same unit: — one record for every entry in it — so a worker that has not outgrown one function reaches context.unit without slicing first. The root is checked against what was declared either way: bind a module that does not export one of those ports and TemporalModule refuses it, naming the port.

A piece whose activities carry a different scope from the rest names its own module on fork: its attempts fork THAT module, seeded the same way, instead of unit.activity — a listing activity with no tenant in its input beside a fan-out whose activities each act for one. The piece's unit is then checked against its own module at the piece's call, the module's unmet needs become the root's (named by start), and an override reaches it under the module's name: overridden(root, providers, { unit: { TenantUnit: [...] } }).

Its own test suite needs a Docker daemon: the specs run against the shared temporalio/temporal development-server container internal/test-infra starts once per machine and every workspace reuses. Nothing else here does.

Diagnostic disclosure

withTemporalDiagnostics(options, work) scopes Temporal's diagnostic defaults without eagerly creating its native Runtime. Start the worker inside the callback and keep it open until owned workers and connections close. Startup refusals or defects before the first native connection then leave no diagnostic Runtime keeping the process alive. The helper returns an AsyncResult preserving the callback's outcome, restoring owned defaults on success, modeled error or defect, while preserving active Runtime instances and replacement configuration.

import { createLogger, diagnosticPolicy, jsonSink } from "@btravstack/observability";
import { withTemporalDiagnostics } from "@btravstack/temporal-worker";
import { fromSafePromise } from "unthrown";

await withTemporalDiagnostics(
  { logger: createLogger(jsonSink()), diagnostics: diagnosticPolicy() },
  () => fromSafePromise(runMain(OrderWorker)),
).get();

TemporalDiagnosticsOptions holds { logger, diagnostics, nativeLogLevel? }. SDK, workflow, activity and native Core logs reach the shared DiagnosticPolicy; native verbosity defaults to WARN, and the kernel owns process signals. An existing Runtime or overlapping scope is a defect. Do not call Runtime.install inside the callback: explicit SDK installation overrides scoped defaults. Temporal owns connection teardown and log flushing; the helper never closes an active Runtime or repairs native resources leaked after Runtime creation.

installTemporalDiagnostics(options) remains the eager, process-wide installer returning Result<void, never>. A late or repeated installation defects. It cannot release an idle Runtime after pre-connection startup failure; prefer the scoped helper for application startup.

temporalDiagnosticLogger(logger, diagnostics) returns a Temporal SDK logger for an application that configures Runtime.install itself. Native log forwarding still needs that Runtime configuration. Both helpers accept the policy declared in @btravstack/core; @btravstack/observability provides diagnosticPolicy(). See diagnostic disclosure for composition and the separate payload/failure boundary.

Schedules

@btravstack/temporal-worker/schedule exports ensureSchedule — the typed schedule client's create, made idempotent, so a deploy that runs again updates the schedule instead of failing on ScheduleAlreadyExistsError. @temporal-contract/client is an optional peer reached only through that subpath. Why scheduled work costs a cluster here, and when it should not be this stack at all: Run something on a schedule.

Testing

@btravstack/temporal-testing runs a worker's specs against one real Temporal server per Docker host, shared across test processes, with a namespace per spec file. It is a dev dependency of its own, so this package ships no test code. See Test a Temporal worker.

Options

  • taskQueue: pins TEMPORAL_TASK_QUEUE, defaulting to the contract queue; see the reference.

TemporalModule(name)({...}) takes the starter's options plus activities and the module lists (imports, provides, exports, needs). It is the only way to compose the starter, which is not exported:

OptionWhat it is
contractthe temporal-contract contract; the task queue this worker polls is read off it
activitiesthe activities provider — what TemporalActivities(contract)(...) returns for this contract
workflowswhere the sandbox's code comes from: { workflowsPath } or { workflowBundle }; required unless the contract declares no workflows
addresspins the service instead of reading TEMPORAL_ADDRESS
namespacepins the namespace instead of reading TEMPORAL_NAMESPACE
gracePeriodpins TEMPORAL_GRACE_PERIOD_MS — Temporal's shutdownGraceTime, a Duration (default 10_000 ms)
forceAfterpins TEMPORAL_FORCE_AFTER_MS — Temporal's shutdownForceTime (default 15_000 ms); keep it under DRAIN_TIMEOUT_MS
maxCachedWorkflowspins TEMPORAL_MAX_CACHED_WORKFLOWS; zero disables caching, otherwise at least two; absent preserves the SDK default
maxConcurrentWorkflowTaskExecutionspins TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASK_EXECUTIONS; fixed workflow-task slots; absent preserves the SDK default
maxConcurrentActivityTaskExecutionspins TEMPORAL_MAX_CONCURRENT_ACTIVITY_TASK_EXECUTIONS; fixed non-local activity slots; absent preserves the SDK default
unit{ activity } — the module the worker forks around every activity it dispatches, seeded with the validated input
apiKeythe application port for renewable TemporalApiKeySource bearer credentials
dataConverterthe application's port for the DataConverter (payload codecs) the worker and its clients share

The full table — required/optional, defaults, and the reasoning — lives on the reference page, which is this list's one detailed home.

Securing the connection

TLS, mTLS and an API key are configured from the environment under Temporal's own names. For mTLS:

export TEMPORAL_TLS=true
export TEMPORAL_TLS_SERVER_CA_CERT_PATH=/var/run/secrets/temporal/ca.crt
export TEMPORAL_TLS_SERVER_NAME=temporal.example.internal
export TEMPORAL_TLS_CLIENT_CERT_PATH=/var/run/secrets/temporal/tls.crt
export TEMPORAL_TLS_CLIENT_KEY_PATH=/var/run/secrets/temporal/tls.key

or TEMPORAL_API_KEY_PATH (or TEMPORAL_API_KEY), which turns TLS on unless TEMPORAL_TLS says otherwise. Mounted files are read again every TEMPORAL_CREDENTIALS_RELOAD_MS (default 60000): a renewed certificate moves the worker to a new connection without a restart, a renewed key is set on the open one. A combination it cannot connect with is a ConfigInvalid at boot. The reference has the rules and the limits.

An application can supply renewable bearer credentials through an apiKey port implementing TemporalApiKeySource.read(). The source owns token acquisition, caching and expiry; the starter polls it with the credentials reload interval. A source requires TLS and excludes inline/file API keys and mTLS. See the reference for composition and fail-closed behavior.

The drain

worker.shutdown() stops polling at once, but run() resolves only when the last in-flight activity has finished — on Temporal's own shutdownForceTime. Serving.drain(signal) races that wait against the kernel's deadline and returns at whichever comes first: the kernel gets its thread back on time, the work is reported abandoned, and the worker keeps winding down on Temporal's clock until the process exits. @temporalio/worker exposes no public forced shutdown, so "stop waiting" is the escalation. Result → activity failure is declareActivitiesHandler's and is deliberately not duplicated here. The rest is on the documentation site.

TemporalModule automatically exports publicly available Logger and HealthChecks ports, including whole-module re-exports and providers declared at the root. It keeps private infrastructure hidden and business exports explicit.

License

MIT © Benoit TRAVERS

版本与发布记录

@btravstack/temporal-worker 共发布 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/temporal-worker 的常见问题

@btravstack/temporal-worker 是什么?

The Temporal worker runtime for @btravstack/core: one unit per activity attempt, and a drain that honours the kernel's deadline

如何安装 @btravstack/temporal-worker?

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

@btravstack/temporal-worker 支持 TypeScript 吗?

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

@btravstack/temporal-worker 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@btravstack/temporal-worker 需要什么版本的 Node.js?

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

@btravstack/temporal-worker 可以商用吗?

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

@btravstack/temporal-worker 的下载量有多少?

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

@btravstack/temporal-worker 还在维护吗?

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