Profile
Back to NewsBack
GitHub Trending 7 min
Reader Mode
danieljvdm/effect-cf: Effect-native primitives for Cloudflare Workers and bindings.

danieljvdm/effect-cf: Effect-native primitives for Cloudflare Workers and bindings.

19 hours ago

effect-cf

Effect services for Cloudflare Workers and Durable Objects.

  • effect-cf: Worker and Durable Object entrypoints, typed bindings, and storage.
  • effect-webtransport: WebTransport sessions, streams, datagrams, and Effect Socket adapters.
For measured application tuning, see runtime performance and the runtime benchmark example.

Worker + Durable Object

Save a document now and archive each revision to R2 in the background. The failure to handle is a restart between saving the document and arranging its delivery:

save document → process stops → scheduling never runs

alarms.transaction commits the document and its delivery alarm together. Before commit, neither is saved; after commit, both survive a restart. Scheduling does not need to throw for this to matter.

Here is the Durable Object from the runnable outbox example. The alarm's persisted payload is the outbox entry: it keeps the exact revision to deliver, even after the document changes again.

import { DateTime, Effect, Schema } from "effect";
import { DurableObject, DurableObjectAlarm, DurableObjectState, R2 } from "effect-cf";

const Snapshot = Schema.Struct({ revision: Schema.Int, body: Schema.String });

export class Documents extends DurableObject.Tag<Documents>()("Documents", { save: DurableObject.method({ args: [Schema.String], success: Schema.String }), read: DurableObject.method({ success: Schema.NullOr(Snapshot) }), }) {}

export class Archive extends R2.Tag<Archive>()("Archive") {}

class DocumentAlarms extends DurableObjectAlarm.Tag<DocumentAlarms>()("DocumentAlarms", { archive: Schema.Struct({ key: Schema.String, body: Schema.String }), }) {}

