@btravstack/temporal-worker
Reference. A complete, structured description of the Temporal worker starter's public surface: every export of
@btravstack/temporal-worker, its options and defaults, and how a worker's drain meets the kernel's deadline. For the task, see Run a Temporal worker; for the reasoning, Starters and Draining, in three beats; for the worked example, Order Temporal worker. Generated signatures are under API reference.
Exports
packages/temporal-worker/src/index.ts exports exactly this:
| Export | Kind | What it is |
|---|---|---|
TemporalModule | value | TemporalModule(name)({ contract, activities, workflows?, address?, namespace?, taskQueue?, gracePeriod?, forceAfter?, unit?, dataConverter?, apiKey?, imports?, provides?, exports?, needs? }) — a di Module(name)({...}) that also takes the activities provider |
TemporalModuleOptions | type | The options object TemporalModule(name) takes |
TemporalActivities | value | TemporalActivities(contract) — the builder on the starter's own activities port, typed for contract, so the next call is { inject: { name: Dep }, unit?, sync }, or ([pieces]) to compose one provider per workflow |
ActivitiesPortOf<C> | type | The activities port's class typed for C — what a composed orderActivities's .port is |
ActivitiesInstanceOf<C> | type | That port's instance typed for C |
TemporalWorkflowActivities | value | TemporalWorkflowActivities(contract, key) — one workflow's activities (or a contract-global activity) as a provider of its own, typed by key alone; the next call is { inject: { name: Dep }, unit?, fork?, sync }, and the piece is what TemporalActivities(contract)([...]) composes |
WorkflowActivitiesPortOf<C, K> | type | One piece's port class, typed for the one key K it implements |
ActivityInput | value | ActivityInput(contract) — the port the validated invocation is seeded on, typed by that contract's own activity inputs; a unit module names it in needs and injects it |
ActivityInputOf<C> | type | The union of every activity input C declares, workflow-local and contract-global alike — what ActivityInput(C) carries |
ActivityInputPortOf<C> | type | ActivityInput(C)'s port class, typed for C |
TemporalOptions | type | the starter's own options, which TemporalModule takes beside activities and the module lists |
TemporalRuntime | value | class TemporalRuntime extends RuntimePort<Runtime<never, TemporalInfo>> {} — the runtime's port |
TemporalConfig | value | class TemporalConfig extends Port("TemporalConfig")<{ address: string; namespace: string; taskQueue: string; gracePeriodMs: number; forceAfterMs: number }> {} — where the service is and what its shutdown budget is, bound from the environment |
TemporalConnection | value | class TemporalConnection extends Port("TemporalConnection")<NativeConnection> {} — the connection, a resource of the graph |
TemporalUnreachable | value | TaggedError("TemporalUnreachable")<{ address: string; cause: unknown }> — the service did not answer |
TemporalInfo | type | { readonly taskQueue: string; readonly namespace: string } — published on Serving.info once polling |
WorkflowSource | type | { workflowsPath: string } | { workflowBundle: WorkflowBundleWithSourceMap } — where the sandbox's code comes from |
ActivitiesPortOf<C> / ActivitiesInstanceOf<C> / WorkflowActivitiesPortOf<C, K> are exported as types only, and only because declaration emit forces it: an application that composes orderActivities = TemporalActivities(contract)([piece, piece]) and exports it by name (or a slice that exports one piece by name) needs to be able to print that type, and a type built from an unexported alias fails TS4023 ("has or is using name 'ID' … but cannot be named") the moment it tries. TemporalActivitiesPort — Port("TemporalActivities"), the starter's own activities port, declared once — and ActivitiesOf<C> (DeclareActivitiesHandlerOptions<C>["activities"], the implementations record declareActivitiesHandler takes, its service) live in src/temporal-runtime.ts and stay not exported from the entry point: nothing outside this package legitimately constructs a provider against the bare port — a consumer always goes through TemporalActivities(contract) or TemporalWorkflowActivities(contract, key), both of which cast it to the typed alias — so there is nothing a value export would help with, and the port is reached as provider.port when a caller needs it, with the types inferred at the call. ActivitiesKeyOf<C> and WORKFLOW_ACTIVITIES_PREFIX (src/workflow-activities.ts) are unexported on the same terms — a bare key nothing outside that file needs to name, and the string prefix a piece's port id carries.
TemporalModule(name)({...})
Everything Module(name)({...}) takes, plus the contract, the activities provider and the workflow source. It is the only way to compose the starter: it imports it, prepends activities to provides, prepends TemporalRuntime to exports — start reads the runtime off the root's exports — and hands the augmented tuples to di's own Module(name).
| Option | Required | Default | What it is |
|---|---|---|---|
contract | yes | — | a temporal-contract ContractDefinition; the task queue this worker polls is read off it |
activities | yes | — | the activities provider — a Provider<ActivitiesInstanceOf<C>, E, N>, what TemporalActivities(contract)({ inject: { name: Dep }, unit?, sync }) returns for this contract; one built for another contract fails at the call |
workflows | when the contract declares a workflow | — | a WorkflowSource; omit it only for a contract that declares no workflows (an activity-only worker) |
address | no | read from TEMPORAL_ADDRESS | pins TemporalConfig.address |
namespace | no | read from TEMPORAL_NAMESPACE | pins TemporalConfig.namespace |
gracePeriod | no | read from TEMPORAL_GRACE_PERIOD_MS | pins Temporal's shutdownGraceTime, a Duration (default 10_000 ms) |
forceAfter | no | read from TEMPORAL_FORCE_AFTER_MS | pins Temporal's shutdownForceTime, a Duration (default 15_000 ms); keep it at or below the kernel's drainTimeoutMs |
unit | no | unset — dispatch runs unchanged | { activity?: Unit }, the unit module forked around every activity attempt and seeded with the validated input on ActivityInput(contract) — built after the activity is invoked, before it runs, torn down when the unit closes; a bound module's own unmet needs join this root's (less the seeded port), refused the same way an unmet activities port is. Gated against what the pieces declared — see The unit |
apiKey | no | unset | the application port for TemporalApiKeySource; see Renewable bearer credentials |
dataConverter | no | unset — Temporal's default converter | the application's port for a @temporalio/common DataConverter, handed to Worker.create — see The data converter; the port joins this root's needs |
imports | no | [] | the application's modules |
provides | no | [] | the application's own providers |
exports | no | [] | the application's own exports; TemporalRuntime is added |
An activity-only worker
workflows is required exactly when the contract declares a workflow: a worker over one with no source would poll the queue and run none of them, so the types refuse it. A contract that declares no workflows (a PDF-rendering pool on its own task queue, say) omits it, and the worker is created with no workflow bundle at all. Workflows of other contracts call its activities through context.executeActivity(contract, name, { args }).
const pdfContract = defineContract({
taskQueue: "pdf",
workflows: {},
activities: { renderInvoice },
});
export const PdfWorker = TemporalModule("PdfWorker")({
contract: pdfContract,
activities: PdfActivities,
});The worked composition root, from examples/order-temporal-worker/src/module.ts:
export const OrderTemporalWorker = TemporalModule("OrderTemporalWorker")({
contract: orderContract,
activities: orderActivities,
workflows: {
workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"),
},
imports: [
OrderPersistenceModule,
FulfillmentModule,
BillingModule,
observability(),
otel(),
],
unit: { activity: ActivityUnitModule },
});RED metrics, reported always and collected when you ask
The runtime REPORTS rate, errors and duration at the unit seam — the one place a framework that owns the unit lifecycle gets them for free — and an observer is what turns a report into a measurement. Reporting always happens; collection happens when otel() is composed, and not before:
| Instrument | Kind | Dimensions |
|---|---|---|
btravstack.temporal.operations | counter | activity, outcome |
btravstack.temporal.duration | histogram (s) | the same two |
instrumented is gone. Every unit is handed to Observers, and this module contributes a no-op member of its own — so a graph composing no observability owes nothing, and an operation costs one inert call per module that reads the port. Composing observability() writes the failures as lines; composing otel() beside it opens the spans and mints btravstack.<component>.operations and .duration.
The dimensions are chosen for cardinality, and what is absent matters more than what is present. Per attempt, not per activity: a retried activity records once per attempt, which is what makes the rate readable when a downstream is failing — the workflow's own count would hide exactly the retries worth alerting on. The workflow id is not a dimension and must not be: it is unbounded, and it is already the unit's traceId, which is where an unbounded value belongs.
TemporalActivities(contract)
The first call fixes C (the contract value is otherwise unused; it exists so C is inferred rather than written) and returns a builder on the starter's activities port, typed for C — so the second call is { inject?, unit?, sync }, whose sync hands back the whole activities record, and the provider it returns carries the port typed as provider.port. It is { inject?, unit?, sync } rather than di's whole arm set for parity: api.OrpcRouter(contract) and all three packages' piece factories spell it that way, so one arm across the family is one surface to learn and one to keep. unit declares the ports every entry of the record reads off context.unit, resolved out of the per-attempt fork exactly as a piece's are and gated by TemporalModule the same way — so a worker that has not outgrown one function needs no slicing to reach it. There is no name to give: a worker serves one activities record as it polls one task queue, so the port is the starter's — one Port("TemporalActivities"), generic at the value level and fixed per contract at the type level (ActivitiesPortOf<C>, the move the kernel's RuntimePort makes) — and two activities providers in one graph are di's duplicate-provider defect at build. Each activity is a plain function typed by the contract (({ errors, input }) => AsyncResult<…>), closing over the services the provider declared; the record it receives carries the invocation's own values — the validated input and the contract's errors — and no service is resolved at call time.
Expanded, the monolithic form looks like this — not a call site inside examples/order-temporal-worker any more, since orderContract now declares three workflows and its own worker composes this record from three pieces instead (see the composing form below and Split a worker into slices), but the form itself is unchanged and still what Run a Temporal worker teaches for a worker with one saga. A single record covers every workflow the contract declares, so this one carries chargeOrder and the scheduled sweepStaleOrders too, not fulfillOrder alone:
import { Context } from "@temporalio/activity";
export const orderActivities = TemporalActivities(orderContract)({
inject: {
stock: StockService,
shipping: ShippingService,
payments: PaymentService,
},
unit: { place: PlaceOrder, repository: OrderRepository },
sync: ({ stock, shipping, payments }) => ({
fulfillOrder: {
place: ({ errors, context, input }) =>
context.unit.place
.execute(input.orderId, input.quantity, { operation: input.operationId ?? Context.current().info.workflowExecution!.runId })
.map((order) => ({ id: order.id, quantity: order.quantity }))
.mapErrCases((matcher) =>
matcher
.with(P.tag("InvalidQuantity"), (error) =>
errors.InvalidQuantity({ id: error.id }),
)
.with(P.tag("InvalidOrderId"), (error) =>
errors.InvalidOrderId({ id: error.id }),
)
.with(P.tag("DuplicateOrder"), (error) =>
errors.OrderAlreadyPlaced({ id: error.id }),
),
),
reserveStock: ({ errors, input }) =>
stock
.reserve(input.orderId, input.quantity)
.mapErrCases((matcher) =>
matcher.with(P.tag("OutOfStock"), (error) =>
errors.OutOfStock({ id: error.id }),
),
),
arrangeShipping: ({ errors, input }) =>
shipping
.arrange(input.orderId)
.mapErrCases((matcher) =>
matcher.with(P.tag("ShippingUnavailable"), (error) =>
errors.ShippingUnavailable({ id: error.id }),
),
),
releaseStock: ({ input }) => stock.release(input.orderId),
cancelPlacement: ({ context, input }) =>
context.unit.repository
.remove(input.orderId, { operation: input.operationId ?? Context.current().info.workflowExecution!.runId })
.recoverErrCases((matcher) =>
matcher.with(P.tag("OrderNotFound"), () => undefined),
),
},
chargeOrder: {
authorizePayment: ({ errors, idempotencyKey, input }) =>
payments
.authorize(input.orderId, input.amount, idempotencyKey)
.map((authorizationId) => ({ authorizationId }))
.mapErrCases((matcher) =>
matcher.with(P.tag("PaymentDeclined"), (error) =>
errors.PaymentDeclined({ id: error.id }),
),
),
capturePayment: ({ idempotencyKey, input }) =>
payments.capture(input.authorizationId, idempotencyKey),
refundPayment: ({ idempotencyKey, input }) =>
payments.refund(input.authorizationId, idempotencyKey),
},
sweepStaleOrders: {
withdrawStaleOrders: ({ context, input }) =>
withdrawStale(
context.unit.repository,
new Date(input.placedBefore),
currentUnit()?.signal,
),
},
}),
});One record, one sync, every workflow's services in its inject — which is exactly the shape that stops scaling once a worker owns enough workflows, and why the composing form below exists.
input.tenantId is the application's, not the package's: it is a field the contract declares on every workflow and activity input, which is what makes it survive a replay — Temporal persists an activity's input in the event history. @btravstack/temporal-worker reads nothing about tenancy. TenantId(…) is examples/order-domain's brand claimed at the boundary the activity is, so a use case cannot be handed an order id where a tenant goes; the contract validated the field as a UUIDv7 before the activity was entered, so the constructor casts rather than parses.
A hand-written Provider(orderActivities.port)(…) targets the same port; a port declared under any other id leaves the starter's need unmet, and start refuses the module.
A third call composes several pieces instead of one record: TemporalActivities(contract)([piece, piece, ...]), where each piece is what TemporalWorkflowActivities(contract, key)({ inject: { name: Dep }, unit?, sync }) returns. Di constructs every piece first — they are the composed provider's own deps, declared under the very key each piece's port id carries, so the services record IS the activities record. Every top-level key the contract's activities record declares must be covered: an array missing one is refused at the call, against an "UNCOVERED ACTIVITIES — the contract declares a workflow this array does not cover" marker. The diagnostic is a three-line TS2769 and the sentence is at the tail of the third line, past three hundred characters of the caller's own contract type — measured, and not shortenable from inside this package. The missing key is named beside it, whatever the array's length: the refusal is a tuple as long as the array you wrote, so TypeScript lines the two up element by element and reports one error on the trailing element — measured on a one-element array, is not assignable to type 'readonly ["UNCOVERED ACTIVITIES — …", "audit" | "runShout"]'. A piece built for another contract is refused too, structurally, since its port's service is that contract's activities for the key. Uncovered checks coverage, not injectivity, so two pieces claiming the same key still type-check together; di's duplicate-provider defect at build catches it only once both end up discharged as providers in the same graph — wire in just one and the other is silently unregistered, with no diagnostic. The composed provider's own deps are the pieces' ports, not what a piece closes over, so the pieces themselves still need discharging like any other need — typically provides: [...] on the module, or a slice module that exports its own piece.
TemporalWorkflowActivities(contract, key)
One workflow's activities, or a contract-global activity, as a provider of its own: the port id carries the contract queue and the top-level key (`TemporalWorkflowActivities:${contract.taskQueue}:${key}`). Different contracts can reuse a key without sharing a DI port. The composing form strips the queue prefix to recover the original wire key; two slices claiming one key in the same worker remain di's duplicate-provider defect rather than a silent merge. contract types key (any top-level key of ActivitiesOf<C> — a workflow that declares activities, or a contract-global activity, so the name is imprecise in the latter case, deliberately: narrowing the type to workflow keys only would cost extra type code and lock a contract with global activities out of the split) and the piece; a key the contract does not declare is refused at the call — there is nothing to type it by — and an activity whose input has drifted is a compile error here rather than at startup. There is no name to give and nothing minted by hand: the provider carries its port as provider.port (WorkflowActivitiesPortOf<C, K>).
The options are { inject?, unit?, fork?, sync } — this package's own record, not di's whole arm set. fork names a unit module of the piece's own — see A piece's own module. unit names the ports the activities read off context.unit, and sync's return is typed by that record while the port it lands on keeps the context-free shape declareActivitiesHandler takes. The narrowing is for consistency with @btravstack/http-server's OrpcController and @btravstack/amqp-worker's AmqpHandler — one arm across the three transports. A piece with no services is { inject: {}, sync: () => activities }; the record is put on the leaves by a wrapper on the piece rather than by the middleware, so it reaches inside both entry shapes — a workflow key carrying a record of implementations, and a contract-global key carrying the implementation itself.
const orderFulfillment = TemporalWorkflowActivities(
orderContract,
"fulfillOrder",
)({
inject: { stock: StockService, shipping: ShippingService },
unit: { place: PlaceOrder, repository: OrderRepository },
sync: ({ stock, shipping }) => ({
place: ({ errors, context, input }) =>
context.unit.place
.execute(input.orderId, input.quantity, { operation: input.operationId ?? Context.current().info.workflowExecution!.runId })
.map((order) => ({ id: order.id, quantity: order.quantity }))
.mapErrCases((matcher) =>
matcher
.with(P.tag("InvalidQuantity"), (error) =>
errors.InvalidQuantity({ id: error.id }),
)
.with(P.tag("InvalidOrderId"), (error) =>
errors.InvalidOrderId({ id: error.id }),
)
.with(P.tag("DuplicateOrder"), (error) =>
errors.OrderAlreadyPlaced({ id: error.id }),
),
),
// … the rest of the record, one arm per activity `fulfillOrder` declares
}),
});
const orderBilling = TemporalWorkflowActivities(
orderContract,
"chargeOrder",
)({
inject: { payments: PaymentService },
sync: ({ payments }) => ({
authorizePayment: ({ errors, idempotencyKey, input }) =>
payments
.authorize(input.orderId, input.amount, idempotencyKey)
.map((authorizationId) => ({ authorizationId }))
.mapErrCases((matcher) =>
matcher.with(P.tag("PaymentDeclined"), (error) =>
errors.PaymentDeclined({ id: error.id }),
),
),
// … the rest of the record, one arm per activity `chargeOrder` declares
}),
});
// orderContract declares three workflows, so the composing call must cover
// all three — one piece short and it is refused at the call.
const orderActivities = TemporalActivities(orderContract)([
orderFulfillment,
orderBilling,
sweepStaleOrders,
]);TemporalConfig, and the environment
Bound through Config.provider; every option pins its field — explicit > environment > default, per field.
| Variable | Default | Parsed by | Notes |
|---|---|---|---|
TEMPORAL_ADDRESS | 127.0.0.1:7233 | Config.string | where the service is |
TEMPORAL_NAMESPACE | default | Config.string | a blank value is a ConfigInvalid, not a default |
TEMPORAL_GRACE_PERIOD_MS | 10000 | Config.integer | Temporal's shutdownGraceTime |
TEMPORAL_FORCE_AFTER_MS | 15000 | Config.integer | Temporal's shutdownForceTime; keep it at or below DRAIN_TIMEOUT_MS |
The connection's security is bound beside them, under Temporal's own environment names plus two of this starter's — see Securing the connection.
TemporalConfig holds the two shutdown budgets as milliseconds: an environment carries strings, and a gracePeriod: "10 seconds" pin is turned into the same number by Temporal's own msToNumber, so both routes reach the worker identically.
A malformed value is a ConfigInvalid — startFailed and exit 78 under runMain.
Securing the connection
Every variable is optional; with none set the connection is plaintext with no key, as before. They are fields of TemporalConfig, never options: what secures a connection is the deployment's.
| Variable | Default | What it is |
|---|---|---|
TEMPORAL_TLS | unset | TLS on (true) or off (false); unset, it is on exactly when an API key is set, as Temporal's own clients do. The CA, server name and client certificate require it on. On alone, the server is verified against the system's roots |
TEMPORAL_TLS_SERVER_CA_CERT_PATH | — | a PEM file the server's certificate is verified against instead |
TEMPORAL_TLS_SERVER_NAME | — | the name the server's certificate is verified for, and sent as SNI |
TEMPORAL_TLS_CLIENT_CERT_PATH | — | mTLS: the client certificate, PEM; requires TEMPORAL_TLS_CLIENT_KEY_PATH |
TEMPORAL_TLS_CLIENT_KEY_PATH | — | mTLS: its private key, PEM; requires TEMPORAL_TLS_CLIENT_CERT_PATH |
TEMPORAL_API_KEY | — | an API key, sent as Authorization: Bearer |
TEMPORAL_API_KEY_PATH | — | a file holding the API key (trimmed), instead of TEMPORAL_API_KEY |
TEMPORAL_CREDENTIALS_RELOAD_MS | 60000 | how often the files above are read again, from 1 to 2147483647 (setTimeout's ceiling) |
A combination it cannot connect with is refused at boot, as one ConfigInvalid against TemporalConfig naming every variable to fix: a security variable with TLS off — TEMPORAL_TLS=false, or unset without an API key (requires TEMPORAL_TLS=true; an API key is never sent in plaintext), a certificate without its key or a key without its certificate (is required with …), TEMPORAL_API_KEY beside TEMPORAL_API_KEY_PATH, and an API key beside a client certificate (cannot be combined with a client certificate: choose one credential). A file that cannot be read (cannot be read (ENOENT)) or is empty (is empty) is refused the same way — exit 78. A server that refuses the credentials is TemporalUnreachable, exit 1.
Rotation. When any *_PATH variable is set, the worker reads the files again every TEMPORAL_CREDENTIALS_RELOAD_MS and compares them with what it connected with:
- A changed certificate, key or CA opens a new
NativeConnectionwith them and moves the worker onto it through Temporal's ownworker.connectionsetter, then closes the connection it replaced. The worker keeps polling throughout; nothing restarts. - A changed API key alone is set on the open connection with
setApiKey; the next call carries it. - A reading that fails — a file missing or empty mid-rotation, or a new certificate the server refuses — keeps the current connection and is tried again on the next tick.
Each applied change, refused change and unreadable file is the observed operation temporal / reload, ok or error, to the observers the application binds; a tick that finds nothing changed is not observed. However the worker's run ends, the reloading stops — after a reload in flight settles — and every connection a reload opened is closed.
Limits, all the SDK's: a TLS change is a new connection — the Rust core fixes TLS when it connects — so an in-flight call finishes on the old one. TemporalConnection stays the connection the graph opened, with the boot certificate: a provider that injects it to call the server keeps that certificate until the process restarts. An API key given inline in TEMPORAL_API_KEY never changes; mount it as a file to rotate it. A failed reload is retried every tick, so a certificate the server keeps refusing is reported, and reconnected to, once per interval until it is fixed.
Renewable bearer credentials
Compose an application port implementing TemporalApiKeySource through TemporalModule("Worker")({ ...options, apiKey: credentialsPort }). Both the source type and TemporalApiKeyUnavailable are exported from this package's root. Its read(): AsyncResult<string, TemporalApiKeyUnavailable> obtains the current credential; the application owns acquisition, expiry and any cache. This works with an OAuth token source without making Temporal depend on OAuth.
A source turns TLS on when unset. Explicit TLS off, an inline/file API key, or mTLS alongside it is a ConfigInvalid before any source read. The server CA and server name settings remain available. An unavailable or blank initial credential prevents startup with TemporalApiKeyUnavailable; its diagnostic contains no credential value.
The starter reads the source again every credentialsReloadMs (TEMPORAL_CREDENTIALS_RELOAD_MS, default 60000). Choose an interval shorter than the source's early-refresh margin so a valid replacement is installed before expiry. The starter keeps no token-expiry cache. A changed credential is installed with setApiKey on the current connection.
If a subsequent read or apply fails, the worker reports temporal / reload with outcome error, withdraws readiness immediately, and shuts down. The kernel marks unexpectedRuntimeStop; runMain returns nonzero, allowing the orchestrator to restart once credentials recover. It never continues using the previous key after source removal. Existing file-only rotation retains its retry behavior described above.
TemporalConnection and TemporalUnreachable
The NativeConnection is a resourceful provider from [TemporalConfig]: acquired with the scope (NativeConnection.connect({ address })) and released on every exit path, startup failure included. A service that will not answer is the modeled TemporalUnreachable { address, cause } — exit 1 under runMain, a startup Err an operator can act on, not the 70 a defect earns.
If close() refuses with IllegalStateError because the worker still holds the connection after a drain deadline, the finalizer waits for worker.run() to settle, whether successful or failed, then closes again. The existing stopTimeoutMs bounds the kernel's wait for that finalizer. If it expires, the report names abandonedAt: "stop" while cleanup continues in the background. An unexpected close failure still reaches ExitReport.teardownErrors; this live array can grow after exited settles, although no further lifecycle event is emitted after exited.
The data converter
Temporal keeps every workflow and activity input and result in an event history nobody can edit. A payload codec on the data converter — one encrypting each payload with a key per tenant, say — is how that history holds only ciphertext, and erasing a key makes what it encrypted unreadable.
dataConverter takes a port the application declares, class OrderConverter extends Port("OrderConverter")<DataConverter> {}, not the converter itself: a codec usually needs a service to construct — a key store — so its provider injects one like any other. temporalClient's fourth argument takes the same port, so the worker and every client that starts or queries its workflows resolve one provider and cannot drift apart; a side without it reads the other's payloads as ciphertext.
class OrderConverter extends Port("OrderConverter")<DataConverter> {}
const orderConverter = Provider(OrderConverter)({
inject: { keys: KeyStore },
sync: ({ keys }) => ({ payloadCodecs: encryptingCodecs(keys) }),
});
export const OrderWorker = TemporalModule("OrderWorker")({
contract: orderContract,
activities: orderActivities,
workflows: { workflowsPath: "./workflows.js" },
dataConverter: OrderConverter,
imports: [KeyStoreModule],
provides: [orderConverter],
});
// Beside it, in every process that starts or queries those workflows:
export const orderClient = temporalClient(
OrderClient,
OrderConnection,
OrderSettings,
OrderConverter,
);payloadConverterPath and failureConverterPath run inside the workflow sandbox: with { workflowsPath }, Temporal bundles both from the converter; with a prebuilt { workflowBundle }, the bundle must have been built with both, or the workflows serialise with Temporal's defaults.
TemporalRuntime and TemporalInfo
Declared over the kernel's RuntimePort with service Runtime<never, TemporalInfo> — it resolves nothing. Its start calls declareActivitiesHandler inside the qualified chain (a contract it cannot satisfy — an undeclared implementation, a declared one missing — throws there, and that throw becomes Err(RuntimeStartFailed({ runtime: "temporal", cause })), exit 1, not a defect), then Worker.create with the connection, namespace, taskQueue, the workflow source, the wrapped activities, shutdownGraceTime and shutdownForceTime. Once run() has started polling it publishes TemporalInfo, { taskQueue, namespace }, on Serving.info.
Sequencing a saga: context.saga(), never sibling consts
An AsyncResult is eager — constructing it starts the work. So the readable spelling of a sequence, each step in its own const and then chained, is a race: it type-checks, it returns a Result, and it runs the steps concurrently. Nothing catches it.
Where the steps carry compensations, context.saga() is the answer. Every argument is a thunk, so nothing is built before the saga reaches it, and the undos are its own business rather than each step's:
context
.saga()
.step(
() => context.activities.place({ ...order, operationId: context.info.runId }),
() => context.activities.cancelPlacement({ ...order, operationId: context.info.runId }),
)
.step(
() => context.activities.reserveStock(order),
() => context.activities.releaseStock(order),
)
.step(() => context.activities.arrangeShipping(order))
.run()
.mapErrCases(/* one triage, at the end */);context.saga() runs the undos LIFO and decides which failures earn one: a declared contract error compensates, an activity that failed unmodelled or was cancelled does not — a step that died mid-flight left state nobody can see. So the machinery-tag arm every step used to repeat is gone, and the re-mint against context.errors happens once.
A sequence with no compensations does not need a saga: flatTap runs a failable step, discards its value and passes the original one through, so the next step is a callback that cannot start before the previous settles. Where a later step needs an earlier step's value rather than just its success, DoAsync().bind("name", (scope) => …) is the same idea with an accumulating scope. See Order Temporal worker for all three at full size.
The unit
One unit per activity attempt, kind: "activity", opened by the starter's own ActivityMiddleware. With no unit bound it calls next() unchanged, and context.unit is {} on every piece. With one bound, the middleware forks it — after the activity is invoked, before it runs — and tears the fork down when the unit closes. Either way, the ambient currentUnit() record is what an adapter reads the trace id from, and the only route to the unit's AbortSignal from inside an activity: currentUnit()?.signal, aborted at the kernel's drainTimeoutMs. Temporal's Context.current().cancellationSignal is a different clock — a workflow-side cancellation, and worker shutdown after shutdownGraceTime — so the two are honoured together.
UnitMeta field | Value |
|---|---|
id | activityInfo().base64TaskToken — identifies one activity task attempt; uniqueness is Temporal's guarantee |
traceId | the workflow id, falling back to the activity id for an activity with no workflow |
A workflow id would be wrong as the id twice over: an activity is retried under the same execution, and Temporal lets a workflow id be reused once an execution closes. It is the correlation id — stable across every retry — which is what traceId is for.
That reuse is visible in the traces, and it is the accepted cost: two runs under one id are one traceId. runId would split them and is the wrong correlation here — server-minted, opaque, not what an operator greps for — so a deployment that needs per-run traces gives its workflows unique ids, which a Temporal Schedule already does.
ActivityInput(contract) — the one seeded port
The fork is seeded with the validated input, on ActivityInput(contract). That is the only entry the worker seeds, and it is what lets a unit module derive a tenant — or anything else it scopes by — from the invocation rather than from an ambient record:
const Input = ActivityInput(orderContract);
export const ActivityUnit = Module("ActivityUnit")({
needs: [Input],
provides: [
Provider(Tenant)({
inject: { input: Input },
sync: ({ input }) => input.tenantId,
}),
],
exports: [Tenant],
});One Port("ActivityInput") call, cast per contract at the type level, so no contract instantiating it warns about a duplicate id while a module built for one contract still cannot read another's input. A module naming that port in needs owes the composition root nothing for it — the seed discharges it, and it is subtracted from what unit contributes to the starter's Needs; everything else the module needs still surfaces at start.
context.unit, and the gate on the module bound
A piece declares the unit-scoped ports its activities may read as unit: beside inject, and an activity reads them off context.unit.name. The whole-record arm takes the same unit:, applied to every entry of the record it hands back, so a worker that has not outgrown one function reaches context.unit without slicing first. Entries are lazy getters over the forked context — neither writable nor configurable, so an activity reads what the fork holds and cannot reshape the record under the next attempt.
That declaration is a promise the root has to keep, and nothing else checks it: the piece and the root are typed independently. So TemporalModulegates unit.activity against what was declared — the union of every piece's record, collected by TemporalActivities(contract)([...]) where the pieces are known, or the record arm's own unit: where the worker is one function — and a bound module that does not export a declared port is refused against a "UNIT DOES NOT PROVIDE — a piece injects a port the bound unit module does not export" marker, carrying the offending port. The gate rides the whole options record, not the unit property, because a gate on a property is not read when the property is absent — and a root that declares a piece's unit: and then binds no module at all is exactly the case worth catching.
A piece's own module: fork
TemporalWorkflowActivities(contract, key)({ inject?, unit?, fork?, sync }) takes fork: the unit module this piece's attempts fork, in place of unit.activity, seeded the same way. The middleware picks the module per attempt by the piece's contract key — the workflow, or the contract-global activity — so a worker whose activities differ in scope forks a tenant's module for the per-tenant pieces and the worker-wide one, or nothing, for the rest. Only the piece form takes it; the whole-record arm is one scope by construction, which is unit.activity.
- Gated at the piece. The piece's
unit:is checked against its own module at its own call, with the sameUNIT DOES NOT PROVIDEmarker, and contributes nothing to the root's gate onunit.activity. - Needs travel with the piece. The module's unmet needs, less the seed, join the piece provider's own, so the root that provides the piece owes them and
startnames any it leaves out. A module readingEnvis refused, asunit.activityis. - Listed on
Runtime.unitsunder the module's name, besideactivity, so the kernel checks it at boot for a port the application already provides, andoverridden(root, providers, { unit: { [module.name]: [...] } })substitutes inside it. A module several pieces share is one entry; two different modules sharing a name are refused as the runtime is built.
The drain, and the deadline
Serving.drain(signal) calls worker.shutdown() — polling stops at once, in-flight activities run to completion — then waits on worker.run()raced against the kernel's deadline signal, and keeps the signal so stop() is released by the same abort. run() settles on Temporal's own shutdownForceTime, not the kernel's drainTimeoutMs; without the race, an activity that never finishes would hold Serving.stop past the kernel's deadline.
When the deadline wins, the runtime returns and the worker is still alive. @temporalio/worker exposes no public forced shutdown (Worker.forceShutdown$ is protected, Runtime.shutdown() is process-global), so stopping the wait is the only escalation: the kernel gets its thread back on time, reports the activity abandoned, and the worker keeps winding down on Temporal's clock. Its held run() Result also keeps connection release ordered after worker completion. The kernel waits for that release within stopTimeoutMs, then reports abandonedAt: "stop" if cleanup still has not finished; the finalizer continues and closes the connection when the worker releases it.
The process does not end on its own after that, and the page used to leave that implicit. runMain sets an exit code and never calls process.exit(); the native Runtime shuts down only once every worker and connection is deregistered, and a worker still winding down holds the event loop. Nothing here terminates the process.
Under Kubernetes — which is where a drain normally comes from — that means the pod ends at terminationGracePeriodSeconds, with SIGKILL, and exit 2 is what the report says rather than what the orchestrator observes. Outside that lifecycle there is no SIGKILL at all: a drain that hits its deadline in a test, a dev loop or an embedder leaves the process alive until the worker finishes on Temporal's own clock and the finalizer closes its connection. A worker that never settles can still keep the process alive. The report reaches stderr first, which is what kubectl logs --previous is for.
stop() with no prior drain (the RunningApp.stop() path) has no deadline to race and waits on run() alone, which is where forceAfter decides when the process exits — the kernel's own stopTimeoutMs bounds how long it waits for that, and the worker keeps winding down after it.
Keep forceAfter at or below drainTimeoutMs (default 20_000). The starter does not check it, and the reason is not that the numbers are out of reach — this module reads Env at build, so DRAIN_TIMEOUT_MS is right there. It is that the variable is not the effective value: start({ drainTimeoutMs }) pins the kernel's own, and a starter cannot see a pin in another package's options. A check over the variable alone would refuse a boot whose real deadline was fine, which is worse than the advice.
Peer dependencies
@btravstack/core, @btravstack/config, @btravstack/di, unthrown, @temporalio/worker, @temporalio/activity, @temporalio/common, @temporal-contract/worker, @temporal-contract/contract, and @temporal-contract/client as an optional peer that only the @btravstack/temporal-worker/schedule subpath reads — a consumer that never imports ensureSchedule installs nothing for it (see Run something on a schedule). Node >=22.22; the package exports ESM only because its required contract peer has import-only subpaths.
Testing a worker
The test server is a package of its own, @btravstack/temporal-testing, so this one ships no test code and peers on neither vitest nor testcontainers. Test a Temporal worker is the recipe.
Deliberately not included
Result→ activity failure.declareActivitiesHandleralready owns it: a declared contract error becomes anonRetryableApplicationFailurethe workflow branches on, and anything unmodeled is retried by the platform's own policy. The starter does not do it a second time.- A forced shutdown. There is no public one to call; the escalation is the released wait above.
- A workflow client. The starter runs a Worker; starting executions is
@temporal-contract/client's job.
Testing
The package's own suite needs a Docker daemon: temporal-runtime.spec.ts and workflow-activities.spec.ts boot a real @temporalio/worker Worker against a real Temporal server — one temporalio/temporal development-server container shared by the whole repository, with a namespace per spec file minted by @btravstack/temporal-testing's temporalEnvironment fixture (see Order Temporal worker for the same choice and its measured cost). It replaced a time-skipping test server started per vitest worker; neither suite ever advanced a clock, so the skippable clock bought nothing a private namespace does not. temporal-runtime.spec.ts covers the published info, the starter's configuration, the connection, the qualified startup chain, the unit boundary and its seeded fork, and the drain; workflow-activities.spec.ts composes a two-workflow, one-task-queue contract from two pieces, pinning that both are mounted and that each was built from the ports its own provider declared. workflow-activities.test-d.ts pins the composing form's compile-time gates: a piece typed by its own key, an array covering every declared key composing into what TemporalModule takes, a key the contract does not declare refused at the piece's own call, an array missing a key refused at the composing call, and a piece built for another contract refused there too. It and @btravstack/amqp-worker's handler.test-d.ts are deliberate mirrors, so a gate added to one belongs in the other — they drifted apart once (issue #51). Checked by tsc -p tsconfig.test-d.json, which the package's own typecheck script runs alongside the ordinary tsc --noEmit.
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.
Runtime task queue
taskQueue pins TEMPORAL_TASK_QUEUE; when both are absent the contract's queue is the default. Configuration validates the destination using the contract library's defineContractBinding, including pinned values. TemporalConfig.taskQueue and runtime info report the physical queue. Activity providers, activity names, workflow types, schemas and execution IDs keep their logical identity.
For example, compose TemporalModule with the original orderContract and its existing activity provider, then set TEMPORAL_TASK_QUEUE=orders.release-a in the deployment. A second deployment may poll orders.release-b using the same wire names. Clients use defineContractBinding(orderContract, { taskQueue }) from @temporal-contract/contract (8.0.0-beta.15 or later) and pass that bound contract to their typed client before starting work; a queue change does not update existing histories or make a bundle replay-safe. Keep workers needed by in-flight histories. Resolve child/activity targets from recorded decisions, never environment reads in workflow code.
const deploymentQueue: Pick<TemporalOptions<typeof orderContract>, "taskQueue"> = {
taskQueue: "orders.release-a",
};