Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
42bc6d8
feat(platform-cloudflare): scaffold package with cluster name codec
tim-smart Aug 18, 2026
dbad9ad
feat(platform-cloudflare): Durable Object classes with cheap entity c…
tim-smart Aug 18, 2026
3ba563a
feat(platform-cloudflare): CloudflareCluster.layer with Worker-side S…
tim-smart Aug 18, 2026
953aaf5
docs(platform-cloudflare): wrangler example and changeset
tim-smart Aug 18, 2026
701c88e
fix(platform-cloudflare): apply review findings
tim-smart Aug 18, 2026
5841eb1
feat(platform-cloudflare): add durable entity mailbox
tim-smart Aug 18, 2026
7d904b2
fix(platform-cloudflare): use public stream schema API
tim-smart Aug 18, 2026
85800fe
refactor(platform-cloudflare): simplify entity mailbox internals
tim-smart Aug 18, 2026
06b4c89
fix(platform-cloudflare): address mailbox audit findings
tim-smart Aug 18, 2026
4e2aa2d
test(platform-cloudflare): cover stream acknowledgements
tim-smart Aug 18, 2026
bdeb29d
feat(platform-cloudflare): add delayed entity delivery
tim-smart Aug 18, 2026
2180bde
fix(platform-cloudflare): preserve delayed request waiters
tim-smart Aug 18, 2026
42db16d
fix(platform-cloudflare): reject ask deduplication to tells
tim-smart Aug 18, 2026
a8eff96
feat(platform-cloudflare): pin entity resource holders
tim-smart Aug 18, 2026
82e9063
feat(platform-cloudflare): add CloudflareWorkflowEngine with durable …
claude Aug 18, 2026
69931e7
fix(platform-cloudflare): apply workflow engine review findings
claude Aug 18, 2026
afdba94
fix(platform-cloudflare): harden workflow engine wake and interrupt p…
claude Aug 18, 2026
050bc57
refactor(platform-cloudflare): simplify workflow engine internals
claude Aug 18, 2026
540e7b5
feat(platform-cloudflare): add DurableQueue Durable Object
Aug 18, 2026
8e98068
refactor(platform-cloudflare): simplify durable queue internals
Aug 18, 2026
e1fa410
feat(platform-cloudflare): add singleton Durable Object wake
tim-smart Aug 18, 2026
d10fd2d
feat(platform-cloudflare): run ClusterCron on Durable Objects
tim-smart Aug 18, 2026
8e90d2a
feat(platform-cloudflare): add proxy telemetry and guidance
tim-smart Aug 18, 2026
11a7caf
fix(platform-cloudflare): correct telemetry lifecycles
tim-smart Aug 18, 2026
5381607
fix(platform-cloudflare): honor defect retry schedules
tim-smart Aug 18, 2026
80b53ed
fix(platform-cloudflare): keep keep-alive handler private
tim-smart Aug 18, 2026
ab2faa3
test(platform-cloudflare): remove unused entity import
tim-smart Aug 18, 2026
fc958ca
refactor(platform-cloudflare): simplicity pass over cluster internals
tim-smart Aug 18, 2026
c50e4e8
refactor(platform-cloudflare): idiomatic Effect cluster internals
tim-smart Aug 19, 2026
41b0181
feat(platform-cloudflare): honor Entity.toLayer concurrency in entity…
tim-smart Aug 19, 2026
a0a463e
chore(platform-cloudflare): remove entity concurrency changeset
tim-smart Aug 19, 2026
a7c3eb6
refactor(platform-cloudflare): simplify entity concurrency internals
tim-smart Aug 19, 2026
38a4f7e
fix(platform-cloudflare): complete interrupted streams
tim-smart Aug 19, 2026
f9d9e65
Honor uninterruptible Cloudflare entity requests (#7345)
tim-smart Aug 19, 2026
84d7532
Fix concurrent Cloudflare entity handler builds (#7346)
tim-smart Aug 19, 2026
969c782
Address PR review feedback
tim-smart Aug 19, 2026
c8f4b60
fix(platform-cloudflare): type SQLite query results
tim-smart Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/cloudflare-cluster.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"effect": patch
---

Add the `@effect/platform-cloudflare` package, running Effect Cluster on
Cloudflare Workers and Durable Objects.

One entity instance is one Durable Object with its SQLite storage as the
system of record. The package provides the four Durable Object classes
(entity, workflow, durable queue, singleton), the length-prefixed entity name
encoding, and `CloudflareCluster.layer`, which wires the cluster `Sharding`
service, the `WorkflowEngine`, and the `PersistedQueueFactory` from the
same-Worker namespace bindings. The `Entity`, `Workflow`, `Activity`,
`DurableClock`, `DurableQueue`, `Singleton`, and `ClusterCron` user APIs are
unchanged on this path; every `DurableClock` is durable through the object's
alarm.
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@effect/opentelemetry",
"@effect/platform-browser",
"@effect/platform-bun",
"@effect/platform-cloudflare",
"@effect/platform-deno",
"@effect/platform-node",
"@effect/platform-node-shared",
Expand Down
1 change: 1 addition & 0 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"packages/opentelemetry/",
"packages/platform/browser/",
"packages/platform/bun/",
"packages/platform/cloudflare/",
"packages/platform/node/",
"packages/platform/node-shared/",
"packages/tools/",
Expand Down
23 changes: 23 additions & 0 deletions packages/effect/src/unstable/cluster/Entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,11 @@ export const keepAlive: (
never,
Sharding | CurrentAddress
> = Effect.fnUntraced(function*(enabled: boolean) {
const ohandler = yield* Effect.serviceOption(KeepAliveHandler)
if (ohandler._tag === "Some") {
yield* ohandler.value(enabled)
return
}
const olatch = yield* Effect.serviceOption(KeepAliveLatch)
if (olatch._tag === "None") return
if (!enabled) {
Expand Down Expand Up @@ -780,3 +785,21 @@ export const KeepAliveRpc = Rpc.make("Cluster/Entity/keepAlive")
export class KeepAliveLatch extends Context.Service<KeepAliveLatch, Latch.Latch>()(
"effect/cluster/Entity/KeepAliveLatch"
) {}

/**
* Service tag for the runtime hook behind {@link keepAlive}.
*
* **Details**
*
* Runtimes that support pinning an entity in memory provide this service; the
* handler receives `true` while at least one keep-alive holder exists and
* `false` once the last holder is released. When the service is absent,
* `keepAlive` is a no-op.
*
* @category services
* @since 4.0.0
*/
export class KeepAliveHandler extends Context.Service<
KeepAliveHandler,
(enabled: boolean) => Effect.Effect<void>
>()("effect/cluster/Entity/KeepAliveHandler") {}
24 changes: 20 additions & 4 deletions packages/effect/src/unstable/workflow/DurableClock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,24 @@ const InstanceTag = Context.Service<
"effect/workflow/WorkflowEngine/WorkflowInstance" satisfies typeof WorkflowInstance.key
)

/**
* Context reference containing the default `inMemoryThreshold` used by
* {@link sleep} when the option is not passed.
*
* **Details**
*
* Workflow engines whose timers are always durable (for example the Cloudflare
* Durable Object engine) provide `Duration.zero` so every `sleep` without an
* explicit `inMemoryThreshold` schedules a durable clock.
*
* @category services
* @since 4.0.0
*/
export const InMemoryThreshold = Context.Reference<Duration.Duration>(
"effect/workflow/DurableClock/InMemoryThreshold",
{ defaultValue: () => Duration.seconds(60) }
)

/**
* Waits inside a workflow, using an in-memory activity for durations at or
* below the threshold and scheduling a durable clock for longer durations.
Expand Down Expand Up @@ -93,9 +111,9 @@ export const sleep: (
return
}

const inMemoryThreshold = options.inMemoryThreshold
const inMemoryThreshold = options.inMemoryThreshold !== undefined
? Duration.fromInputUnsafe(options.inMemoryThreshold)
: defaultInMemoryThreshold
: yield* InMemoryThreshold

if (Duration.isLessThanOrEqualTo(duration, inMemoryThreshold)) {
return yield* Activity.make({
Expand All @@ -113,5 +131,3 @@ export const sleep: (
})
return yield* DurableDeferred.await(clock.deferred)
})

const defaultInMemoryThreshold = Duration.seconds(60)
21 changes: 21 additions & 0 deletions packages/platform/cloudflare/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2023 Effectful Technologies Inc

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
187 changes: 187 additions & 0 deletions packages/platform/cloudflare/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# @effect/platform-cloudflare

Runs Effect Cluster on [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/). Every entity instance is one Durable Object, the Worker is the edge, and each object's SQLite storage is the system of record.

## Installation

```sh
npm install effect@rc @effect/platform-cloudflare@rc
```

## Usage

The package ships four Durable Object classes. Re-export them from your Worker entry module and bind each one as a SQLite-backed class:

```jsonc
// wrangler.jsonc
{
"name": "my-worker",
"main": "src/worker.ts",
"compatibility_date": "2026-08-01",
"durable_objects": {
"bindings": [
{ "name": "CLUSTER_ENTITY", "class_name": "ClusterEntity" },
{ "name": "CLUSTER_WORKFLOW", "class_name": "ClusterWorkflow" },
{ "name": "CLUSTER_QUEUE", "class_name": "ClusterDurableQueue" },
{ "name": "CLUSTER_SINGLETON", "class_name": "ClusterSingleton" },
],
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": [
"ClusterEntity",
"ClusterWorkflow",
"ClusterDurableQueue",
"ClusterSingleton",
],
},
],
"triggers": {
"crons": ["0 * * * *"],
},
}
```

```ts
// src/worker.ts
import { CloudflareCluster } from "@effect/platform-cloudflare"
import { Effect, Layer, Schema } from "effect"
import { Entity, Singleton } from "effect/unstable/cluster"
import { Rpc } from "effect/unstable/rpc"

export {
ClusterDurableQueue,
ClusterEntity,
ClusterSingleton,
ClusterWorkflow
} from "@effect/platform-cloudflare/CloudflareDurableObjects"

// The same Entity + RpcGroup definitions as on every other cluster path
const Counter = Entity.make("Counter", [
Rpc.make("Increment", { success: Schema.Number })
])

const CounterLayer = Counter.toLayer({
Increment: () => Effect.succeed(1)
})

const MaintenanceLayer = Singleton.make(
"hourly-maintenance",
Effect.logInfo("Running hourly maintenance")
)

const clusterLayer = (env: Env) =>
Layer.merge(CounterLayer, MaintenanceLayer).pipe(
Layer.provideMerge(CloudflareCluster.layer({
entities: [Counter],
entityNamespace: env.CLUSTER_ENTITY,
workflowNamespace: env.CLUSTER_WORKFLOW,
queueNamespace: env.CLUSTER_QUEUE,
singletonNamespace: env.CLUSTER_SINGLETON
}))
)
```

The Cron Trigger wakes the named singleton through its same-Worker binding.
The call returns after one run, allowing the Durable Object to hibernate; do
not make the singleton effect a forever loop.

```ts
export default {
scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext) {
const singleton = env.CLUSTER_SINGLETON.getByName("Singleton/hourly-maintenance")
ctx.waitUntil(singleton.wake())
}
}
```

`Entity.client` stays the user API. The Worker encodes `(type, id)` into the Durable Object name and resolves the object with `getByName`; an unknown entity type fails at the Worker before any Durable Object is contacted.

## Worker routes

The existing `EntityProxy` / `EntityProxyServer` and `WorkflowProxy` /
`WorkflowProxyServer` modules remain the route helpers. Define an HTTP or RPC
surface with the proxy module, then provide its server layer with
`CloudflareCluster.layer`. Entity proxy handlers call `Entity.client`, and
workflow proxy handlers call the workflow API, so the Cloudflare layers encode
the entity or workflow name and resolve the corresponding Durable Object stub.
There is no runner-fleet proxy on this path.

These are Worker routes, not Durable Object routes. The Durable Object classes
are internal transport: they trust the same-Worker namespace bindings and must
not be exposed on a public route. HTTP or RPC authentication and authorization
are user code on the Worker.

## Long waits and delivery

- A long ask pins the caller. A delayed ask made directly by a Worker also
keeps the destination RPC open, so it pins the destination too.
- Caller eviction or deployment drops the in-memory wait even though the
destination may still run the persisted request.
- Prefer a tell when no response is needed. For durable long waits, prefer a
workflow with `DurableClock` and `DurableDeferred`.
- Stream asks with a future `DeliverAt` are outside v1.

## Handler concurrency

`Entity.toLayer(..., { concurrency })` applies inside the entity Durable
Object. The default of 1 runs one handler at a time, a number allows that many
in-flight handlers per entity, and `"unbounded"` removes the limit. Durable
Object isolates are single-threaded, so this is interleaving of suspended
handlers, not parallelism.

- Envelope decode, persist-before-run, dedupe, duplicate resume, and alarm
arming stay serialized at any setting.
- With `concurrency` above 1, strict mailbox ordering holds per permit, the
same as the classic runner path: in-flight handlers interleave at every
suspension point.
- An ask cycle (entity A asks B while B's handler asks A back) needs
`concurrency` of at least 2 on the entity receiving the second ask. At the
default of 1 the cycle deadlocks, matching the classic contract.
- Replayed mailbox rows and alarm-due runs draw from the same budget as live
requests.

## v1 compatibility

The status vocabulary is **maps 1:1**, **adapted**, and **out of scope**.

| Capability | Status | Rationale |
| ----------------------------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------- |
| `Entity` + `RpcGroup` definition | adapted | Same definition; handlers register at Worker init onto one shared Durable Object class |
| `Entity.client` / location-transparent ask-tell | adapted | Worker encodes `(type, id)` and calls `getByName`; there is no `ShardId` routing |
| `EntityProxy` / `EntityProxyServer` and workflow equivalents | adapted | Worker route helpers encode the name and stub the Durable Object through the Cloudflare layers |
| Non-`Persisted` RPC | adapted | Best-effort in-request only; it can be lost on hibernation or a crash |
| `Persisted` ask/tell + mailbox | adapted | Per-entity Durable Object SQLite, persist-before-run, and uuidv7 request ids |
| `PrimaryKey` dedupe / `Duplicate` resume | maps 1:1 | Same contract |
| Stream ask `Chunk` / `AckChunk` / `lastSentChunk` / `WithExit` | maps 1:1 | Same reply protocol on Durable Object storage |
| `clearReplies` / `reset` | maps 1:1 | Same re-run semantics |
| `DeliverAt` mailbox delivery | adapted | Destination due column and alarm instead of storage polling |
| Ask + future `DeliverAt` | adapted | Destination may hibernate through `replyTo`; ask pins its caller, and a Worker ask pins the destination too |
| `MailboxFull` / 4096 cap / 2 MB row rejection | maps 1:1 | Same limits; the SQLite row is the hard ceiling |
| `defectRetryPolicy` then terminal defect | adapted | Rebuilds handlers in the wake; crash or deployment wipes memory and replays unprocessed rows |
| `Entity.toLayer` `concurrency` | maps 1:1 | Same per-entity handler interleaving contract; storage entry stays serialized at any setting |
| `Entity.keepAlive` | adapted | Pins while holders exist; hibernation is allowed with no holders |
| `CurrentRunnerAddress` | adapted | Synthetic address for identity and telemetry; no peer dialing |
| `EntityResource.make` | adapted | External lifetimes such as a browser; close or idle TTL unpins |
| `EntityResource.makeK8sPod` | out of scope | Requires `K8sHttpClient` |
| `Workflow` / `Activity` / `DurableDeferred` user APIs | maps 1:1 | Unchanged; the engine behind them changes |
| `CloudflareWorkflowEngine` (`WorkflowEngine.Encoded`) | adapted | Dedicated workflow Durable Object, SQLite, and one alarm |
| `DurableClock` | adapted | Always durable; there is no short in-memory timer path |
| `DurableQueue` | adapted | One Durable Object per queue name with SQLite and an alarm watchdog |
| `Singleton` | adapted | Named Durable Object; runs once per wake and then may hibernate |
| `ClusterCron` | adapted | Per-fire entity ids, `DeliverAt` destination alarms, and a singleton seed |
| Address `(EntityType, EntityId)` | adapted | Length-prefixed Durable Object name; cold first contact is normal |
| `ShardId` / shard locks / runner ring | out of scope | One-instance-per-id replaces ownership |
| `MessageStorage` / `RunnerStorage` / `RunnerHealth` / `Runners` as user seams | out of scope | The Durable Object path owns persistence and alarms internally |
| `HttpRunner` / `SocketRunner` / peer runner fleet | out of scope | Worker edge only |
| `EntityReaper` / `maxIdleTime` | out of scope | Cloudflare hibernation owns sleep; `keepAlive` holders provide pinning |
| Activate/deactivate / shard handoff / `EntityNotAssignedToRunner` | out of scope | Whole-wake handlers with no handoff |
| Park-and-replay caller hibernation | out of scope | Not part of v1 |
| Stream ask + future `DeliverAt` | out of scope | Delayed asks support non-stream `WithExit` only |
| External SQL as the system of record | out of scope | Durable Object SQLite is the system of record |
| Non-Worker long-lived runners as a first-class edge | out of scope | The Worker is the supported edge model |

## Documentation

- [Effect website](https://effect.website)
76 changes: 76 additions & 0 deletions packages/platform/cloudflare/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"name": "@effect/platform-cloudflare",
"type": "module",
"version": "4.0.0-rc.110",
"license": "MIT",
"description": "Platform specific implementations for Cloudflare Workers and Durable Objects",
"homepage": "https://effect.website",
"repository": {
"type": "git",
"url": "https://github.com/Effect-TS/effect.git",
"directory": "packages/platform/cloudflare"
},
"bugs": {
"url": "https://github.com/Effect-TS/effect/issues"
},
"tags": [
"cloudflare",
"typescript",
"algebraic-data-types",
"functional-programming"
],
"keywords": [
"cloudflare",
"typescript",
"algebraic-data-types",
"functional-programming"
],
"sideEffects": [],
"exports": {
"./package.json": "./package.json",
".": "./src/index.ts",
"./*": "./src/*.ts",
"./internal/*": null,
"./index": null,
"./*/index": null
},
"files": [
"src/**/*.ts",
"dist/**/*.js",
"dist/**/*.js.map",
"dist/**/*.d.ts",
"dist/**/*.d.ts.map",
"AGENTS.md",
"CLAUDE.md",
"ai-docs/**/*"
],
"publishConfig": {
"access": "public",
"provenance": true,
"exports": {
"./package.json": "./package.json",
".": "./dist/index.js",
"./*": "./dist/*.js",
"./internal/*": null,
"./index": null,
"./*/index": null
}
},
"scripts": {
"codegen": "effect-utils codegen",
"build": "tsc -b tsconfig.json && pnpm babel",
"babel": "babel dist --plugins annotate-pure-calls --out-dir dist --source-maps",
"check": "tsc -b tsconfig.json"
},
"peerDependencies": {
"effect": "workspace:^"
},
"dependencies": {
"@cloudflare/workers-types": "^5.20260816.1"
},
"devDependencies": {
"effect": "workspace:^",
"esbuild": "^0.25.12",
"miniflare": "^4.20260730.0"
}
}
Loading
Loading