Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 1 addition & 7 deletions apps/effect-worker-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,10 @@
},
"dependencies": {
"@repo/contracts": "workspace:^",
"@repo/cloudflare": "workspace:^",
"@repo/db": "workspace:^",
"@repo/domain": "workspace:^",
"@effect/experimental": "latest",
"@effect/platform": "latest",
"@effect/sql": "latest",
"@effect/sql-drizzle": "latest",
"@effect/sql-pg": "latest",
"drizzle-orm": "^0.45.0",
"effect": "latest"
"effect": "catalog:"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241127.0",
Expand Down
20 changes: 9 additions & 11 deletions apps/effect-worker-api/src/handlers/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* @module
*/
import { HttpApiBuilder } from "@effect/platform"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { DateTime, Effect } from "effect"
import { WorkerApi } from "@repo/contracts"

Expand All @@ -14,14 +14,12 @@ export const HealthGroupLive = HttpApiBuilder.group(
WorkerApi,
"health",
(handlers) =>
Effect.gen(function* () {
return handlers.handle("check", () =>
Effect.gen(function* () {
return {
status: "ok" as const,
timestamp: DateTime.unsafeNow()
}
})
)
})
handlers.handle("check", () =>
Effect.gen(function* () {
return {
status: "ok" as const,
timestamp: DateTime.nowUnsafe()
}
})
)
)
22 changes: 10 additions & 12 deletions apps/effect-worker-api/src/handlers/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* @module
*/
import { HttpApiBuilder } from "@effect/platform"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Effect } from "effect"
import { WorkerApi } from "@repo/contracts"
import { UserQueries } from "@repo/db"
Expand All @@ -15,15 +15,13 @@ export const UsersGroupLive = HttpApiBuilder.group(
WorkerApi,
"users",
(handlers) =>
Effect.gen(function* () {
return handlers
.handle("list", () =>
Effect.gen(function* () {
const users = yield* UserQueries.findAllUsers
return { users, total: users.length }
})
)
.handle("get", ({ path: { id } }) => UserQueries.findUserById(id))
.handle("create", ({ payload }) => UserQueries.createUser(payload))
})
handlers
.handle("list", () =>
Effect.gen(function* () {
const users = yield* UserQueries.findAllUsers
return { users, total: users.length }
})
)
.handle("get", ({ params: { id } }) => UserQueries.findUserById(id))
.handle("create", ({ payload }) => UserQueries.createUser(payload))
)
14 changes: 9 additions & 5 deletions apps/effect-worker-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@
*
* @module
*/
import { runtime, handleRequest } from "@/runtime"
import { withCloudflareBindings } from "@/services"
import { pipe, ServiceMap } from "effect"
import { handler } from "@/runtime"
import { currentEnv, currentCtx } from "@/services/cloudflare"

/**
* Cloudflare Worker fetch handler.
*/
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
// Handle request with Cloudflare bindings available via FiberRef
const effect = handleRequest(request).pipe(withCloudflareBindings(env, ctx))
// Pass per-request Cloudflare bindings via ServiceMap context
const services = pipe(
ServiceMap.make(currentEnv, env),
ServiceMap.add(currentCtx, ctx)
)

return runtime.runPromise(effect)
return handler(request, services)
}
} satisfies ExportedHandler<Env>
72 changes: 20 additions & 52 deletions apps/effect-worker-api/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,37 @@
/**
* Effect Runtime Configuration
*
* Sets up the ManagedRuntime for handling HTTP requests.
* Sets up the HTTP handler using HttpRouter.toWebHandler.
*
* @module
*/
import { Effect, Layer, ManagedRuntime } from "effect"
import { HttpApiBuilder, HttpApiScalar, HttpServer } from "@effect/platform"
import * as ServerRequest from "@effect/platform/HttpServerRequest"
import * as ServerResponse from "@effect/platform/HttpServerResponse"
import { Layer } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { WorkerApi } from "@repo/contracts"
import { HttpGroupsLive } from "@/handlers"
import { MiddlewareLive } from "@/services"