const DocumentLive = Documents.make(Archive.layer({ binding: "ARCHIVE" }), { rpc: { save: Effect.fn("Documents.save")(function* (body) { const state = yield* DurableObjectState.DurableObjectState; const alarms = yield* DocumentAlarms;

return yield* alarms.transaction((tx) => Effect.gen(function* () { const previous = yield* state.storage.get<typeof Snapshot.Type>("document"); const revision = (previous?.revision ?? 0) + 1; const name = state.id.name ?? state.id.toString(); const key = ${name}/${revision}.txt; const now = yield* DateTime.now;

// A restart cannot leave a saved revision with no delivery alarm. yield* state.storage.put("document", { revision, body }); yield* tx.scheduleAlarm({ tag: "archive", id: key, // The demo leaves time to stop Wrangler before delivery. runAt: DateTime.add(now, { seconds: 30 }), payload: { key, body }, });

return key; }), ); }), read: Effect.fn("Documents.read")(function* () { const state = yield* DurableObjectState.DurableObjectState;

return (yield* state.storage.get<typeof Snapshot.Type>("document")) ?? null; }), }, alarms: DocumentAlarms.handlers({ archive: Effect.fn("Documents.archive")(function* ({ tag, id, payload }) { const archive = yield* Archive; const alarms = yield* DocumentAlarms; const now = yield* DateTime.now;

// Persist another wake BEFORE the external write, in case execution stops. yield* alarms.scheduleAlarm({ tag, id, runAt: DateTime.add(now, { seconds: 30 }), payload, });

// Outside the transaction. Replays write the same key and exact content. yield* archive.put(payload.key, payload.body); yield* alarms.cancelAlarm({ tag, id }); }), }), });

export class DocumentDurableObject extends DocumentLive {}

Registering DocumentAlarms.handlers(...) on Documents.make provides the DocumentAlarms service. Scheduling and handling share the same schemas, including the transaction's tx.scheduleAlarm. Omitting the registration or a declared handler fails typechecking.

The Worker routes PUT /notes and GET /notes through the typed Documents RPC binding. GET /archive/notes/1.txt reads R2. Worker wiring and Wrangler bindings complete the application.

R2 is outside the local transaction. Before uploading, the handler persists a recovery alarm. If it stops before recording success, another attempt writes the same key and content. After a successful upload, cancellation removes the pending entry and its wake. No permanent polling is needed when there is no work.

Run locally, with emulated R2 and no Cloudflare account:

vp install
vp run dev

In another terminal:

curl -X PUT http://localhost:8787/notes --data-binary 'First draft'
curl http://localhost:8787/notes

Stop Wrangler before the 30-second delivery delay, then restart it with vp run dev. Once the deadline has passed, curl http://localhost:8787/archive/notes/1.txt returns First draft without another save request. The deliberate delay makes this recovery path easy to try; production can schedule the initial delivery immediately.

This is at-least-once delivery, not a transaction across SQLite and R2. Repeated uploads preserve the object's content, but may produce repeated write events. Persistent storage or alarm failures still need operational recovery. See the example notes for scope and retry behavior.

KV and R2

Other bindings use the same service/layer pattern. Here, R2 stores a report and schema-checked KV maps its name to the object key:

import { Effect, Layer, Schema } from "effect";
import { Kv, R2 } from "effect-cf";

class ReportIndex extends Kv.Tag<ReportIndex>()("ReportIndex", { key: Schema.String, value: Schema.String, }) {}

class Reports extends R2.Tag<Reports>()("Reports") {}

export const ReportsLive = Layer.mergeAll( ReportIndex.layer({ binding: "REPORT_INDEX" }), Reports.layer({ binding: "REPORTS" }), );

export const saveReport = Effect.fn("saveReport")(function* (name: string, csv: string) { const reports = yield* Reports; const index = yield* ReportIndex; const key = ${name}.csv;

yield* reports.put(key, csv); yield* index.put(name, key); });

Declare REPORT_INDEX as a KV namespace and REPORTS as an R2 bucket in Wrangler, then pass ReportsLive to Worker.make to call saveReport from a handler. These are separate writes, not a cross-service transaction. Binding operations expose typed errors in Effect's error channel.

See the package docs for installation, tracing, and more APIs, including D1, Queues, Workflows, and WebSockets.

effect-cf vs Alchemy

Alchemy handles infrastructure and deployment. effect-cf goes deep on the runtime inside Workers and Durable Objects. Use them together: Alchemy provisions and deploys your application; effect-cf runs it.

Alchemy also includes runtime support. These are the areas where effect-cf adds more:

| Runtime concern | Alchemy | What effect-cf adds | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Native RPC boundaries | Inferred types without runtime validation; schema-validated RPC is also available over HTTP. | Schema-checked arguments and results, serialization that preserves schema-defined values, and tagged validation errors on native RPC. | | Durable alarm jobs | Persistent named jobs with application-managed handling. | Typed handlers, retry policies, acknowledgement after success, and atomic commits of application state and alarms. | | Restoring WebSocket state | Persistent attachments; application code restores sessions. | Schema-validated session restoration with explicit handling of invalid state. | | RPC streams across hibernation | Typed RPC and WebSocket lifecycle hooks. | Idle connection recovery and opt-in stream resumption from application-provided checkpoints and replay logic. |

That runtime focus also covers:

  • Resource lifetimes: reuse application services, keep resources alive through Effect response streams, and scope background work separately.
  • Cancellation and cleanup: abort in-flight binding requests and wait for transaction rollback and workflow callback cleanup.
  • Observability: trace RPC calls across services and flush telemetry without changing handler results.
The outbox example demonstrates atomic state and alarm updates. Our bundle checks also build effect-cf applications with Alchemy.

Development

Use Vite+ 0.3.0 and Bun 1.4.0. Run vp upgrade to update an existing global Vite+ installation. The root packageManager field selects Bun for local installs and CI.

vp install
vp run check
vp run dev

check builds the packages, checks formatting, lints, runs tests, and typechecks the packages and example. dev starts the document outbox locally.

Use vp run -r build to build all workspaces. Package tests live under packages/*/tests.

Effect 4.0's Vitest adapter declares a Vitest 5 peer requirement, while the repository's pinned Vite+ and Cloudflare test plugin use Vitest 4.1.11. The repository keeps that runner and validates the Effect adapter with its Node and workerd suites; this remains a declared peer compatibility gap until the toolchains align.

Every PR gets a consumer bundle comparison, with initial and deferred sizes, a PR comment, and downloadable module analysis. Run it locally with vp run bundle:compare -- --base-dir /path/to/built/base-checkout.

Vite+ 0.3 forwards Bun 1.4's native dependency-management commands:

  • Run vp dedupe after dependency updates to consolidate compatible versions in bun.lock, or vp dedupe --check to inspect without changing it.
  • Use vp add --filter --save-catalog to add a shared dependency through the root catalog without manually editing both manifests.
Package source changes need a changeset. Create one with vp run changeset. Changesets and GitHub Actions handle releases.

MIT. See LICENSE.

Chat with me