Providers and transports
Exact public API for Signal providers, webhook, polling, managed stream, SSE, and WebSocket transports, managed bindings, accept/normalize lifecycle, statistics, and projections.
Import authoring helpers from focused subpaths and lifecycle helpers from the Runtime surface:
import {
classifySseHttpStatus,
classifyWebSocketCloseCode,
createBoundedPushBuffer,
polling,
sse,
sseHttpStatusErrorCode,
stream,
webhook,
websocket,
webSocketCloseErrorCode,
type PollingTransport,
type SseItem,
type SseTransport,
type StreamItem,
type StreamTransport,
type WebhookTransport,
type WebSocketItem,
type WebSocketTransport,
} from "@use-crux/core/signal/transport";
import {
managedTransportBinding,
signalProvider,
type SignalProvider,
} from "@use-crux/core/signal/provider";
import {
acceptTransportEnvelope,
createTransportNormalizationRunner,
ManagedStreamTerminalError,
projectTransportBindingHealth,
projectTransportEnvelope,
replayTransportEnvelope,
transportBindingHealth,
transportStatistics,
type RuntimeAcceptedTransportEnvelope,
type RuntimeManagedTransportBinding,
type RuntimeTransportBindingCheckpoint,
type RuntimeTransportBindingHealth,
type RuntimeTransportBindingHealthSnapshot,
type RuntimeTransportEnvelopeProjection,
type RuntimeTransportEnvelopeRecord,
type RuntimeTransportEnvelopeState,
} from "@use-crux/core/runtime";Provider and binding helpers also re-export from the root package where documented.
Authoring
webhook({ handle })
Returns a frozen WebhookTransport (_tag: "WebhookTransport",
kind: "webhook").
handle(request) must authenticate and validate before returning:
| Field | Meaning |
|---|---|
accountId | Provider account identity for idempotent acceptance |
eventId | Provider event identity for idempotent acceptance |
authenticatedRouting | Detached JSON metadata without secrets |
payload | Opaque inline-base64url or durable payload reference |
polling({ poll, intervalMs? })
Returns a frozen PollingTransport (_tag: "PollingTransport",
kind: "polling").
poll({ cursor, signal, configRef }) acquires one bounded page:
| Field | Meaning |
|---|---|
events[] | Authenticated events with the same fields as webhook handle results |
nextCursor | Opaque resume position after this batch, or null |
more? | When true, more pages are available; the next worker tick may skip intervalMs once after durable acceptance |
Optional intervalMs is a positive minimum between poll starts for one binding
when more is not pending. When omitted, the Runtime worker maintenance
cadence is the poll cadence.
The single Runtime worker leases the binding, calls poll, accepts each event
through acceptTransportEnvelope(), and persists nextCursor only after the
full batch is accepted, treated as a same-digest duplicate, or progressable
after TransportEnvelopeConflictError when durable envelope-store evidence for
that identity is confirmed. Other accept failures keep the previous cursor.
Partial failure or abort also leaves the previous cursor. Restart resumes from
the durable checkpoint without inventing a second worker. signal aborts on
worker stop or lease release.
stream({ open })
Returns a frozen StreamTransport (_tag: "StreamTransport",
kind: "stream"). This is the managed ingress transport constructor on
@use-crux/core/signal/transport. It is distinct from LLM generation
stream() helpers on provider packages and @use-crux/ai.
open({ cursor, signal, configRef }) opens one connection and returns an
AsyncIterable<StreamItem> (or a Promise of one). Runtime owns lease, fiber,
reconnect, accept, and checkpoint. Credentials and live clients stay inside the
open closure.
StreamItem
Exactly one protocol item per yield — never a batch:
| Variant | Fields | Checkpoint timing |
|---|---|---|
| Envelope | kind: "envelope", accountId, eventId, authenticatedRouting, payload, optional cursor? | Optional cursor is progress through this item inclusive. Runtime may write it only after durable accept (or same-digest duplicate / progressable conflict with evidence). Omitted means no new checkpoint from this item. null clears the resume position when the provider truly has none. |
| Cursor-only | kind: "cursor", cursor: string | null | May checkpoint immediately. Use for heartbeats, SSE comment/id advances, or caught-up markers that do not carry events. Must never cover unyielded input. |
Cursor law
A yielded cursor asserts that the adapter has finished all provider input through that opaque position. Runtime never advances an envelope cursor before accept resolves for that item. Crash between accept and checkpoint redelivers the same provider event id; the #337 kernel dedupes.
Lifecycle
| Event | Behavior |
|---|---|
| Clean EOF | Disconnect, not terminal success. Reconnect with bounded backoff from the durable cursor (defaults: base 1s, max 60s). |
| Transient throw | Reconnect by default with bounded backoff. Safe lastErrorCode may be stored without advancing the cursor. |
| Terminal fault | Throw ManagedStreamTerminalError(code, message?) or a duck-typed { terminal: true, code: string }. Durable checkpoint status: "faulted"; automatic reconnect stops until config identity changes or an operator clears status. Unsafe codes map to TRANSPORT_STREAM_TERMINAL. |
| Abort | signal aborts on worker stop, lease expiry/loss, or rebalance. Runtime calls iterator.return?.() and rejects stale accept/checkpoint after the lease fence. |
| Config change | Checkpoint stores secret-free configRef. When live binding configRef differs, Runtime over-invalidates the stored cursor (does not inherit faulted status from the prior config). |
| Backpressure | Pull iteration is serial: one item at a time on the connection fiber. Push adapters must bound buffers and fail on overflow — never silent drop. |
| Post-accept ack | Optional process-local acknowledge on envelope items. Runtime invokes only after durable accept (and cursor checkpoint when present). Failure is observable/transient and never rolls back accept or cursor. |
sse({ open })
Returns a frozen SseTransport (_tag: "SseTransport", kind: "sse"). This is
the managed provider-ingress SSE transport on
@use-crux/core/signal/transport.
It is not:
- LLM generation
stream()helpers on provider packages /@use-crux/ai @use-crux/reactbrowser SSE (createSSETransport/cruxSSEHandler), which is egress from Crux RecordStore/state to the browser
Runtime lowers SSE items onto the managed stream fiber. There is no second
reconnect loop, worker, checkpoint schema, or Runtime-owned fetch /
EventSource / frame parser. Credentials and HTTP clients stay inside the open
closure; inert bindings and program JSON remain secret-free.
open({ cursor, signal, configRef }) opens one SSE connection and returns an
AsyncIterable<SseItem> (or a Promise of one). The open context field is still
named cursor — for SSE adapters it is the durable Last-Event-ID resume
value (or null). Map non-null cursor to the HTTP Last-Event-ID request
header when connecting.
SseItem
Exactly one protocol item per yield — never a batch:
| Variant | Fields | After lowering |
|---|---|---|
| Envelope | kind: "envelope", accountId, eventId, authenticatedRouting, payload, optional lastEventId? | Stream envelope with cursor set from lastEventId when present; omitted when lastEventId was omitted |
| Cursor-only | kind: "cursor", lastEventId: string | null | Stream cursor-only item with cursor: lastEventId |
lastEventId is progress through this event inclusive (wire id: /
Last-Event-ID after the event). Omitted means no new resume position.
null clears the durable resume position only when the provider truly has none.
Cursor-only items must never cover unyielded events. Validation reuses the
canonical stream cursor contract (byte limit, non-empty trimmed, no ASCII
controls).
Pure HTTP status helpers
Core does not perform HTTP. After an adapter observes a failed connect status:
| Helper | Role |
|---|---|
classifySseHttpStatus(status) | "terminal" or "transient" |
sseHttpStatusErrorCode(status) | Safe durable code such as SSE_HTTP_401 |
| HTTP status | Kind | Suggested code |
|---|---|---|
| 401, 403, 404, 410 | terminal | SSE_HTTP_${status} |
| 408, 425, 429 | transient | SSE_HTTP_${status} |
| 5xx | transient | SSE_HTTP_${status} |
| Other 4xx | terminal | SSE_HTTP_${status} |
Throw ManagedStreamTerminalError(code, message?) for terminal classifications;
throw an ordinary Error for transient. Non-HTTP / network failures before a
status remain ordinary errors (transient reconnect). 2xx is success — do not
call these helpers for successful connects. Content-Type mismatches after 2xx
are adapter policy.
Lifecycle
EOF, transient throw, terminal fault, abort, config invalidation, pull
backpressure, accept-before-checkpoint, and no required post-accept ack all
reuse the stream({ open }) lifecycle table above.
websocket({ open })
Returns a frozen WebSocketTransport (_tag: "WebSocketTransport",
kind: "websocket"). This is the managed provider-ingress WebSocket
transport on @use-crux/core/signal/transport.
Runtime lowers WebSocket items onto the managed stream fiber. There is no second
reconnect loop, worker, checkpoint schema, or Runtime-owned socket API.
Credentials, sockets, ping/pong, and wire codecs stay inside the open closure;
inert bindings and program JSON remain secret-free.
open({ cursor, signal, configRef }) opens one WebSocket connection and returns
an AsyncIterable<WebSocketItem> (or a Promise of one). Must honor signal and
clean up sockets on abort / iterator return.
WebSocketItem
Exactly one protocol item per yield — never a batch:
| Variant | Fields | After lowering |
|---|---|---|
| Envelope | kind: "envelope", identity fields, optional cursor?, optional acknowledge? | Stream envelope; process-local acknowledge preserved for the post-accept seam |
| Cursor-only | kind: "cursor", cursor: string | null | Stream cursor-only item |
Optional post-accept acknowledge
Ordinary receive-only WebSocket ingress omits acknowledge. When a provider
requires an application ack only after durable progress, attach
acknowledge: () => void | Promise<void> on the envelope item.
Runtime invokes it only after durable #337 accept (or same-digest duplicate)
and, when cursor is present, after that cursor is successfully checkpointed
(or checkpoint is skipped because the store port is absent).
If acknowledge throws:
- acceptance remains accepted (or remains a same-digest duplicate);
- the durable cursor is not cleared or rolled back;
- Runtime writes
lastErrorCode(TRANSPORT_ACK_FAILEDor a safe provider code) on an active checkpoint; - the connection outcome is transient so reconnect resumes from the durable cursor; provider redelivery is #337-deduped.
Bounded push buffering
Use createBoundedPushBuffer({ capacity, signal }) (or an equivalent bound).
There is no unlimited mode. Overflow fails the consumer with
TRANSPORT_PUSH_BUFFER_OVERFLOW instead of dropping messages so Runtime can
reconnect from the durable cursor.
Pure close-code helpers
| Helper | Role |
|---|---|
classifyWebSocketCloseCode(code) | "normal", "transient", or "terminal" |
webSocketCloseErrorCode(code) | Safe durable code such as WS_CLOSE_1008 |
Map "normal" to clean iterator completion, "transient" to an ordinary
Error, and "terminal" to ManagedStreamTerminalError.
Lifecycle
EOF, transient throw, terminal fault, abort, config invalidation, pull
backpressure, and accept-before-checkpoint reuse the stream({ open })
lifecycle table above.
signalProvider({ id, transport, signals, onEvent })
Returns a frozen SignalProvider. Live definitions retain transport and
onEvent as process code. transport may be webhook(), polling(),
stream(), sse(), or websocket(). They perform no I/O at construction time
and do not register globally.
onEvent(envelope, { signals }) may publish only the declared Signal map.
Omitted publish idempotency keys default to the accepted provider/account/event
identity when invoked through the transport normalization runner.
managedTransportBinding(provider, options)
Projects a live provider into an inert RuntimeManagedTransportBinding:
| Field | Meaning |
|---|---|
id | Stable binding identity |
configRef | { id, revision } without secrets |
signalId | Declared Signal target from the provider map |
provider? / adapterId? | Optional overrides; default to provider id |
The result is suitable for immutable Runtime program generation. It never
captures handle, poll, open, onEvent, credentials, or clients.
Acceptance and normalization
acceptTransportEnvelope(options)
Call only after edge authentication succeeds.
| Outcome | How it surfaces | Host ack |
|---|---|---|
accepted | Resolves { kind: "accepted", acknowledge: true, record } | Acknowledge success |
duplicate | Resolves { kind: "duplicate", acknowledge: true, record } | Acknowledge success |
| conflict | Throws TransportEnvelopeConflictError (code: "TRANSPORT_ENVELOPE_CONFLICT", plus provider, accountId, eventId) | Do not acknowledge as success (typically HTTP 409) |
Catch only TransportEnvelopeConflictError for conflict responses and rethrow
other errors. Conflict is never a resolved acknowledge: false result.
Envelope lifecycle states
RuntimeTransportEnvelopeState:
| State | Meaning |
|---|---|
accepted | Durable, claimable or retry-scheduled |
claimed | Leased for normalization |
normalized | Provider onEvent completed |
dead-letter | Exhausted attempts; explicit replay only |
createTransportNormalizationRunner({ store, namespace, providers })
Claims a bounded batch and runs provider normalization. Hosts typically invoke
runOnce() from the single Runtime worker maintenance path.
replayTransportEnvelope(options)
Returns a dead-letter envelope to accepted. Automatic workers never invent
replay.
Observability
transportStatistics({ store, namespace })
Returns TransportEnvelopeStats:
- exact totals:
accepted,deduplicated,normalized,delivered,retried,deadLettered - first-64
byIdentityattribution keyed bytransportStatisticsIdentity(adapterId, bindingId)(structured JSON pair, unambiguous when either id contains/) - overflow identities roll into
otherIdentitieswithidentityAttribution: "truncated"
Statistics use the shared statistics ledger export persisted on the transport store. They are restart-safe where the Runtime store is durable.
projectTransportEnvelope(record)
Privacy-safe operator view: identity, state, attempts, safe lastFailure,
config ref ids, target Signal id, and lineage entries of
{ signalId, occurrenceId }.
Projections omit payload bytes, payload refs, credentials, and raw routing secrets. Occurrence identities join existing Signal / Work / Session / Flow observability without a parallel execution registry.
When observability sinks are active, accept and normalize also emit a
custom.operation run whose attributes carry
{ kind: "transport.envelope", outcome, envelope } so Devtools Run detail can
render accepted-envelope lineage without a second event store.
transportBindingHealth({ store, namespace, program, now? })
Bounded, restart-safe per-binding health snapshot for operators and Devtools. Derives only from the immutable Runtime program, durable binding checkpoints, and the existing transport statistics ledger — no second registry or metrics store.
Each binding row includes:
| Facet | Source | Coverage notes |
|---|---|---|
| Binding / adapter / config / Signal target | Program declaration | Always present for declared bindings |
| Transport kind | Executable program provider | unknown + unavailable when no provider |
| Status | Checkpoint status | defaulted to active when omitted |
| Lease owner | Checkpoint lastOwnerId | Diagnostic last owner only; live lease tokens are never exposed |
| Cursor presence + age | Checkpoint cursor / updatedAt | Raw cursor text never appears; provider lag is unavailable |
| Outcome counts | Statistics byIdentity | First-64 attribution; overflow uses other coverage without inventing counts |
| Fault / reconnect | Checkpoint lastErrorCode / status | Live reconnect backoff is process-local (unavailable); stream exhaustion may be durable_exhausted |
| Shutdown outcome | Not durable | Always unavailable |
At most 64 bindings are projected (coverage.bindings: "truncated" beyond the
bound). Missing checkpoint methods, empty statistics ledgers, and unsupported
facets are reported with explicit coverage — never fabricated counters.
projectTransportBindingHealth(options)
Pure projection of one binding health row from already-loaded durable facts. Useful in tests and hosts that already hold checkpoints and statistics.
Retention
Runtime retention accepts transportEnvelopes (default 7d, or false to
keep forever). Only terminal normalized and dead-letter rows are eligible.
Accepted and claimed envelopes are never pruned by retention.
Binding checkpoints
Polling and stream supervision store opaque cursors through the transport store:
| Field | Meaning |
|---|---|
cursor | Last fully accepted poll/stream position |
lastPolledAt | Start of the latest poll attempt, including failed attempts (polling) |
lastOwnerId | Diagnostic owner id from the last poll/stream acquisition |
lastErrorCode | Safe failure code when the last poll/stream/accept failed |
morePending | Provider reported more pages; next tick may skip intervalMs (polling) |
configRef? | Secret-free config identity the cursor was produced under (stream invalidation; additive for shared records) |
status? | "active" (default), "faulted" (terminal stream failure / exhaustion), or "disabled" (operator). Non-active statuses skip acquisition |
Checkpoints never retain credentials, clients, sockets, Requests, raw payloads,
or unbounded event identity lists. Lease ownership uses the existing Runtime
lease port with resource transport-binding:{namespace}:{bindingId}.
putBindingCheckpoint is lease-fenced (accepted / rejected).
Deployment requirements
- Memory or PostgreSQL Runtime storage with the transports port, including
getBindingCheckpoint/putBindingCheckpoint(PostgreSQL tabletransport_binding_checkpointsvia store setup). - Convex Runtime storage does not implement managed-transport accept,
checkpoints, or statistics. Worker start with managed bindings fails with
CAPABILITY_MISSINGrather than fabricating transport state. createRuntimeProgram({ providers, transports })with live providers in process and inert bindings in the program.createRuntimeWorker({ runtime, program })as the only supervised owner of polling and stream acquisition, envelope drain, and Work maintenance.- Shutdown: worker stop aborts in-flight poll/open via
AbortSignal, releases binding leases, and preserves the last durable checkpoint for restart recovery. Process-local reconnect attempt history is not durable; see health coverage.
Guarantees and non-goals
- No second transport daemon, queue, or global provider registry.
- No Channel exclusive conversation ownership on this path (#302).
- Webhook edge accept remains host-driven; polling and managed stream are worker-supervised.
- Provider-ingress
sse({ open })andwebsocket({ open })ship as thin adapters over the managed stream fiber on this path. - Competing supervisors coordinate through Runtime leases on Memory and PostgreSQL.