/**
* API Layer combining static services.
* API routes layer.
*
* These layers are memoized by ManagedRuntime - built once at startup.
* Middleware layers are provided here so their implementations are available,
* but the middleware effects run per-request.
* HttpApiBuilder.layer registers all API routes into the HttpRouter.
* The openapiPath option automatically serves the OpenAPI spec.
*/
const ApiLive = HttpApiBuilder.api(WorkerApi).pipe(Layer.provide(HttpGroupsLive))

const ApiLayer = Layer.mergeAll(
ApiLive,
HttpApiBuilder.Router.Live,
HttpApiBuilder.Middleware.layer,
HttpServer.layerContext,
HttpApiScalar.layer({ path: "/docs" }).pipe(Layer.provide(ApiLive))
).pipe(Layer.provideMerge(MiddlewareLive))
const ApiRoutes = HttpApiBuilder.layer(WorkerApi, {
openapiPath: "/api/openapi.json"
}).pipe(
Layer.provide(HttpGroupsLive),
Layer.provide(MiddlewareLive)
)

/**
* Shared runtime instance.
* Web handler created from the API routes.
*
* Built once at module initialization. Layers are memoized, so subsequent
* calls to runPromise reuse the same service instances.
* Layers are memoized internally — built once at startup.
* Per-request services (env/ctx) are passed via the ServiceMap context
* parameter of the handler function.
*/
export const runtime = ManagedRuntime.make(ApiLayer)

/**
* Handle an incoming HTTP request.
*
* Returns an Effect that can be wrapped with request-scoped services
* (Cloudflare env/ctx) before execution.
*/
export const handleRequest = (request: Request) =>
Effect.gen(function* () {
const app = yield* HttpApiBuilder.httpApp
const serverRequest = ServerRequest.fromWeb(request)
const url = new URL(request.url)

const response = yield* app.pipe(
Effect.provideService(ServerRequest.HttpServerRequest, serverRequest),
Effect.scoped,
Effect.catchAll(() =>
ServerResponse.json(
{
_tag: "NotFoundError",
path: url.pathname,
message: `Route not found: ${request.method} ${url.pathname}`
},
{ status: 404 }
)
)
)

return ServerResponse.toWeb(response)
})
export const { handler, dispose } = HttpRouter.toWebHandler(
ApiRoutes.pipe(Layer.provide(HttpServer.layerServices))
)
28 changes: 17 additions & 11 deletions apps/effect-worker-api/src/services/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
/**
* Cloudflare Bindings Service
*
* FiberRef bridge for providing Cloudflare's `env` and `ExecutionContext`
* ServiceMap.Reference bridge for providing Cloudflare's `env` and `ExecutionContext`
* to Effect handlers.
*
* @module
*/
import { Effect, FiberRef } from "effect"
import { Effect, ServiceMap } from "effect"

/**
* FiberRef holding the current request's Cloudflare environment bindings.
* Reference holding the current request's Cloudflare environment bindings.
*/
export const currentEnv = FiberRef.unsafeMake<Env | null>(null)
export const currentEnv = ServiceMap.Reference<Env | null>(
"@app/api/currentEnv",
{ defaultValue: () => null }
)

/**
* FiberRef holding the current request's ExecutionContext.
* Reference holding the current request's ExecutionContext.
*/
export const currentCtx = FiberRef.unsafeMake<ExecutionContext | null>(null)
export const currentCtx = ServiceMap.Reference<ExecutionContext | null>(
"@app/api/currentCtx",
{ defaultValue: () => null }
)

