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 👇
Claude code just recommended @EffectTS_ over NestJS/Fastify/Express for API, threshold is crossed
API with effect
@effect/platform provides a series of HttpApi* modules to build a type-safe and composable API. Main highlights:
HttpApiGroupto define API groups (e.g./profile)HttpApiEndpointfor individual endpoints definitionHttpApiMiddlewarefor middlewaresHttpApiSecurityfor 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>;
HttpApiBuilderis 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
R2Storageservice 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/sqland@effect/sql-d1, you don't need to bother with D1-specific details, you just write standard SQL witheffect🏗️
My project uses AI, Streams, Durable Objects, observability, and much more. It all works like a charm with effect on Cloudflare 🪄
See you next 👋
