HTTP Server

Functional HTTP layer with @effect/platform — type-safe, composable, resource-managed

Unlike Express or Koa, Gello's HTTP layer is built on Effect's functional patterns. Routes are values, handlers return Effects, and resources are automatically managed. This gives you type-safe routing, middleware composition, and proper resource cleanup without any imperative callback chains.

Setup

pnpm add effect @effect/schema @effect/platform @effect/platform-node

Basic Router

import { pipe } from "effect"
import * as HttpRouter from "@effect/platform/HttpRouter"
import * as HttpServerResponse from "@effect/platform/HttpServerResponse"

const AppRouter = pipe(
  HttpRouter.empty,

  HttpRouter.get("/health",
    HttpServerResponse.json({ status: "ok" })
  ),

  HttpRouter.get("/hello/:name", Effect.gen(function* () {
    const { name } = yield* HttpRouter.schemaPathParams(
      S.Struct({ name: S.String })
    )
    return HttpServerResponse.json({ message: `Hello, ${name}!` })
  }))
)

Typed Request Bodies

import * as S from "@effect/schema/Schema"
import * as HttpServerRequest from "@effect/platform/HttpServerRequest"

const CreateUser = S.Struct({
  name: S.String,
  email: S.String.pipe(S.filter((s) => s.includes("@")))
})

HttpRouter.post("/users", Effect.gen(function* () {
  // Validates and decodes — fails with 400 on bad input
  const body = yield* HttpServerRequest.schemaBodyJson(CreateUser)

  const repo = yield* UserRepo
  const user = yield* repo.create(body)

  return yield* HttpServerResponse.schemaJson(User)(user)
}))

Typed Responses

// Schema for response — ensures you return the right shape
const UserResponse = S.Struct({
  id: S.String,
  name: S.String,
  email: S.String,
  createdAt: S.Date
})

HttpRouter.get("/users/:id", Effect.gen(function* () {
  const { id } = yield* HttpRouter.schemaPathParams(S.Struct({ id: S.String }))
  const repo = yield* UserRepo
  const user = yield* repo.findById(id)

  if (!user) {
    return HttpServerResponse.empty({ status: 404 })
  }

  // Type-checked: must match UserResponse schema
  return yield* HttpServerResponse.schemaJson(UserResponse)(user)
}))

Query Parameters

const PaginationParams = S.Struct({
  page: S.optional(S.NumberFromString).pipe(S.withDefault(() => 1)),
  limit: S.optional(S.NumberFromString).pipe(S.withDefault(() => 20))
})

HttpRouter.get("/users", Effect.gen(function* () {
  const { page, limit } = yield* HttpRouter.schemaSearchParams(PaginationParams)
  const repo = yield* UserRepo
  const users = yield* repo.list({ page, limit })
  return yield* HttpServerResponse.schemaJson(S.Array(User))(users)
}))

Middleware

import * as HttpMiddleware from "@effect/platform/HttpMiddleware"

// Logging middleware (built-in)
const withLogging = HttpMiddleware.logger

// Custom auth middleware
const withAuth = HttpMiddleware.make((app) =>
  Effect.gen(function* () {
    const request = yield* HttpServerRequest.HttpServerRequest
    const auth = request.headers["authorization"]

    if (!auth?.startsWith("Bearer ")) {
      return HttpServerResponse.empty({ status: 401 })
    }

    const token = auth.slice(7)
    const user = yield* verifyToken(token)

    // Continue with user in context
    return yield* app.pipe(Effect.provideService(CurrentUser, user))
  })
)

// Apply to router
const ProtectedRouter = pipe(
  HttpRouter.empty,
  HttpRouter.get("/me", Effect.gen(function* () {
    const user = yield* CurrentUser
    return HttpServerResponse.json(user)
  }))
).pipe(withAuth)

Booting the Server

import * as HttpServer from "@effect/platform/HttpServer"
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import { createServer } from "node:http"

const HttpApp = HttpRouter.toHttpApp(AppRouter)

const ServerLayer = pipe(
  HttpServer.serve(HttpApp),
  HttpServer.withLogAddress,
  Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
)

// Compose with your app layers
const MainLayer = pipe(
  ServerLayer,
  Layer.provide(AppLayer) // ConfigLive, DbLive, etc.
)

Layer.launch(MainLayer).pipe(NodeRuntime.runMain)

Error Handling

// Errors become proper HTTP responses
class NotFoundError extends Data.TaggedError("NotFoundError")<{
  resource: string
  id: string
}> {}

HttpRouter.get("/users/:id", Effect.gen(function* () {
  const { id } = yield* HttpRouter.schemaPathParams(S.Struct({ id: S.String }))
  const repo = yield* UserRepo
  const user = yield* repo.findById(id)

  if (!user) {
    return yield* new NotFoundError({ resource: "User", id })
  }

  return yield* HttpServerResponse.schemaJson(User)(user)
}).pipe(
  Effect.catchTag("NotFoundError", (e) =>
    HttpServerResponse.json({ error: `${e.resource} not found` }, { status: 404 })
  )
))