Context
Routed is planning a small jobs/scheduling layer built on Stem. It must run in normal Dart VM/Node deployments and in Cloudflare Workers, where VM libraries such as dart:io and dart:isolate are unavailable and queue work arrives as event-driven batches rather than through a long-lived consumer stream.
The current Stem public surface is not portable enough:
package:stem/stable.dart exports the VM Worker.
worker.dart owns process signals, isolates, host metrics, leases, and the long-lived consumer lifecycle.
src/core/stem.dart imports dart:io for host metadata and transitively imports VM-bound metrics.
src/core/contracts.dart imports task_invocation.dart, which exposes SendPort from dart:isolate.
src/core/task_result.dart imports the historical package:stem/stem.dart barrel.
QueueBroker requires consume/ack/nack/close even when a runtime only needs to publish.
ResultBackend combines task status, group/chord, and worker-heartbeat responsibilities.
Architecture
1. Portable public entrypoint
Add package:stem/portable.dart. Its complete transitive dependency graph must compile for Dart's JS target without dart:io, dart:isolate, dart:ffi, or other VM-only libraries.
It should expose:
StemProducer, StemObserver, and the Stem enqueue facade;
- envelopes, task definitions, portable task contexts, handlers, registries, payload codecs, retry and signing contracts;
- portable publishing and broker capability contracts;
- narrow task-status, scheduling, and lock-store contracts;
- a portable task-processing primitive and typed outcomes;
- one-shot schedule execution.
Keep Worker, isolate pools and messaging, process signals, host metrics, daemon timers, filesystem stores, and other VM lifecycle code behind the existing compatibility barrels and an explicit VM entrypoint such as package:stem/vm.dart.
Existing imports from stable.dart and stem.dart must remain source compatible.
2. Split publishing from consumption
Introduce a publish-only contract:
abstract interface class TaskPublisher {
Future<void> publish(Envelope envelope, {RoutingInfo? routing});
}
QueueBroker should extend TaskPublisher and retain consume/ack/nack/close for long-lived broker workers. Stem and one-shot scheduling depend on TaskPublisher; the VM Worker depends on QueueBroker.
Resource closing should use a separate optional lifecycle capability so Cloudflare bindings do not need fake consumer or close methods.
3. Portable task processing
Extract envelope validation, handler resolution, argument decoding, execution middleware, handler invocation, timeout handling, and retry classification into a portable primitive (working name: TaskProcessor).
The processor must not:
- consume broker streams;
- acknowledge, negatively acknowledge, or dead-letter deliveries;
- manage broker leases;
- start isolates or processes;
- install signal handlers;
- start permanent timers.
It accepts an envelope plus runtime-provided execution controls and returns a typed semantic outcome. Outcomes must cover at least:
- success;
- retry, including delay and next-attempt metadata;
- terminal failure;
- rejected input such as unknown task, invalid signature, or invalid payload;
- cancellation/expiry;
- skipped terminal duplicate.
The existing VM Worker translates outcomes into its current persistence, acknowledgement, retry publication, group/chord, linked-task, lease, signal, and telemetry behaviour. Event-driven adapters translate each outcome to their platform's per-message acknowledgement API.
4. Portable task context
Separate the handler-facing task context and control callbacks from isolate transport.
Portable code may expose heartbeat, progress, cancellation, lease-extension, enqueue, and workflow capabilities through callbacks/interfaces. SendPort, isolate request/reply signals, and remote isolate context implementations remain VM-only.
5. Narrow persistence capabilities
Add narrow portable contracts such as:
TaskStatusStore;
AtomicTerminalResultStore;
GroupResultStore;
WorkerHeartbeatStore.
Keep ResultBackend as the compatibility facade for existing adapters. The portable processor should depend only on capabilities it actually uses.
6. One-shot scheduling
Extract portable one-shot scheduling into a ScheduleRunner.runOnce()-style primitive. The existing timer-based Beat facade may delegate to it while retaining current source compatibility.
7. Cloudflare integration
This enables Routed to provide:
- a Cloudflare Queue producer implementing
TaskPublisher;
- a batch consumer adapter mapping native queue messages to envelopes and processor outcomes to per-message
ack(), retry({delaySeconds}), or DLQ behaviour;
- a scheduled-event adapter invoking one-shot scheduling;
- D1/DO-backed
ScheduleStore, LockStore, task-status storage, and optional task-claim storage.
Cloudflare's native message attempts value is one-based. The adapter must normalize it to Stem's zero-based attempt and pass it as an execution override because a native retry redelivers the same message body. Envelope.id remains stable across every attempt.
Cloudflare Queues are at-least-once. A stable task ID is exposed as an idempotency key, but Stem does not claim exactly-once handler execution. Existing terminal-status suppression prevents already-completed work from being repeated; concurrent cross-instance suppression requires an optional atomic TaskClaimStore or application-level idempotency.
Successful messages must be acknowledged independently so another failure in the batch does not redeliver completed messages:
Non-goals
- Porting the full process/isolate worker lifecycle to Workers.
- Making Cloudflare Workflows a required Stem backend.
- Adding Cloudflare-specific types to the Stem portable package.
- Replacing the existing Node/VM worker runtime.
- Claiming exactly-once execution from an at-least-once queue.
- Requiring every result backend to implement worker heartbeat, groups, or chords.
Acceptance criteria
- A JS-targeted Dart package can import
package:stem/portable.dart without resolving VM-only libraries.
- A compile guard checks the complete portable import closure for
dart:io, dart:isolate, and dart:ffi.
Stem producer/enqueue tests compile and run on a web/JS target.
- A Cloudflare-style publisher implements only the publish contract; it does not provide fake consume/ack/nack methods.
- The portable processor executes a registered handler and returns typed success, retry, terminal failure, rejection, cancellation, and duplicate outcomes without transport side effects.
- Attempt overrides preserve the stable envelope ID while exposing the correct current and next attempt values.
- A mixed batch can independently map successful, retried, and terminal messages to different platform dispositions.
- Tests explicitly cover sequential duplicate delivery and document the limits of concurrent idempotency without an atomic claim store.
- One-shot scheduling runs without starting a daemon timer.
- Existing VM worker imports and behaviour remain source compatible.
- The Cloudflare integration package uses portable contracts wherever it does not need the VM worker runtime.
Context
Routed is planning a small jobs/scheduling layer built on Stem. It must run in normal Dart VM/Node deployments and in Cloudflare Workers, where VM libraries such as
dart:ioanddart:isolateare unavailable and queue work arrives as event-driven batches rather than through a long-lived consumer stream.The current Stem public surface is not portable enough:
package:stem/stable.dartexports the VMWorker.worker.dartowns process signals, isolates, host metrics, leases, and the long-lived consumer lifecycle.src/core/stem.dartimportsdart:iofor host metadata and transitively imports VM-bound metrics.src/core/contracts.dartimportstask_invocation.dart, which exposesSendPortfromdart:isolate.src/core/task_result.dartimports the historicalpackage:stem/stem.dartbarrel.QueueBrokerrequires consume/ack/nack/close even when a runtime only needs to publish.ResultBackendcombines task status, group/chord, and worker-heartbeat responsibilities.Architecture
1. Portable public entrypoint
Add
package:stem/portable.dart. Its complete transitive dependency graph must compile for Dart's JS target withoutdart:io,dart:isolate,dart:ffi, or other VM-only libraries.It should expose:
StemProducer,StemObserver, and theStemenqueue facade;Keep
Worker, isolate pools and messaging, process signals, host metrics, daemon timers, filesystem stores, and other VM lifecycle code behind the existing compatibility barrels and an explicit VM entrypoint such aspackage:stem/vm.dart.Existing imports from
stable.dartandstem.dartmust remain source compatible.2. Split publishing from consumption
Introduce a publish-only contract:
QueueBrokershould extendTaskPublisherand retain consume/ack/nack/close for long-lived broker workers.Stemand one-shot scheduling depend onTaskPublisher; the VMWorkerdepends onQueueBroker.Resource closing should use a separate optional lifecycle capability so Cloudflare bindings do not need fake consumer or close methods.
3. Portable task processing
Extract envelope validation, handler resolution, argument decoding, execution middleware, handler invocation, timeout handling, and retry classification into a portable primitive (working name:
TaskProcessor).The processor must not:
It accepts an envelope plus runtime-provided execution controls and returns a typed semantic outcome. Outcomes must cover at least:
The existing VM
Workertranslates outcomes into its current persistence, acknowledgement, retry publication, group/chord, linked-task, lease, signal, and telemetry behaviour. Event-driven adapters translate each outcome to their platform's per-message acknowledgement API.4. Portable task context
Separate the handler-facing task context and control callbacks from isolate transport.
Portable code may expose heartbeat, progress, cancellation, lease-extension, enqueue, and workflow capabilities through callbacks/interfaces.
SendPort, isolate request/reply signals, and remote isolate context implementations remain VM-only.5. Narrow persistence capabilities
Add narrow portable contracts such as:
TaskStatusStore;AtomicTerminalResultStore;GroupResultStore;WorkerHeartbeatStore.Keep
ResultBackendas the compatibility facade for existing adapters. The portable processor should depend only on capabilities it actually uses.6. One-shot scheduling
Extract portable one-shot scheduling into a
ScheduleRunner.runOnce()-style primitive. The existing timer-basedBeatfacade may delegate to it while retaining current source compatibility.7. Cloudflare integration
This enables Routed to provide:
TaskPublisher;ack(),retry({delaySeconds}), or DLQ behaviour;ScheduleStore,LockStore, task-status storage, and optional task-claim storage.Cloudflare's native message
attemptsvalue is one-based. The adapter must normalize it to Stem's zero-based attempt and pass it as an execution override because a native retry redelivers the same message body.Envelope.idremains stable across every attempt.Cloudflare Queues are at-least-once. A stable task ID is exposed as an idempotency key, but Stem does not claim exactly-once handler execution. Existing terminal-status suppression prevents already-completed work from being repeated; concurrent cross-instance suppression requires an optional atomic
TaskClaimStoreor application-level idempotency.Successful messages must be acknowledged independently so another failure in the batch does not redeliver completed messages:
Non-goals
Acceptance criteria
package:stem/portable.dartwithout resolving VM-only libraries.dart:io,dart:isolate, anddart:ffi.Stemproducer/enqueue tests compile and run on a web/JS target.