effect-cf 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +31 -1079
  3. package/dist/{Artifacts-DnHQA-Ax.d.mts → Artifacts-B3r3RuTB.d.mts} +1 -48
  4. package/dist/{Artifacts-CSIk1Oml.mjs → Artifacts-CBxWGeZB.mjs} +2 -7
  5. package/dist/{Binding-pfilNJmP.mjs → Binding-C82jRhdr.mjs} +1 -2
  6. package/dist/{Binding-xJJJPmiD.d.mts → Binding-DKcLrQCM.d.mts} +0 -45
  7. package/dist/ComputerArtifacts.d.mts +1 -3
  8. package/dist/ComputerArtifacts.mjs +1 -2
  9. package/dist/ComputerWorkspace.d.mts +11 -12
  10. package/dist/ComputerWorkspace.mjs +31 -10
  11. package/dist/ComputerWorkspaceHost.d.mts +1 -9
  12. package/dist/{ContainerNamespace-CFjiLw8b.d.mts → ContainerNamespace-DbLaeFhN.d.mts} +1 -14
  13. package/dist/{DurableObjectState-CVLLse69.mjs → DurableObjectState-DcqElJ4e.mjs} +82 -89
  14. package/dist/{DurableObjectState-BpRjz6-q.d.mts → DurableObjectState-DpYdNZgT.d.mts} +102 -266
  15. package/dist/{ErrorMessage-BM0K-Nik.mjs → ErrorMessage-Byx2OgNd.mjs} +0 -1
  16. package/dist/{Hyperdrive-z-9fFz15.d.mts → Hyperdrive-HrwFlZ6x.d.mts} +1 -4
  17. package/dist/HyperdrivePg.d.mts +2 -11
  18. package/dist/HyperdrivePg.mjs +0 -8
  19. package/dist/Sandbox.d.mts +2 -32
  20. package/dist/Sandbox.mjs +2 -11
  21. package/dist/Vitest.d.mts +3 -31
  22. package/dist/Vitest.mjs +8 -26
  23. package/dist/{WorkflowBinding-lBR4_-op.d.mts → WorkflowBinding-Cn3AuUyP.d.mts} +2 -5
  24. package/dist/index.d.mts +42 -480
  25. package/dist/index.mjs +267 -380
  26. package/package.json +1 -1
  27. package/src/AiGateway.ts +0 -6
  28. package/src/AnalyticsEngine.ts +8 -10
  29. package/src/Artifacts.ts +0 -47
  30. package/src/Binding.ts +0 -8
  31. package/src/BrowserRendering.ts +0 -4
  32. package/src/Cache.ts +0 -19
  33. package/src/CloudflareOtlp.ts +6 -39
  34. package/src/ComputerArtifacts.ts +0 -2
  35. package/src/ComputerWorkspace.ts +53 -35
  36. package/src/ComputerWorkspaceHost.ts +0 -8
  37. package/src/ContainerNamespace.ts +0 -13
  38. package/src/D1.ts +0 -22
  39. package/src/DurableObject.ts +51 -32
  40. package/src/DurableObjectAlarm.ts +6 -210
  41. package/src/DurableObjectDefinition.ts +8 -20
  42. package/src/DurableObjectNamespace.ts +88 -115
  43. package/src/DurableObjectRpcWebSocket.ts +5 -46
  44. package/src/DurableObjectSqlite.ts +0 -12
  45. package/src/DurableObjectState.ts +18 -38
  46. package/src/DurableObjectStorage.ts +55 -70
  47. package/src/DurableObjectWebSocket.ts +23 -72
  48. package/src/Email.ts +0 -12
  49. package/src/Environment.ts +0 -37
  50. package/src/Hyperdrive.ts +0 -3
  51. package/src/HyperdrivePg.ts +0 -9
  52. package/src/Images.ts +0 -5
  53. package/src/Kv.ts +0 -15
  54. package/src/QueueBinding.ts +0 -2
  55. package/src/R2.ts +0 -5
  56. package/src/RpcDefinition.ts +5 -17
  57. package/src/RpcTracing.ts +133 -0
  58. package/src/Sandbox.ts +0 -30
  59. package/src/ServiceBinding.ts +83 -128
  60. package/src/Vectorize.ts +0 -5
  61. package/src/Vitest.ts +18 -43
  62. package/src/WebTransport.ts +0 -15
  63. package/src/Worker.ts +140 -108
  64. package/src/WorkerDefinition.ts +5 -14
  65. package/src/WorkersAi.ts +21 -18
  66. package/src/Workflow.ts +45 -54
  67. package/src/WorkflowBinding.ts +0 -3
  68. package/src/index.ts +1 -0
  69. package/src/internal/Entrypoint.ts +23 -4
  70. package/src/internal/ErrorMessage.ts +0 -1
  71. package/src/internal/NativeCallback.ts +55 -0
  72. package/src/internal/RpcInvocation.ts +18 -1
  73. package/src/internal/Runtime.ts +12 -7
  74. package/src/internal/WorkerContext.ts +5 -14
package/README.md CHANGED
@@ -1,1116 +1,68 @@
1
1
  # effect-cf
2
2
 
3
- Effect-native Cloudflare primitives for Workers, Durable Objects, Containers, bindings, Cache, KV, D1, R2, Artifacts, Queues, Email, Analytics Engine, Workflows, and Durable Object storage.
3
+ Cloudflare entrypoints and bindings as Effect services.
4
4
 
