tech

Effect API on Cloudflare

TypeScript API? Well, use Effect. And since Effect is just TypeScript, it just works everywhere. I have been implementing an API with Effect on Cloudflare, and it all works, great. Here is my setup.


Sandro Maglione

Sandro Maglione

Software

Effect on Cloudflare is 🔥

I have been working on a (relatively) complex API with effect, all hosted on Cloudflare:

  • D1 database (with @effect/sql-d1)
  • Durable Objects (with @effect/sql-sqlite-do)
  • R2 storage

All powered by @effect/platform, @effect/sql and HttpApi. Let's see 👇


API with effect

@effect/platform provides a series of HttpApi* modules to build a type-safe and composable API. Main highlights:

  • HttpApiGroup to define API groups (e.g. /profile)
  • HttpApiEndpoint for individual endpoints definition
  • HttpApiMiddleware for middlewares
  • HttpApiSecurity for security/authentication

All is powered by Schema, with support for encoding, decoding, branded types and more ⚡️

These are all the modules my API definition is using (not implementation, yet):

import {
  HttpApi,
  HttpApiEndpoint,
  HttpApiGroup,
  HttpApiMiddleware,
  HttpApiSchema,
  HttpApiSecurity,
} from "@effect/platform";
import { Context, Function, Match, Schema } from "effect";

// ...

export class ServerApi extends HttpApi.make("server-api")
  .add(ProfileGroup)
  .add(OnboardingGroup)
  .add(AccountGroup)
  .add(WebhookGroup) {}

Entry point: Request and Response

Cloudflare workers has a single fetch entry point. It provides a Web Standard Request, and expects a Response as return type.

export default {
  async fetch(request, env): Promise<Response> {
    // ...
  },
} satisfies ExportedHandler<Env>;

HttpApiBuilder is used to define a concrete implementation for the above API signature 🏗️

The HttpApiBuilder module takes in the HttpApi we defined, and handles its implementation.

First, let's extract the API implementation as a Layer using HttpApiBuilder.api:

const MainApiLive = HttpApiBuilder.api(ServerApi).pipe(
  Layer.provide(/* Provide groups implementations */)
);

We need a way to handle standard Request/Response. This is done with HttpApiBuilder.toWebHandler:

const MainApiLive = HttpApiBuilder.api(ServerApi).pipe(
  Layer.provide(/* ... */)
);

export const HttpApiLive = (env: Env) =>
  HttpApiBuilder.toWebHandler(
    Layer.mergeAll(
      HttpServer.layerContext,
      MainApiLive.pipe(Layer.provide(WorkerEnv.layer(env)))
    ),
    {
      middleware: flow(HttpMiddleware.logger, HttpMiddleware.cors())
    }
  );

toWebHandler provides an handler function that takes a Request, routes it through the effect API, and returns a Response.

We use this in the Cloudflare worker entry point:

export default {
  async fetch(request, env): Promise<Response> {
    const { dispose, handler } = HttpApiLive(env);
    const response = await handler(request);
    await dispose();
    return response;
  },
} satisfies ExportedHandler<Env>;

Cloudflare Env

Noticed WorkerEnv.layer?

We need a way to access Cloudflare services inside the API. Cloudflare provides them directly from fetch as a second env parameter.

export default {
  async fetch(request, env): Promise<Response> {
    const { dispose, handler } = HttpApiLive(env);
    const response = await handler(request);
    await dispose();
    return response;
  },
} satisfies ExportedHandler<Env>;

WorkerEnv is a service used to inject those services inside the API, so that they can be used anywhere:

import { D1Client } from "@effect/sql-d1";
import { Context, Layer, String } from "effect";

export class WorkerEnv extends Context.Tag<"WorkerEnv">("WorkerEnv")<
  WorkerEnv,
  Env
>() {
  static readonly layer = (env: Env) =>
    Layer.mergeAll(
      Layer.succeed(WorkerEnv, env),
      D1Client.layer({
        db: env.name_of_db,
        transformQueryNames: String.camelToSnake,
        transformResultNames: String.snakeToCamel,
      })
    );
}

Now the API can just yield* WorkerEnv to access any Cloudflare service.

R2 storage example

My API is organized in effect services (as yours should as well 💁🏼‍♂️).

For example, I have a R2Storage service for storing/getting files 📦

With the previous setup using WorkerEnv, we don't need any Cloudflare-specific implementation anymore, it's all effect:

export class R2Storage extends Effect.Service<R2Storage>()("R2Storage", {
  effect: Effect.gen(function* () {
    const env = yield* WorkerEnv;
    const bucket = env.R2_BUCKET;
    return {
      put: (key: string, data: ReadableStream<Uint8Array<ArrayBufferLike>>) =>
        Effect.tryPromise({
          try: () =>
            bucket.put(key, data, {
              httpMetadata: { contentType: "audio/mpeg" },
            }),
          catch: (error) => new R2StorageError({ cause: error }),
        }).pipe(
          Effect.tap(() => Effect.log(`Successfully uploaded to R2: ${key}`)),
          Effect.tapError((error) =>
            Effect.logError(`R2 upload failed for ${key}:`, error)
          )
        ),
    };
  }),
}) {}

The same can be done for all other services 🚀

By using @effect/sql and @effect/sql-d1, you don't need to bother with D1-specific details, you just write standard SQL with effect 🏗️


My project uses AI, Streams, Durable Objects, observability, and much more. It all works like a charm with effect on Cloudflare 🪄

See you next 👋

Start here.

Every week I dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights

Not convinced? Well, let me tell you more about it