/**
* Set Cloudflare bindings for the scope of an effect.
Expand All @@ -33,8 +39,8 @@ export const currentCtx = FiberRef.unsafeMake<ExecutionContext | null>(null)
export const withCloudflareBindings = (env: Env, ctx: ExecutionContext) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.locally(currentEnv, env),
Effect.locally(currentCtx, ctx)
Effect.provideService(currentEnv, env),
Effect.provideService(currentCtx, ctx)
)

/**
Expand All @@ -47,13 +53,13 @@ export const waitUntil = <A, E>(
effect: Effect.Effect<A, E>
): Effect.Effect<void> =>
Effect.gen(function* () {
const ctx = yield* FiberRef.get(currentCtx)
const ctx = yield* currentCtx
if (ctx) {
ctx.waitUntil(
Effect.runPromise(
effect.pipe(
Effect.tapErrorCause(Effect.logError),
Effect.catchAll(() => Effect.void)
Effect.tapCause(Effect.logError),
Effect.catch(() => Effect.void)
)
)
)
Expand Down
58 changes: 32 additions & 26 deletions apps/effect-worker-api/src/services/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,36 @@
/**
* Middleware Implementations
*
* App-specific implementations of middleware defined in @repo/api.
* App-specific implementations of middleware defined in @repo/contracts.
*
* In Effect v4, middleware with `provides` is a function that wraps the
* httpEffect and provides the required service to it.
*
* @module
*/
import { Effect, FiberRef, Layer } from "effect";
import { Effect, Layer } from "effect";
import {
CloudflareBindingsMiddleware,
CloudflareBindingsError,
CloudflareBindings,
DatabaseMiddleware,
DatabaseConnectionError,
} from "@repo/contracts";
import { PgDrizzle, makeDrizzle } from "@repo/db";
import { currentEnv, currentCtx } from "@/services/cloudflare";
import { makeDrizzle } from "@repo/cloudflare";

/**
* Live implementation of CloudflareBindingsMiddleware.
*
* Reads env/ctx from FiberRef and provides them as the CloudflareBindings service.
* Reads env/ctx from ServiceMap.Reference and provides CloudflareBindings
* to the downstream handler effect.
*/
export const CloudflareBindingsMiddlewareLive = Layer.effect(
export const CloudflareBindingsMiddlewareLive = Layer.succeed(
CloudflareBindingsMiddleware,
Effect.gen(function* () {
// Return the middleware effect (runs per-request)
return Effect.gen(function* () {
const env = yield* FiberRef.get(currentEnv);
const ctx = yield* FiberRef.get(currentCtx);
(httpEffect) =>
Effect.gen(function* () {
const env = yield* currentEnv;
const ctx = yield* currentCtx;

if (env === null || ctx === null) {
return yield* Effect.fail(
Expand All @@ -37,24 +41,23 @@ export const CloudflareBindingsMiddlewareLive = Layer.effect(
);
}

return { env, ctx };
});
}),
return yield* httpEffect.pipe(
Effect.provideService(CloudflareBindings, { env, ctx }),
);
}),
);

/**
* Live implementation of DatabaseMiddleware.
*
* Creates a scoped PgDrizzle instance per-request.
* The connection is automatically closed when the request scope ends.
* Creates a scoped PgDrizzle instance per-request and provides it
* to the downstream handler effect.
*/
export const DatabaseMiddlewareLive = Layer.effect(
export const DatabaseMiddlewareLive = Layer.succeed(
DatabaseMiddleware,
Effect.gen(function* () {
// Return the middleware effect (runs per-request)
return Effect.gen(function* () {
// Get connection string from Cloudflare env via FiberRef
const env = yield* FiberRef.get(currentEnv);
(httpEffect) =>
Effect.gen(function* () {
const env = yield* currentEnv;
if (env === null) {
return yield* Effect.fail(
new DatabaseConnectionError({
Expand All @@ -64,17 +67,20 @@ export const DatabaseMiddlewareLive = Layer.effect(
);
}

return yield* makeDrizzle(env.HYPERDRIVE.connectionString);
const db = yield* makeDrizzle(env.HYPERDRIVE.connectionString);

return yield* httpEffect.pipe(
Effect.provideService(PgDrizzle, db),
);
}).pipe(
Effect.catchAll((error) =>
Effect.catch(() =>
Effect.fail(
new DatabaseConnectionError({
message: `Database connection failed: ${String(error)}`,
message: "Database connection failed",
}),
),
),
);
}),
),
);

/**
Expand Down
2 changes: 0 additions & 2 deletions apps/effect-worker-api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@
"@/*": ["./src/*"],
"@repo/contracts": ["../../packages/contracts/src/index.ts"],
"@repo/contracts/*": ["../../packages/contracts/src/*"],
"@repo/cloudflare": ["../../packages/cloudflare/src/index.ts"],
"@repo/cloudflare/*": ["../../packages/cloudflare/src/*"],
"@repo/db": ["../../packages/db/src/index.ts"],
"@repo/db/*": ["../../packages/db/src/*"],
"@repo/domain": ["../../packages/domain/src/index.ts"],
Expand Down
Loading
Loading