5
- ## Install
6
-
7
- `effect-cf` targets Effect `^4.0.0-beta.105` and Cloudflare workerd `1.20260825.1`
8
- (`@cloudflare/workers-types@5.20260825.1`, recommended `compatibility_date` `2026-08-25`).
9
-
10
- ```bash
11
- bun add effect-cf "effect@^4.0.0-beta.105"
12
- ```
13
-
14
- ```bash
15
- pnpm add effect-cf "effect@^4.0.0-beta.105"
16
- ```
17
-
18
- ```bash
19
- npm install effect-cf "effect@^4.0.0-beta.105"
20
- ```
21
-
22
- The Computer workspace integration is an optional peer. Install it only in
23
- applications that import `effect-cf/computer-workspace`,
24
- `effect-cf/computer-artifacts`, or `effect-cf/computer-workspace-host`:
25
-
26
- ```bash
27
- bun add "@cloudflare/computer@^0.2.0"
28
- ```
29
-
30
- Workspace Git operations additionally require `@platformatic/vfs`,
31
- `@cloudflare/computer/git`'s own optional peer:
32
-
33
- ```bash
34
- bun add "@platformatic/vfs@^0.4.0"
35
- ```
36
-
37
- The Sandbox integration is an optional peer. Install the `@next` release line
38
- of `@cloudflare/sandbox` (the Sandbox SDK 1.0 API) only in applications that
39
- import `effect-cf/sandbox`:
40
-
41
- ```bash
42
- bun add "@cloudflare/sandbox@next"
43
- ```
44
-
45
- ## Goal
46
-
47
- Cloudflare APIs return promises and expose platform-specific bindings. `effect-cf` wraps those boundaries as `Context`, `Layer`, and `Effect` values so application code stays inside one managed Effect runtime.
48
-
49
- Runtime creation belongs at Cloudflare entrypoints, not inside binding helpers.
50
-
51
- ## Exports
52
-
53
- - `Worker` - Worker entrypoint factory, request services, and typed Worker bindings
54
- - `DurableObject` - Durable Object entrypoint factory and typed namespace helpers
55
- - `DurableObjectState` / `DurableObjectStorage` - Effect wrappers for state, alarms, SQL, and embedded KV
56
- - `DurableObjectWebSocket` - WebSocket upgrade helpers for Durable Objects
57
- - `ContainerNamespace` - named Cloudflare Container instances with Effect-wrapped request and lifecycle operations
58
- - `Cache` - Effect wrapper for Cloudflare's default and named Cache API instances
59
- - `Kv` - typed KV namespace helper
60
- - `D1` - typed D1 database binding helper with an `@effect/sql-d1` backed SQL layer
61
- - `R2` - typed R2 bucket binding helper with Effect-wrapped object and multipart operations
62
- - `Artifacts` - typed Cloudflare Artifacts namespace and repository helpers, including repository lifecycle, tokens, and Git object reads
63
- - `effect-cf/computer-workspace` - scoped Effect wrappers for the complete Cloudflare Computer filesystem, Git, runtime, Assets, Artifacts, and Think-compatible client surfaces
64
- - `effect-cf/computer-artifacts` - session-isolated Artifacts repositories backed by `@cloudflare/computer/artifacts`
65
- - `effect-cf/computer-workspace-host` - optional Durable Object host mixin, worker-shell backend wiring, and `WorkspaceServiceProxy`
66
- - `effect-cf/sandbox` - Effect-native client for Cloudflare Sandbox containers: processes with log streams, files and watches, preview URLs, tunnels, terminals, bucket mounts, and backups
67
- - `Hyperdrive` - typed Hyperdrive binding helper for connection strings and optional Postgres SQL integration
68
- - `Images` - typed Cloudflare Images binding helper with transformation APIs and optional hosted image operations
69
- - `Email` - typed Cloudflare Email Service binding helper for `send_email` bindings, with limit validation and typed error codes
70
- - `AnalyticsEngine` - typed Cloudflare Analytics Engine write bindings and SQL API query helpers
71
- - `Queue` - typed Queue producer/consumer tags plus client and error types
72
- - `Workflow` - typed Workflow entrypoints, steps, starter clients, and instance types
73
- - `Rpc` - Cloudflare RPC type helpers and scoped disposal utilities
74
- - `WebTransport` - truthful WebTransport/HTTP-3 boundary: typed runtime capabilities and decoded inbound protocol metadata
75
- - `WorkerConfig` - Effect `Config` helpers backed by Cloudflare `env`
76
- - `effect-cf/vitest` - Effect-native runners and scoped test helpers for Cloudflare's Workers Vitest plugin
77
-
78
- ## Vitest Workers Plugin
79
-
80
- Install and configure a `^1.0.0` release of
81
- [`@cloudflare/vitest-plugin`](https://github.com/cloudflare/workers-sdk/tree/main/packages/vitest-plugin#readme),
82
- then import the test-only helpers from `effect-cf/vitest`. The `fetch` runner
83
- constructs the Worker with the current test environment and waits for all
84
- `waitUntil` work before completing the Effect.
85
-
86
- ```ts
87
- import { assert, it } from "@effect/vitest";
88
- import { Config, Effect } from "effect";
89
- import * as PoolWorkers from "effect-cf/vitest";
90
-
91
- import WorkerEntrypoint from "../src/index";
92
-
93
- it.effect("serves a request", () =>
94
- Effect.gen(function* () {
95
- const response = yield* PoolWorkers.fetch(
96
- WorkerEntrypoint,
97
- new Request("https://worker.test/health"),
98
- );
99
-
100
- assert.strictEqual(response.status, 200);
101
- }),
102
- );
103
-
104
- it.effect("reads Wrangler vars", () =>
105
- Config.string("APP_NAME").pipe(
106
- Effect.tap((appName) => assert.strictEqual(appName, "test-app")),
107
- Effect.provide(PoolWorkers.layer),
108
- ),
109
- );
110
- ```
111
-
112
- Queue consumers can be invoked with typed message bodies while retaining the
113
- plugin's acknowledgement and retry result:
114
-
115
- ```ts
116
- it.effect("acknowledges a job", () =>
117
- Effect.gen(function* () {
118
- const { result } = yield* PoolWorkers.queue(MyQueueConsumer, "jobs", [
119
- {
120
- id: "job-1",
121
- timestamp: new Date(),
122
- attempts: 1,
123
- body: { accountId: "account-1" },
124
- },
125
- ]);
126
-
127
- assert.deepStrictEqual(result.explicitAcks, ["job-1"]);
128
- }),
129
- );
5
+ ```sh
6
+ npm install effect-cf "effect@^4.0.0-rc.110"
130
7
  ```
131
8
 
132
- Scheduled handlers and Pages Functions use the same lifecycle behavior: their
133
- Effects complete only after the event's `waitUntil` work has settled. Pages
134
- tests must configure the `ASSETS` binding required by the Workers Vitest plugin.
135
-
136
- ```ts
137
- it.effect("runs the cron handler", () =>
138
- PoolWorkers.scheduled(worker.scheduled, {
139
- cron: "30 * * * *",
140
- scheduledTime: new Date("2030-01-01T00:00:00Z"),
141
- }),
142
- );
143
-
144
- it.effect("runs a Pages Function", () =>
145
- Effect.gen(function* () {
146
- const response = yield* PoolWorkers.pages(onRequest, {
147
- request: new Request("https://pages.test/users/dan"),
148
- params: { user: "dan" },
149
- data: { authenticated: true },
150
- });
9
+ The repository tests against workerd `1.20260825.1` and `@cloudflare/workers-types@5.20260825.1`. Use `compatibility_date: "2026-08-25"` in Wrangler.
151
10
 
152
- assert.strictEqual(response.status, 200);
153
- }),
154
- );
155
- ```
156
-
157
- `runInDurableObject` carries the test's Effect context into the Durable Object
158
- I/O context and provides the same wrapped `DurableObjectState` service used by
159
- production handlers. Typed failures remain in the Effect error channel.
160
-
161
- ```ts
162
- it.effect("seeds a Durable Object", () =>
163
- Effect.gen(function* () {
164
- const stub = env.COUNTERS.getByName("home");
165
-
166
- yield* PoolWorkers.runInDurableObject(stub, () =>
167
- Effect.gen(function* () {
168
- const state = yield* DurableObjectState;
169
- yield* state.storage.put("count", 41);
170
- }),
171
- );
172
-
173
- assert.strictEqual(yield* PoolWorkers.runDurableObjectAlarm(stub), true);
174
-
175
- // Recreate in-memory state while preserving Durable Object storage.
176
- yield* PoolWorkers.evictDurableObject(stub);
177
- }),
178
- );
179
- ```
180
-
181
- The subpath also exposes Effects for `listDurableObjectIds`, `reset`,
182
- `abortAllDurableObjects`, `evictAllDurableObjects`, and `applyD1Migrations`.
183
- `adminSecretsStore` returns an Effect-native admin client whose `create`,
184
- `update`, `duplicate`, `delete`, `list`, and `get` operations are Effects.
185
-
186
- `withExecutionContext` supports lower-level tests that need a native
187
- `ExecutionContext`. Workflow introspectors and all of their modifiers and query
188
- operations are Effect-native. They are disposed with the current Effect scope,
189
- and modifier callbacks carry the caller's Effect context and typed failures.
190
-
191
- ```ts
192
- it.effect("controls a Workflow instance", () =>
193
- Effect.scoped(
194
- Effect.gen(function* () {
195
- const instanceId = crypto.randomUUID();
196
- const instance = yield* PoolWorkers.introspectWorkflowInstance(
197
- env.REPORT_WORKFLOW,
198
- instanceId,
199
- );
200
-
201
- yield* instance.modify((modifier) =>
202
- Effect.gen(function* () {
203
- yield* modifier.disableSleeps();
204
- yield* modifier.mockStepResult({ name: "render-report" }, "reports/test.json");
205
- }),
206
- );
207
- yield* Effect.promise(() =>
208
- env.REPORT_WORKFLOW.create({
209
- id: instanceId,
210
- params: { reportId: "test", requestedBy: "dan@example.com" },
211
- }),
212
- );
213
-
214
- yield* instance.waitForStatus("complete");
215
- assert.deepStrictEqual(yield* instance.getOutput, {
216
- objectKey: "reports/test.json",
217
- notified: true,
218
- });
219
- }),
220
- ),
221
- );
222
- ```
223
-
224
- Use `introspectWorkflow` when the instance ID is not known before creation. Its
225
- `modifyAll` and `get` operations are Effects, and each instance returned by
226
- `get` uses the same Effect-native interface shown above.
227
-
228
- ## Worker Example
11
+ ## Worker
229
12
 
230
13
  ```ts
231
14
  import { Effect, Layer } from "effect";
232
- import { HttpServerResponse } from "effect/unstable/http";
233
15
  import { Worker } from "effect-cf";
234
16
 
235
- export default Worker.make(Layer.empty, Effect.succeed(HttpServerResponse.text("ok")));
236
- ```
237
-
238
- ## Durable Object Example
239
-
240
- ```ts
241
- import { Effect, Layer, Schema as S } from "effect";
242
- import { DurableObject, DurableObjectState } from "effect-cf";
243
-
244
- export class Counter extends DurableObject.Tag<Counter>()("Counter", {
245
- get: DurableObject.method({ success: S.Number }),
246
- }) {}
247
-
248
- export const CounterDurableObject = Counter.make(Layer.empty, {
249
- rpc: {
250
- get: () =>
251
- Effect.gen(function* () {
252
- const state = yield* DurableObjectState.DurableObjectState;
253
- const row = yield* (yield* state.storage.sql.exec<{ count: number }>(
254
- "SELECT 0 AS count",
255
- )).one();
256
- return row.count;
257
- }),
258
- },
259
- });
260
-
261
- export const CounterLayer = Counter.layer({ binding: "COUNTER" });
262
-
263
- export const readCounter = Effect.gen(function* () {
264
- const counters = yield* Counter;
265
- return yield* counters.byName("home").get();
17
+ export default Worker.make(Layer.empty, {
18
+ fetch: Effect.sync(() => new Response("Hello")),
266
19
  });
267
20
  ```
268
21
 
269
- Define Wrangler bindings and migrations in the consuming application. Durable Object namespace bindings are provided with `YourObject.layer({ binding })`, and consumers use `const namespace = yield* YourObject`.
270
-
271
- `DurableObjectState.waitUntil` accepts either a raw Promise or an Effect. The
272
- Effect form runs in the background with the caller's Effect context and the
273
- same failure modes as `WorkerContext.waitUntil`, so Durable Objects can
274
- schedule background Effects (for example a pump consuming an outbound
275
- WebSocket) without capturing a Context and calling `Effect.runPromiseWith`.
22
+ `Worker.make` owns the Effect runtime. Pass application services as its layer; use `Worker.NativeRequest` inside the handler to read the request.
276
23
 
277
- `DurableObjectState.abort(reason, { retryAlarm: false })` resets the current
278
- Durable Object without retrying the alarm that triggered the reset. The option
279
- has no effect outside an alarm handler and defaults to retrying the alarm.
24
+ ## Bindings
280
25
 
281
- ## Container Example
282
-
283
- Container entrypoints remain owned by `@cloudflare/containers`; `effect-cf`
284
- wraps the Container namespace used by a calling Worker.
26
+ Define a service, connect it to a Wrangler binding, then yield it in your program.
285
27
 
286
28
  ```ts
287
- import { Container, switchPort } from "@cloudflare/containers";
288
- import { Effect, Layer } from "effect";
289
- import { ContainerNamespace, Worker } from "effect-cf";
290
-
291
- export class RendererContainer extends Container {
292
- defaultPort = 8080;
293
- sleepAfter = "10m";
294
- }
295
-
296
- class Renderers extends ContainerNamespace.Tag<Renderers>()("Renderers") {}
297
-
298
- const RenderersLive = Renderers.layer({ binding: "RENDERERS" });
29
+ import { Effect, Schema } from "effect";
30
+ import { Kv } from "effect-cf";
299
31
 
300
- export default Worker.make(RenderersLive, {
301
- fetch: Effect.gen(function* () {
302
- const request = yield* Worker.NativeRequest;
303
- const renderer = Renderers.byName(new URL(request.url).pathname);
304
-
305
- yield* renderer.startAndWaitForPorts({ ports: 8080 });
306
- return yield* renderer.fetch(switchPort(request, 8080));
307
- }),
308
- });
309
- ```
310
-
311
- The instance client exposes `state`, `fetch`, `start`,
312
- `startAndWaitForPorts`, `waitForPort`, `stop` (named or numeric signals),
313
- `destroy`, the runtime host-policy operations (`setAllowedHosts`,
314
- `setDeniedHosts`, `allowHost`, `denyHost`, `removeAllowedHost`,
315
- `removeDeniedHost`), and the Effect-valued native `rawUnsafe` stub.
316
- Host-policy operations only forward the native remote calls; applications
317
- continue to own hostname policy and outbound handlers.
318
- Responses are returned unchanged, including non-2xx and WebSocket upgrade
319
- responses. If the Container uses outbound interception, also export
320
- `ContainerProxy` from `@cloudflare/containers`.
321
-
322
- `ContainerNamespace.Tag` accepts an optional second type parameter carrying
323
- the exact native namespace type. `rawUnsafe` on the namespace and on named
324
- instances then preserves that exact type, including extra subclass methods,
325
- instead of the minimal structural shape:
326
-
327
- ```ts
328
- class Sandboxes extends ContainerNamespace.Tag<Sandboxes, DurableObjectNamespace<CodexSandbox>>()(
329
- "Sandboxes",
330
- ) {}
331
-
332
- // Effect<DurableObjectStub<CodexSandbox>, ContainerOperationError, Sandboxes>
333
- const stub = Sandboxes.byName("codex").rawUnsafe;
334
- ```
335
-
336
- See [`examples/containers/README.md`](../../examples/containers/README.md) for
337
- the corresponding Wrangler configuration and entrypoint responsibilities.
338
-
339
- ## Sandbox Example
340
-
341
- `effect-cf/sandbox` wraps the [Cloudflare Sandbox SDK](https://github.com/cloudflare/sandbox-sdk)
342
- 1.0 API (`@cloudflare/sandbox@next`). The Sandbox Durable Object class remains
343
- owned by `@cloudflare/sandbox`; `effect-cf` wraps the namespace binding used by
344
- a calling Worker and loads the SDK lazily, so applications without the optional
345
- peer are unaffected.
346
-
347
- ```ts
348
- import { Effect, Option } from "effect";
349
- import { Worker } from "effect-cf";
350
- import * as Sandbox from "effect-cf/sandbox";
351
-
352
- export { Sandbox as SandboxDurableObject } from "@cloudflare/sandbox";
353
-
354
- class Sandboxes extends Sandbox.Tag<Sandboxes>()("Sandboxes") {}
355
-
356
- const SandboxesLive = Sandboxes.layer({ binding: "Sandbox" });
357
-
358
- export default Worker.make(SandboxesLive, {
359
- fetch: Effect.gen(function* () {
360
- const request = yield* Worker.NativeRequest;
361
-
362
- // Route preview-URL traffic before any other routing.
363
- const proxied = yield* Sandbox.proxyToSandbox(request);
364
- if (Option.isSome(proxied)) {
365
- return proxied.value;
366
- }
367
-
368
- const sandbox = yield* Sandboxes.get("my-sandbox");
369
- const handle = yield* sandbox.exec(["python3", "-c", "print(2 + 2)"]);
370
- const output = yield* handle.outputText();
371
-
372
- return new Response(output.stdout);
373
- }),
374
- });
375
- ```
376
-
377
- The instance client mirrors the complete `ISandbox` client contract plus the
378
- lifecycle, port, tunnel, and backup operations the SDK client proxies to the
379
- Sandbox Durable Object. `exec` returns a process handle whose `logs` is an
380
- Effect `Stream` of typed log events; `watch` decodes the SDK's Server-Sent
381
- Events into a `Stream` of file-watch events; `readFileStream` streams raw
382
- bytes. Interruptible waits (`waitForExit`, `waitForLog`, `output`,
383
- `waitForPort`) abort the underlying SDK call when the Effect is interrupted.
384
- Failures carry a typed `SandboxOperationError` whose `cause` preserves the
385
- SDK's rich error classes (`ProcessNotFoundError`, `PortNotExposedError`, and
386
- friends) for inspection.
387
-
388
- The corresponding `wrangler.jsonc` declares the container image and Durable
389
- Object binding; note the SDK requires the exported Durable Object class to be
390
- reachable under the configured `class_name`:
391
-
392
- ```jsonc
393
- {
394
- "containers": [
395
- {
396
- "class_name": "SandboxDurableObject",
397
- "image": "./Dockerfile",
398
- "instance_type": "lite",
399
- },
400
- ],
401
- "durable_objects": {
402
- "bindings": [{ "class_name": "SandboxDurableObject", "name": "Sandbox" }],
403
- },
404
- "migrations": [{ "tag": "v1", "new_sqlite_classes": ["SandboxDurableObject"] }],
405
- }
406
- ```
407
-
408
- ## Queue Example
409
-
410
- Queues define the message contract once. The same class is the producer service tag and the consumer Worker definition: use `YourQueue.layer({ binding })` to provide a Cloudflare producer binding from `WorkerEnvironment`, and use `.make(...)` for the consumer Worker entrypoint.
411
-
412
- ```ts
413
- import { Effect, Layer, Schema as S } from "effect";
414
- import { Queue } from "effect-cf";
415
-
416
- class AvatarQueue extends Queue.Tag<AvatarQueue>()("AvatarQueue", {
417
- message: S.Struct({ userId: S.String, imageKey: S.String }),
32
+ class Settings extends Kv.Tag<Settings>()("Settings", {
33
+ key: Schema.String,
34
+ value: Schema.String,
418
35
  }) {}
419
36
 
420
- export const AvatarQueueConsumer = AvatarQueue.make(Layer.empty, {
421
- queue: (batch) =>
422
- Effect.gen(function* () {
423
- for (const message of batch.messages) {
424
- yield* Effect.logInfo("process avatar", message.body.userId);
425
- yield* message.ack;
426
- }
427
- }),
428
- });
429
-
430
- export const AvatarQueueLayer = AvatarQueue.layer({ binding: "AVATAR_QUEUE" });
431
-
432
- export const enqueueAvatar = (userId: string, imageKey: string) =>
433
- Effect.gen(function* () {
434
- const queue = yield* AvatarQueue;
435
- yield* queue.send({ userId, imageKey });
436
- });
437
- ```
438
-
439
- Producers should usually use `const queue = yield* AvatarQueue` and then call `queue.send(...)`, `queue.sendBatch(...)`, or `queue.metrics()`. The static `AvatarQueue.send(...)` helpers remain available for concise one-off calls.
440
-
441
- Queue handlers run inline failures through Cloudflare's normal retry path. If background work scheduled with `WorkerContext.waitUntil(...)` should also make the batch retry, use `WorkerContext.waitUntilPropagating(...)` or `waitUntil(..., { mode: "propagate" })`; the default `waitUntil` mode observes and logs failures without rejecting the native `waitUntil` promise.
442
-
443
- effect-cf automatically schedules best-effort OTLP flushes only for Worker
444
- fetch/RPC and Durable Object alarm/RPC handlers. Those internal flushes are
445
- capped at two seconds. Queue handlers do not flush telemetry automatically;
446
- applications that explicitly flush in a queue handler own that flush's timeout
447
- and failure policy.
448
-
449
- ## Cache Example
450
-
451
- `Cache.layer` exposes Cloudflare's global Cache API as an Effect service. Cache misses are represented by `Option.none()`, and named caches are available through `open(...)`.
452
-
453
- ```ts
454
- import { Effect, Option } from "effect";
455
- import { Cache, Worker } from "effect-cf";
456
-
457
- export default Worker.make(Cache.layer, {
458
- fetch: Effect.gen(function* () {
459
- const request = yield* Worker.NativeRequest;
460
- const storage = yield* Cache.CacheStorage;
461
- const cached = yield* storage.default.match(request);
462
-
463
- if (Option.isSome(cached)) {
464
- return cached.value;
465
- }
466
-
467
- const response = new Response("fresh", {
468
- headers: { "Cache-Control": "public, max-age=300" },
469
- });
470
-
471
- const context = yield* Worker.WorkerContext;
472
- yield* context.waitUntil(storage.default.put(request, response.clone()));
37
+ const SettingsLive = Settings.layer({ binding: "SETTINGS" });
473
38
 
474
- return response;
475
- }),
476
- });
477
- ```
478
-
479
- Use `const cache = yield* storage.open("api-cache")` for a named cache. `match`, `put`, `delete`, and `open` failures are reported as `CacheOperationError` values.
480
-
481
- ## R2 Example
482
-
483
- R2 bucket tags expose Cloudflare object operations as Effects and map nullable reads to `Option`.
484
-
485
- ```ts
486
- import { Effect, Layer, Option } from "effect";
487
- import { R2 } from "effect-cf";
488
-
489
- class ArtifactBucket extends R2.Tag<ArtifactBucket>()("ArtifactBucket") {}
490
-
491
- export const ArtifactBucketLayer = ArtifactBucket.layer({ binding: "ARTIFACT_BUCKET" });
492
-
493
- export const writeArtifact = (key: string, body: string) =>
494
- Effect.gen(function* () {
495
- const bucket = yield* ArtifactBucket;
496
- yield* bucket.put(key, body, {
497
- httpMetadata: { contentType: "application/json" },
498
- });
499
- });
500
-
501
- export const readArtifact = (key: string) =>
502
- Effect.gen(function* () {
503
- const bucket = yield* ArtifactBucket;
504
- const object = yield* bucket.get(key);
505
- return Option.isSome(object) ? yield* Effect.promise(() => object.value.text()) : undefined;
506
- });
507
- ```
508
-
509
- Use `createMultipartUpload(...)` or `resumeMultipartUpload(...)` for large objects; returned upload handles wrap `uploadPart`, `complete`, and `abort` in Effect.
510
-
511
- ## Artifacts Example
512
-
513
- Artifacts tags expose every namespace and repository-handle binding operation as an Effect. Configure the binding under Wrangler's `artifacts` field:
514
-
515
- ```jsonc
516
- {
517
- "artifacts": [
518
- {
519
- "binding": "ARTIFACTS",
520
- "namespace": "default",
521
- // Optional in local development:
522
- "remote": true,
523
- },
524
- ],
525
- }
526
- ```
527
-
528
- ```ts
529
- import { Effect } from "effect";
530
- import { Artifacts } from "effect-cf";
531
-
532
- class Repositories extends Artifacts.Tag<Repositories>()("Repositories") {}
39
+ const greeting = Effect.gen(function* () {
40
+ const settings = yield* Settings;
533
41
 
534
- export const RepositoriesLayer = Repositories.layer({ binding: "ARTIFACTS" });
535
-
536
- export const createWorkspace = Effect.gen(function* () {
537
- const artifacts = yield* Repositories;
538
- const created = yield* artifacts.create("agent-workspace", {
539
- description: "Versioned agent workspace",
540
- setDefaultBranch: "main",
541
- });
542
- const repo = yield* artifacts.get(created.name);
543
- const readToken = yield* repo.createToken("read", 3600);
544
-
545
- return {
546
- remote: created.remote,
547
- token: readToken.plaintext,
548
- history: yield* repo.log({ ref: "main", limit: 10 }),
549
- };
42
+ return yield* settings.get("greeting");
550
43
  });
551
44
  ```
552
45
 
553
- `create`, `import`, and `fork` return an initial plaintext token. Treat returned tokens as secrets and avoid logging or persisting them unnecessarily.
554
-
555
- ## Computer Workspace Example
556
-
557
- `ComputerWorkspace` exposes the complete `@cloudflare/computer`
558
- `WorkspaceClient` as Effects. The workspace client and every runtime execution
559
- handle are owned by an Effect `Scope`, so RPC stubs are disposed when their
560
- scope closes. The host mixin is a separate optional-dependency entrypoint:
561
-
562
- ```ts
563
- import shellCurl from "@cloudflare/computer/shell/curl";
564
- import { Effect, Schema as S } from "effect";
565
- import { DurableObjectDefinition } from "effect-cf";
566
- import * as ComputerWorkspace from "effect-cf/computer-workspace";
567
- import { withComputerWorkspace, WorkspaceServiceProxy } from "effect-cf/computer-workspace-host";
46
+ Declare `SETTINGS` in `wrangler.jsonc` and pass `SettingsLive` to `Worker.make`. Other bindings use the same tag/layer pattern.
568
47
 
569
- export { WorkspaceServiceProxy };
48
+ The [counter example](https://github.com/danieljvdm/effect-cf/tree/main/examples/counter) shows `DurableObject.Tag`, storage, a typed RPC call, and the complete Wrangler configuration.
570
49
 
571
- const ResearchWorkspace = DurableObjectDefinition.make("ResearchWorkspace", {
572
- inspect: DurableObjectDefinition.method({ success: S.String }),
573
- });
50
+ ## Native RPC tracing
574
51
 
575
- const ResearchWorkspaceLive = ResearchWorkspace.make(ComputerWorkspace.ComputerWorkspace.layer, {
576
- rpc: {
577
- inspect: () =>
578
- Effect.gen(function* () {
579
- const workspace = yield* ComputerWorkspace.ComputerWorkspace;
52
+ `call()`, `scopedCall()`, and definition methods create one CLIENT span named `binding/method`, covering argument encoding, the native RPC wait, and success decoding. Raw `rpc()` retains Cloudflare's pipelined result without creating a span. Wrap its complete lifetime with `RpcTracing.withRpcClientSpan` when tracing raw calls.
580
53
 
581
- // Keep reads bounded when returning their contents over RPC.
582
- const readme = yield* workspace.readFile("/repo/README.md", {
583
- byteLength: 256 * 1024,
584
- });
585
- const commits = yield* workspace.git.log({ maxCount: 20 });
586
- const run = yield* workspace.execCollect("git status --short", {
587
- cwd: "/repo",
588
- timeoutMillis: 30_000,
589
- });
54
+ Live parent propagation requires both `Contract.layer({ binding: "COUNTERS", rpcTracing: true })` on the client and `rpcTracing: { service: "COUNTERS" }` in the receiver's `make` options. Namespace and service binding clients accept the same boolean option. Enable receivers before clients. Disabled clients preserve argument lists exactly; receivers strip only a valid trailing `effect-cf/RpcTraceContext/v1` argument after opting in. Opting in reserves that complete tagged shape in the final argument position, so do not use it there as a domain argument, including during receiver-first rollout.
590
55
 
591
- return `${commits.length}:${run.exitCode}:${readme.length}`;
592
- }),
593
- },
594
- });
56
+ Applications own SERVER spans. Override the exported `DurableObject.RunSymbol` or `Worker.RunSymbol`, wrap the effect with `RpcTracing.withRpcServerSpan(effect, options.rpc)` when `options.rpc` exists, and call `super` with the original options. See the [typed receiver example](https://github.com/danieljvdm/effect-cf/blob/main/packages/effect-cf/tests/fixtures/durable-object-consumer.ts). The receiver installs the validated parent before instrumentation and event-layer setup. No additional layer input is required.
595
57
 
596
- const HostedResearchWorkspace = withComputerWorkspace(ResearchWorkspaceLive, (_, state, env) => ({
597
- storage: state.storage,
598
- sessionId: state.id.toString(),
599
- gitIdentity: { name: "Research Agent", email: "agent@example.test" },
600
- artifacts: { binding: env.ARTIFACTS },
601
- shell: {
602
- loader: env.WORKER_LOADER,
603
- hostBinding: "RESEARCH_WORKSPACE",
604
- hostId: state.id.toString(),
605
- executionContext: state,
606
- commands: [shellCurl],
607
- egress: { mode: "none" },
608
- },
609
- }));
610
-
611
- export class ResearchWorkspaceDurableObject extends HostedResearchWorkspace {}
612
- ```
58
+ `RunOptions.event` identifies the native event before work starts. `RunOptions.rpc` includes `service`, `method`, native `args`, and the validated `parent`. Its `decodedArgs` becomes available after definition decoding succeeds, before the handler runs. Never log these arguments. Span helpers record stable RPC attributes and failure status without error payloads; original typed failures still reach the caller.
613
59
 
614
- Serialize checkout mutations such as `clone`, `fetch`, and reset/checkout
615
- sequences with an Effect `Semaphore` when several RPC methods can update the
616
- same working tree concurrently. Reads can remain concurrent.
60
+ This metadata belongs only to the live native call. Do not store it in domain envelopes, alarms, queues, or WebSocket attachments, or reuse it for resumed work. Sampling and exporter configuration remain application choices.
617
61
 
618
- The corresponding Wrangler configuration needs SQLite-backed Durable Object
619
- storage. Worker-shell execution additionally requires the `experimental` flag,
620
- a Worker Loader binding, the loopback namespace binding named by `hostBinding`,
621
- and the exact `WorkspaceServiceProxy` export shown above:
622
-
623
- ```jsonc
624
- {
625
- "compatibility_flags": ["nodejs_compat", "experimental"],
626
- "worker_loaders": [{ "binding": "WORKER_LOADER" }],
627
- "artifacts": [{ "binding": "ARTIFACTS", "namespace": "default" }],
628
- "durable_objects": {
629
- "bindings": [
630
- {
631
- "name": "RESEARCH_WORKSPACE",
632
- "class_name": "ResearchWorkspaceDurableObject",
633
- },
634
- ],
635
- },
636
- "migrations": [
637
- {
638
- "tag": "v1",
639
- "new_sqlite_classes": ["ResearchWorkspaceDurableObject"],
640
- },
641
- ],
642
- }
643
- ```
644
-
645
- Omit `shell` and the Worker Loader configuration when only the SQLite
646
- filesystem and Git client are needed. Supplying `artifacts` also installs the
647
- session-scoped client at `workspace.artifacts` and makes the in-shell
648
- `artifacts` command use the same upstream repository-prefixing convention.
649
-
650
- Workers can use that session isolation without a Durable Object or workspace:
651
-
652
- ```ts
653
- import { Effect } from "effect";
654
- import { Artifacts } from "effect-cf";
655
- import * as ComputerArtifacts from "effect-cf/computer-artifacts";
656
-
657
- export const createSessionRepo = (binding: Artifacts.ArtifactsBinding, sessionId: string) =>
658
- Effect.gen(function* () {
659
- const artifacts = yield* ComputerArtifacts.makeClient(binding, sessionId);
660
- const created = yield* artifacts.create("build-cache", {
661
- description: "CI artifacts",
662
- });
663
- const token = yield* artifacts.createToken("build-cache", "read", 3600);
664
-
665
- return { remote: created.remote, token: token.plaintext };
666
- });
667
- ```
668
-
669
- Repository prefixing, list filtering, and the argv API are delegated directly
670
- to `@cloudflare/computer/artifacts`, keeping this API and the in-shell command
671
- in agreement.
672
-
673
- ## Hyperdrive Example
674
-
675
- Hyperdrive tags expose the binding `connectionString` directly on the yielded service.
676
-
677
- ```ts
678
- import { Effect } from "effect";
679
- import { Hyperdrive } from "effect-cf";
680
-
681
- class AppDatabase extends Hyperdrive.Tag<AppDatabase>()("AppDatabase") {}
682
-
683
- export const AppDatabaseLayer = AppDatabase.layer({ binding: "HYPERDRIVE" });
684
-
685
- export const databaseUrl = Effect.gen(function* () {
686
- const hyperdrive = yield* AppDatabase;
687
- return hyperdrive.connectionString;
688
- });
689
- ```
690
-
691
- If your Worker uses Postgres via `@effect/sql-pg`, install that driver and use the optional subpath integration:
692
-
693
- ```ts
694
- import { Hyperdrive } from "effect-cf";
695
- import * as HyperdrivePg from "effect-cf/hyperdrive-pg";
696
-
697
- class AppDatabase extends Hyperdrive.Tag<AppDatabase>()("AppDatabase") {}
698
-
699
- export const SqlLive = HyperdrivePg.layer(AppDatabase, { binding: "HYPERDRIVE" });
700
- ```
701
-
702
- The Postgres integration builds an Effect `PgClient` from Hyperdrive's generated connection string with `PgClient.makeClient`. It intentionally does not expose app-side pool options; Hyperdrive manages the underlying database pool.
703
-
704
- ## Images Example
705
-
706
- Images tags expose `info`, `input`, optional hosted image operations, and composable transform/draw steps.
707
-
708
- ```ts
709
- import { Effect } from "effect";
710
- import { Images } from "effect-cf";
711
-
712
- class AvatarImages extends Images.Tag<AvatarImages>()("AvatarImages") {}
713
-
714
- export const AvatarImagesLayer = AvatarImages.layer({ binding: "IMAGES" });
715
-
716
- export const resizeAvatar = (image: Images.ImageInputValue) =>
717
- Effect.gen(function* () {
718
- const images = yield* AvatarImages;
719
- const result = yield* images.process(
720
- Images.transform(Images.empty, { width: 256, height: 256 }),
721
- {
722
- stream: image,
723
- outputOptions: { format: "image/webp" },
724
- },
725
- );
726
-
727
- return yield* result.response();
728
- });
729
- ```
730
-
731
- ## Cloudflare runtime pin
732
-
733
- `effect-cf` develops and tests against a fixed Cloudflare Workers runtime stack. Keep consumer
734
- `compatibility_date` and optional `@cloudflare/workers-types` peers at or above this pin:
735
-
736
- | Pin | Version |
737
- | ------------------------------------------- | -------------------- |
738
- | workerd | `1.20260825.1` |
739
- | `@cloudflare/workers-types` (optional peer) | `5.20260825.1` |
740
- | miniflare (repo override / tests) | `5.20260825.0-alpha` |
741
- | wrangler (repo catalog / examples) | `4.126.0` |
742
- | Recommended `compatibility_date` | `2026-08-25` |
743
-
744
- The date in the `workerd` / `@cloudflare/workers-types` version is the API surface this package wraps.
745
- Repo catalog pins live in the root `package.json` `catalog` field (`@cloudflare/workers-types`, `wrangler`).
746
- `wrangler@4.126.0` pulls `workerd@1.20260825.1` and peers `@cloudflare/workers-types@^5.20260825.1`.
747
- The Miniflare and Wrangler overrides keep worker tests on the same runtime when
748
- `@cloudflare/vitest-plugin` lags the repo catalog.
749
-
750
- ## Email Example
751
-
752
- Email tags expose Cloudflare Email Service `send_email` bindings as Effect-wrapped `send(...)` operations. Structured messages are validated against documented Email Sending limits before reaching the binding, and Cloudflare's `E_*` error codes are surfaced on `EmailOperationError.code`.
753
-
754
- ```ts
755
- import { Effect } from "effect";
756
- import { Email } from "effect-cf";
757
-
758
- class TransactionalEmail extends Email.Tag<TransactionalEmail>()("TransactionalEmail") {}
759
-
760
- export const TransactionalEmailLayer = TransactionalEmail.layer({ binding: "EMAIL" });
761
-
762
- export const sendWelcomeEmail = (to: string) =>
763
- Effect.gen(function* () {
764
- const email = yield* TransactionalEmail;
765
-
766
- return yield* email.send({
767
- from: { name: "Example", email: "team@example.com" },
768
- to,
769
- subject: "Welcome to Example",
770
- text: "Welcome to Example",
771
- html: "<p>Welcome to Example</p>",
772
- });
773
- });
774
- ```
775
-
776
- Onboard the sending domain under **Compute > Email Service > Email Sending**, then declare the binding in the consuming Worker's `wrangler.jsonc`:
777
-
778
- ```jsonc
779
- {
780
- "send_email": [{ "name": "EMAIL" }],
781
- }
782
- ```
783
-
784
- Messages that violate a documented limit fail with `EmailValidationError` carrying every violation, without calling the binding. Use `layer({ binding, send: { validate: false } })` to skip validation and let Cloudflare reject the message instead.
785
-
786
- Cloudflare's total message size limit is exposed as `Email.sendLimits.maxMessageBytes` but is not enforced, because the encoded MIME size is only known once Cloudflare composes the message. Oversized messages fail at the binding with `E_CONTENT_TOO_LARGE`.
787
-
788
- See [`examples/email/README.md`](../../examples/email/README.md) for domain onboarding, binding restrictions, and typed recovery from `EmailValidationError` and `EmailOperationError`.
789
-
790
- ## Analytics Engine Example
791
-
792
- Analytics Engine tags expose Cloudflare dataset bindings as Effect-wrapped `writeDataPoint(...)` operations. Writes use Cloudflare's native non-blocking runtime API and are validated against Workers Analytics Engine limits before reaching the binding.
793
-
794
- ```ts
795
- import { Effect } from "effect";
796
- import { AnalyticsEngine } from "effect-cf";
797
-
798
- class RequestAnalytics extends AnalyticsEngine.Tag<RequestAnalytics>()("RequestAnalytics") {}
799
-
800
- export const RequestAnalyticsLayer = RequestAnalytics.layer({
801
- binding: "REQUEST_ANALYTICS",
802
- write: {
803
- onInvalid: "error",
804
- },
805
- });
806
-
807
- export const recordPageView = (request: Request) =>
808
- Effect.gen(function* () {
809
- const analytics = yield* RequestAnalytics;
810
- const url = new URL(request.url);
811
-
812
- yield* analytics.writeDataPoint({
813
- indexes: [url.hostname],
814
- blobs: [url.pathname, request.headers.get("cf-connecting-country") ?? "unknown"],
815
- doubles: [1],
816
- });
817
-
818
- yield* analytics.writeDataPoints(
819
- [
820
- {
821
- indexes: [url.hostname],
822
- blobs: [url.pathname, "page-view"],
823
- doubles: [1],
824
- },
825
- ],
826
- { onInvalid: "drop" },
827
- );
828
- });
829
- ```
830
-
831
- Define the dataset binding in the consuming Worker's `wrangler.jsonc` with `analytics_engine_datasets`, then provide `RequestAnalytics.layer({ binding: "REQUEST_ANALYTICS" })` from the Worker layer. Invalid writes fail with `AnalyticsEngineWriteValidationError` by default. Use `write: { onInvalid: "drop" }` on the layer or `{ onInvalid: "drop" }` per call when dropping invalid points is preferable.
832
-
833
- Analytics Engine query tags wrap Cloudflare's HTTP SQL API. Configuration can stay in Effect `Config`, including a redacted API token, the outbound transport is an Effect `HttpClient` dependency, and rows can be decoded with Effect schemas at the query boundary. Use `layerFetchConfig(...)` as shorthand when the platform fetch-backed client is enough.
834
-
835
- ```ts
836
- import { Effect, Layer, Schema as S } from "effect";
837
- import { FetchHttpClient } from "effect/unstable/http";
838
- import { AnalyticsEngine, WorkerConfig } from "effect-cf";
839
-
840
- class AnalyticsQuery extends AnalyticsEngine.QueryTag<AnalyticsQuery>()("AnalyticsQuery") {}
841
-
842
- export const AnalyticsQueryLayer = AnalyticsQuery.layerConfig(
843
- AnalyticsEngine.queryConfig({
844
- accountId: WorkerConfig.string("CLOUDFLARE_ACCOUNT_ID"),
845
- apiToken: WorkerConfig.redacted("CLOUDFLARE_API_TOKEN"),
846
- }),
847
- ).pipe(Layer.provide(FetchHttpClient.layer));
848
-
849
- const PageView = S.Struct({
850
- path: S.String,
851
- views: S.Number,
852
- });
853
-
854
- export const topPages = Effect.gen(function* () {
855
- const analytics = yield* AnalyticsQuery;
856
-
857
- return yield* analytics.queryRows(
858
- PageView,
859
- `
860
- SELECT blob1 AS path, SUM(_sample_interval) AS views
861
- FROM request_metrics
862
- GROUP BY path
863
- ORDER BY views DESC
864
- LIMIT 10
865
- `,
866
- );
867
- });
868
- ```
869
-
870
- ## Workflow Example
871
-
872
- Workflow definitions type the payload and result. Runtime handlers can access `Workflow.WorkflowEvent`, use durable `Workflow.step(...)`, and use normal binding services inside steps.
873
-
874
- ```ts
875
- import { Effect, Layer, Schema as S } from "effect";
876
- import { Workflow } from "effect-cf";
877
-
878
- class ExportWorkflow extends Workflow.Tag<ExportWorkflow>()("ExportWorkflow", {
879
- payload: S.Struct({ segmentId: S.String }),
880
- result: S.Struct({ objectKey: S.String }),
881
- }) {}
882
-
883
- export const ExportWorkflowEntrypoint = ExportWorkflow.make(Layer.empty, {
884
- run: (payload) =>
885
- Effect.gen(function* () {
886
- const objectKey = yield* Workflow.step(
887
- "write-export",
888
- Effect.succeed(`exports/${payload.segmentId}.json`),
889
- );
890
-
891
- return { objectKey };
892
- }),
893
- });
894
-
895
- export const ExportWorkflowLayer = ExportWorkflow.layer({ binding: "EXPORT_WORKFLOW" });
896
- ```
897
-
898
- Provide `ExportWorkflow.layer({ binding: "EXPORT_WORKFLOW" })`, then use `const workflow = yield* ExportWorkflow` or the static `ExportWorkflow.create(...)`, `createBatch(...)`, and `get(...)` helpers to start and inspect instances.
899
-
900
- In definition-backed workflows, the `payload` argument is the typed decoded payload and is the source of truth. `Workflow.WorkflowEvent.payload` is also re-provided decoded for convenience; `Workflow.WorkflowEvent.raw.payload` remains the native Cloudflare event payload.
901
-
902
- ## Durable Object WebSockets
903
-
904
- Use `initialize` for work that should run each time Cloudflare loads a Durable
905
- Object instance into memory. Yield `state.blockConcurrencyWhile(...)` when
906
- later events should wait for initialize to finish. If work should happen only once
907
- for a Durable Object id, store a sentinel in Durable Object storage.
908
-
909
- ```ts
910
- export const RoomLive = DurableObject.make(layer, {
911
- initialize: Effect.gen(function* () {
912
- const state = yield* DurableObjectState.DurableObjectState;
913
- yield* state.blockConcurrencyWhile(
914
- Effect.gen(function* () {
915
- yield* state.storage.put("loadedAt", Date.now());
916
- }),
917
- );
918
- }),
919
- fetch,
920
- });
921
- ```
922
-
923
- Durable Object application sockets should use the hibernation-compatible state API. Accept sockets with `DurableObjectWebSocket.acceptUpgrade(...)`; do not call `server.accept()` or attach native `message` listeners in application code.
924
-
925
- ```ts
926
- import { Effect, Schema as S } from "effect";
927
- import { DurableObject, DurableObjectState, DurableObjectWebSocket, Worker } from "effect-cf";
928
-
929
- const ConnectionAttachment = S.Struct({
930
- id: S.String,
931
- roomId: S.String,
932
- });
933
-
934
- const Attachments = DurableObjectWebSocket.attachment(ConnectionAttachment);
935
-
936
- export const fetch = Effect.gen(function* () {
937
- const request = yield* Worker.NativeRequest;
938
-
939
- if (!Worker.isWebSocketUpgrade(request)) {
940
- return new Response("Expected WebSocket upgrade", { status: 426 });
941
- }
942
-
943
- const upgrade = yield* DurableObjectWebSocket.acceptUpgrade({ tags: ["room:general"] });
944
- yield* Attachments.serialize(upgrade.server, {
945
- id: crypto.randomUUID(),
946
- roomId: "general",
947
- });
948
- yield* upgrade.server.send(JSON.stringify({ type: "ready" }));
949
-
950
- return upgrade.response;
951
- });
952
- ```
953
-
954
- `DurableWebSocket` keeps the native socket available as `socket.raw`, while `send`, `close`, `serializeAttachment`, and `deserializeAttachment` return typed `Effect` failures. Use `state.getWebSockets(tag)` to retrieve wrapped sockets for broadcast and rehydration.
955
-
956
- `DurableObject.make` lifecycle handlers receive wrapped sockets automatically:
957
-
958
- ```ts
959
- export const RoomLive = DurableObject.make(layer, {
960
- webSocketMessage: (socket, message) =>
961
- Effect.gen(function* () {
962
- yield* socket.send(message);
963
- }),
964
- });
965
- ```
966
-
967
- Schema-backed attachments can rehydrate hibernated sockets:
968
-
969
- ```text
970
- const restored = yield* Attachments.rehydrate({
971
- tag: "room:general",
972
- onInvalid: "ignore-and-close",
973
- });
974
-
975
- for (const { socket, attachment } of restored) {
976
- yield* socket.send(`restored:${attachment.id}`).pipe(Effect.ignore);
977
- }
978
- ```
979
-
980
- Worker-to-Durable-Object forwarding should stay native so WebSocket upgrade responses are preserved:
981
-
982
- ```text
983
- if (Worker.isWebSocketUpgrade(request)) {
984
- const rooms = yield* ChatRoom;
985
- return yield* rooms.byName(roomId).fetch(request);
986
- }
987
- ```
988
-
989
- ### Hibernation-aware Effect RPC WebSockets
990
-
991
- `DurableObjectRpcWebSocket.layer(...)` adapts Effect RPC to Cloudflare's hibernatable WebSocket API. Build the layer in the Durable Object runtime and forward all three WebSocket lifecycle events:
992
-
993
- ```ts
994
- import { Effect, Layer } from "effect";
995
- import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
996
- import { DurableObject, DurableObjectRpcWebSocket, Worker } from "effect-cf";
997
-
998
- const RpcLive = RpcServer.layer(MyRpcs).pipe(
999
- Layer.provideMerge(DurableObjectRpcWebSocket.layer({ tag: "my-rpc" })),
1000
- Layer.provide(MyRpcHandlers),
1001
- Layer.provide(RpcSerialization.layerJson),
1002
- );
1003
-
1004
- const MyObjectLive = DurableObject.make(RpcLive, {
1005
- fetch: Effect.gen(function* () {
1006
- const request = yield* Worker.NativeRequest;
1007
-
1008
- if (!Worker.isWebSocketUpgrade(request)) {
1009
- return new Response("Expected WebSocket upgrade", { status: 426 });
1010
- }
1011
-
1012
- const transport = yield* DurableObjectRpcWebSocket.DurableObjectRpcWebSocket;
1013
- const upgrade = yield* transport.acceptUpgrade({
1014
- tags: ["room:general"],
1015
- attachment: { roomId: "general" },
1016
- });
1017
-
1018
- return upgrade.response;
1019
- }),
1020
- webSocketMessage: (socket, message) =>
1021
- Effect.gen(function* () {
1022
- const transport = yield* DurableObjectRpcWebSocket.DurableObjectRpcWebSocket;
1023
-
1024
- yield* transport.message(socket, message);
1025
- }),
1026
- webSocketClose: (socket) =>
1027
- Effect.gen(function* () {
1028
- const transport = yield* DurableObjectRpcWebSocket.DurableObjectRpcWebSocket;
1029
-
1030
- yield* transport.close(socket);
1031
- }),
1032
- webSocketError: (socket, cause) =>
1033
- Effect.gen(function* () {
1034
- const transport = yield* DurableObjectRpcWebSocket.DurableObjectRpcWebSocket;
1035
-
1036
- yield* transport.error(socket, cause);
1037
- }),
1038
- });
1039
-
1040
- export class MyDurableObject extends MyObjectLive {}
1041
- ```
1042
-
1043
- `DurableObject.make` builds a new managed runtime whenever Cloudflare constructs or reconstructs the object. During that build, the RPC layer calls `getWebSockets(tag)` and registers tagged sockets before the triggering lifecycle handler runs. A completed finite call can therefore be followed by idle hibernation and another finite call on the same physical socket.
1044
-
1045
- This is a hibernation-aware RPC transport, not a durable RPC runtime. The adapter stores versioned state under the `attachmentKey` namespace, which defaults to `effectCloudflareRpcClientId`, and shallow-merges it with application-owned object fields. Its RPC tag is added alongside application tags. Application code that later replaces an attachment must preserve the reserved namespace; the adapter requires object-shaped attachments. Keep resume descriptors compact because Cloudflare caps the entire serialized attachment, including application fields, at 16,384 bytes. Legacy numeric client-ID metadata is migrated when read.
1046
-
1047
- Each non-notification request stays marked pending until its terminal `Exit` is sent or the request is interrupted. If a new constructor finds a pending non-resumable request, it quarantines the socket and closes it with WebSocket code `1012` before dispatching the event that woke the object. Effect's stock socket protocol reports this as an `RpcClientError` containing `SocketCloseError`, so the affected call or stream fails instead of hanging. The socket transport may reconnect for later calls, but the adapter never replays a non-resumable request. Retrying a mutation requires application-level idempotency or deduplication. Notifications are also not durable and have no delivery acknowledgement.
1048
-
1049
- The default `heartbeat: "auto-response"` policy installs Cloudflare's exact text Ping/Pong auto-response while at least one RPC socket exists and no non-resumable RPC operation is active. The stock Effect client's five-second Ping therefore does not run Durable Object JavaScript while the socket is idle or while an explicitly resumable subscription waits for data. The adapter creates no timer. A non-resumable call or stream removes the global auto-response so a Ping wakes a newly reconstructed object and triggers its pending-operation reset. Cloudflare provides only one auto-response pair per Durable Object, across every accepted socket. Use `"passthrough"` if unrelated WebSocket protocols share the same object.
1050
-
1051
- Use `heartbeat: "passthrough"` for a non-text serialization or when the application owns a different auto-response pair. That mode does not alter the pair; the application is responsible for ensuring a lost pending operation still wakes and resets. With the default policy, a conflicting application pair fails layer construction instead of being overwritten.
1052
-
1053
- Streams are non-resumable unless their RPC tag matches a declaration passed in `resumableStreams`. Constructor recreation resets an ordinary active stream with code `1012`.
1054
-
1055
- An explicitly resumable stream persists its original Effect request ID, RPC tag, application subscription key, compact resume descriptor, and last application checkpoint in the socket attachment. Each declaration supplies `resumeDescriptorSchema` and `checkpointSchema`; a new constructor validates both persisted values before it rebuilds anything. Invalid state resets that socket with code `1012` instead of reaching application reconstruction. Valid state becomes an encoded request written through the new `RpcServer.Protocol` with the original request ID. Effect runs request decoding, middleware, authorization, and the handler again. The physical socket stays open, so the stock Effect client routes later `Chunk` messages into the existing client `Stream`.
1056
-
1057
- This reconstructs a declared logical subscription. It does not restore the original Effect fiber, scope, stream, queue, or middleware context. The application must keep a durable event log and rebuild the stream after its cursor. Delivery is at least once. If hibernation lands after a chunk reaches the client but before its checkpoint is stored, the rebuilt handler emits that chunk again. Apply events idempotently by cursor, then call a finite checkpoint RPC. Retention, cursor expiry, snapshots, and resync remain application concerns. This transport does not provide exactly-once delivery or generic stream resumption.
1058
-
1059
- The declaration also gives the transport a stable token for each encoded chunk value. A cursor/token must identify one logical event for the full lifetime of that subscription. Distinct events must not share it; a replay of the same event reuses it. The adapter ignores stock request-ID-only stream `Ack` messages for resumable requests. It persists the matching explicit checkpoint before releasing the current server chunk latch, and an acknowledgement left over from an earlier activation cannot release a later chunk. If attachment serialization fails, `checkpoint` fails with `DurableWebSocketAttachmentError` and the transport resets the socket with code `1012`; it never releases or sends a chunk whose replay state was not stored. Interrupt and terminal `Exit` remove the persisted descriptor.
1060
-
1061
- The compiled [`ResumableEventLog.example.ts`](../../examples/todo-rpc-ws/durable-objects/todo-store/src/ResumableEventLog.example.ts) defines a standard `RpcGroup`, cursor-backed Durable Object SQL event log, `RpcServer.layer`, resumable declaration, checkpoint handler using `Rpc.ServerClient.id`, and complete constructor/event wiring. Its client-side ordering is the important part:
1062
-
1063
- ```ts
1064
- import { Effect, Stream } from "effect";
1065
-
1066
- client.SubscribeEvents({ subscriptionKey, topic, after }).pipe(
1067
- Stream.runForEach((event) =>
1068
- applyEventIfNew(event).pipe(
1069
- Effect.andThen(
1070
- client.CheckpointSubscription({
1071
- subscriptionKey,
1072
- cursor: event.cursor,
1073
- }),
1074
- ),
1075
- ),
1076
- ),
1077
- );
1078
- ```
1079
-
1080
- Checkpoint only after `applyEventIfNew` commits. A replayed cursor must make that function a no-op. `CheckpointSubscription` is an ordinary non-resumable finite RPC, so make it idempotent and safe for the client to retry. Map `DurableWebSocketAttachmentError` into that RPC's declared error channel. The checkpoint call must be able to run while stream handlers are active. Keep Effect's default unbounded RPC server concurrency, or set a bound above the maximum number of simultaneously open streams so checkpoint calls retain capacity. If the activation disappears after checkpoint persistence but before that RPC sends its `Exit`, the socket resets with `1012`. The client reconnects and starts a new subscription after its durably applied cursor. Register the same declaration on every constructor activation and keep its `id`, schemas, and checkpoint token semantics stable for stored attachments.
1081
-
1082
- `accept(socket, tags)` remains available for a manually created, not-yet-accepted server socket. Prefer `transport.acceptUpgrade(...)` for new upgrades so the socket is accepted exactly once. Use the general `DurableObjectWebSocket` module instead for rooms, presence, broadcasts, or other non-RPC protocols.
1083
-
1084
- ## WebTransport and HTTP/3
1085
-
1086
- Cloudflare Workers cannot accept inbound WebTransport sessions today: workerd contains no QUIC/HTTP-3 stack, there is no `WebSocketPair`-style session API, and the maintainers state the feature "is not currently on our priority list" ([cloudflare/workerd#6451](https://github.com/cloudflare/workerd/issues/6451), [discussion #6454](https://github.com/cloudflare/workerd/discussions/6454)). Inbound HTTP/3 is a zone-level setting — the edge terminates QUIC and the Worker still receives an ordinary `fetch` Request. Workers and Durable Objects can accept inbound WebSocket connections; Durable Objects additionally support the hibernation WebSocket API.
1087
-
1088
- The `WebTransport` module gives that boundary a typed shape rather than pretending otherwise:
1089
-
1090
- ```ts
1091
- import { Effect, Option } from "effect";
1092
- import { WebTransport } from "effect-cf";
1093
-
1094
- const fetch = Effect.fn("fetch")(function* (request: Request) {
1095
- // Decoded metadata about the browser→edge hop (HTTP/3 does not reach you).
1096
- const transport = WebTransport.inboundTransport(request);
1097
- const viaHttp3 = Option.match(transport, {
1098
- onNone: () => false,
1099
- onSome: WebTransport.isHttp3,
1100
- });
1101
-
1102
- // Feature-detected runtime capabilities: { inboundSessions: false, ... }.
1103
- const capabilities = yield* WebTransport.capabilities;
1104
-
1105
- // Explicit typed boundary where a future inbound session API would slot in.
1106
- // yield* WebTransport.inboundSessionsUnsupported;
1107
-
1108
- return Response.json({ viaHttp3, capabilities });
1109
- });
1110
- ```
62
+ ## API
1111
63
 
1112
- For clients that prefer WebTransport where it exists and fall back to Cloudflare-supported WebSockets, see the companion [`effect-webtransport`](../effect-webtransport) package's `Fallback` module — `examples/todo-rpc-ws` wires it up end to end.
64
+ See the [exports](src/index.ts) and [tests](https://github.com/danieljvdm/effect-cf/tree/main/packages/effect-cf/tests) for the remaining APIs.
1113
65
 
1114
- ## License
66
+ Optional integrations have separate imports: `effect-cf/hyperdrive-pg`, `effect-cf/computer-workspace`, `effect-cf/computer-artifacts`, `effect-cf/computer-workspace-host`, `effect-cf/sandbox`, and `effect-cf/vitest`. Install the matching SDK or driver listed in [peerDependencies](package.json). Computer Git operations also require `@platformatic/vfs`.
1115
67
 
1116
- MIT
68
+ [Changelog](CHANGELOG.md) · [MIT license](LICENSE)