Skip to content

feat(cloudflare): expose bucket client service#344

Open
wking-io wants to merge 3 commits into
alchemy-run:mainfrom
wking-io:codex/r2-bucket-client-service
Open

feat(cloudflare): expose bucket client service#344
wking-io wants to merge 3 commits into
alchemy-run:mainfrom
wking-io:codex/r2-bucket-client-service

Conversation

@wking-io
Copy link
Copy Markdown
Contributor

@wking-io wking-io commented May 14, 2026

Summary

  • add yieldable Cloudflare bound-client service tags for Cloudflare object bindings
  • add .layer(boundClient) helpers so application/service layers can receive an already-bound runtime client at the boundary
  • require callers to run .bind(resource) first, in Worker init, so deploy-time binding discovery cannot be hidden inside an unbuilt service layer
  • update source JSDoc examples to show both patterns: direct binding inside a Worker and providing a bound client to another Effect service

Context

I was making an app service (ImageStorage) that wanted to use R2 without depending on Cloudflare.R2Bucket.bind(Bucket) directly. The old pattern made downstream services either bind the resource themselves or accept a binder-shaped dependency, which is awkward for idiomatic Effect services.

The first version of this PR let R2BucketClient.layer(bucket) call .bind(...) internally. That was too easy to misuse: if the layer is only provided around request/RPC handlers, Alchemy may never build that layer during deploy, so the Worker binding would not be discovered. This version makes the dependency explicit: bind in Worker init, then pass the already-bound client into the client layer.

This PR applies that client-service pattern across the scoped Cloudflare object bindings and documents both usage styles.

Example service: https://github.com/wking-io/creative-agent/blob/5d0506b861120e4c6d404b9db72d777afee96261/apps/api/src/infra/ImageStorage.ts#L90

Example worker: https://github.com/wking-io/creative-agent/blob/main/apps/api/src/worker.ts

Usage Examples

Direct binding inside a Worker

Use the existing .bind(...) API when the Worker owns the use site directly. This keeps the shortest path for simple runtime handlers and records the deploy-time Cloudflare binding during Worker init.

export const MyBucket = Cloudflare.R2Bucket("MyBucket");

export default Cloudflare.Worker(
  "ImageWorker",
  { main: import.meta.filename },
  Effect.gen(function* () {
    const bucket = yield* Cloudflare.R2Bucket.bind(MyBucket);

    return {
      fetch: Effect.gen(function* () {
        const request = yield* HttpServerRequest;
        const key = request.url.split("/").pop()!;

        if (request.method === "GET") {
          const object = yield* bucket.get(key);
          return object
            ? HttpServerResponse.text(yield* object.text())
            : HttpServerResponse.empty({ status: 404 });
        }

        yield* bucket.put(key, request.stream);
        return HttpServerResponse.empty({ status: 201 });
      }),
    };
  }).pipe(Effect.provide(Cloudflare.R2BucketBindingLive)),
);

Providing a bound client to an Effect service

Use the new client service when another service should depend on an already-bound Cloudflare runtime client. The Worker init effect performs the deploy-discoverable bind first; the application service depends on Cloudflare.R2BucketClient and receives that concrete client from a layer.

export const MyBucket = Cloudflare.R2Bucket("MyBucket");

class Store extends Context.Service<Store, {
  put(key: string, value: string): Effect.Effect<Cloudflare.R2Object, Cloudflare.R2Error>;
  get(key: string): Effect.Effect<Cloudflare.R2Object | undefined, Cloudflare.R2Error>;
}>()("Store") {}

const StoreLive = Layer.effect(
  Store,
  Effect.gen(function* () {
    const bucket = yield* Cloudflare.R2BucketClient;
    return {
      put: (key: string, value: string) => bucket.put(key, value),
      get: (key: string) => bucket.get(key),
    };
  }),
);

export default Cloudflare.Worker(
  "ImageWorker",
  { main: import.meta.filename },
  Effect.gen(function* () {
    // This bind runs during Worker init, so Alchemy can discover and attach
    // the deploy-time R2 Worker binding.
    const bucket = yield* Cloudflare.R2Bucket.bind(MyBucket);

    const AppLayer = StoreLive.pipe(
      Layer.provide(Cloudflare.R2BucketClient.layer(bucket)),
    );

    return {
      fetch: Effect.gen(function* () {
        const store = yield* Store;
        const object = yield* store.get("hello.txt");
        return object
          ? HttpServerResponse.text(yield* object.text())
          : HttpServerResponse.empty({ status: 404 });
      }).pipe(Effect.provide(AppLayer)),
    };
  }).pipe(Effect.provide(Cloudflare.R2BucketBindingLive)),
);

Same shape for other Cloudflare object clients

The same pattern is available for the other scoped Cloudflare bindings in this PR:

const kv = yield* Cloudflare.KVNamespace.bind(MyKV);
const db = yield* Cloudflare.D1Connection.bind(MyDB);
const queue = yield* Cloudflare.QueueBinding.bind(MyQueue);
const images = yield* Cloudflare.Images.bind(MyImages);
const artifacts = yield* Cloudflare.Artifacts.bind(Repos);
const analytics = yield* Cloudflare.AnalyticsEngineDataset.bind(Analytics);
const gateway = yield* Cloudflare.AiGateway.bind(Gateway);
const hyperdrive = yield* Cloudflare.Hyperdrive.bind(MyHyperdrive);
const email = yield* Cloudflare.SendEmail.bind(Email);

Layer.provide(Cloudflare.KVNamespaceClient.layer(kv));
Layer.provide(Cloudflare.D1ConnectionClient.layer(db));
Layer.provide(Cloudflare.QueueSender.layer(queue));
Layer.provide(Cloudflare.ImagesClient.layer(images));
Layer.provide(Cloudflare.ArtifactsClient.layer(artifacts));
Layer.provide(Cloudflare.AnalyticsEngineDatasetClient.layer(analytics));
Layer.provide(Cloudflare.AiGatewayClient.layer(gateway));
Layer.provide(Cloudflare.HyperdriveBindingClient.layer(hyperdrive));
Layer.provide(Cloudflare.SendEmailClient.layer(email));

Validation

  • bun generate:api-reference
  • git diff --check
  • bun run build:packages
  • focused non-provider vitest suite: 13 files / 742 tests passed
  • tested locally through creative-agent with bun run infra:typecheck and bun test:e2e:api against the linked local package

wking-io added 2 commits May 15, 2026 07:36
Add yieldable XClient service tags with a layer factory so downstream Effect services can depend on an already-bound client instead of depending on the binding function directly.

The binding still records the deploy-time Worker binding, but the returned client now captures WorkerEnvironment internally and exposes methods without leaking WorkerEnvironment through each method's requirement type. This lets application services remain ordinary Effect services that depend on XClient.

Also document the pattern in the examples.
@wking-io wking-io force-pushed the codex/r2-bucket-client-service branch from df5c062 to b3a1c6d Compare May 15, 2026 12:55
@wking-io wking-io marked this pull request as ready for review May 15, 2026 13:09
@wking-io wking-io changed the title feat(r2): expose bucket client service feat(cloudflare): expose bucket client service May 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant