A composable, type-safe web framework for Haskell. Your API is a type, your middleware is tracked at compile time, and your backend is pluggable.
Built on GHC 9.10.3. Informed by Rust's tower/axum/typeway ecosystem.
- GHC 9.10.3 (install via ghcup)
- cabal-install >= 3.10
ghcup install ghc 9.10.3
ghcup set ghc 9.10.3Clone and build:
git clone <repo-url> acolyte
cd acolyte
cabal build all # ~7s clean build
cabal test all # 27 test suites, 1000+ assertions, 60+ hedgehog propertiesRun the hello-world example:
cabal run hello-worldIn another terminal:
curl http://localhost:3000/health # -> ok
curl http://localhost:3000/users # -> ["alice","bob","charlie"]
curl http://localhost:3000/users/42 # -> "user-42"Endpoints are a promoted list. Paths use type-level strings. Captures are typed.
{-# LANGUAGE DataKinds #-}
import Acolyte.Core
import Acolyte.Server
type HealthPath = At "health" -- expands to '[ 'Lit "health" ]
type UsersPath = At "users" -- expands to '[ 'Lit "users" ]
type API =
'[ Get HealthPath Text -- GET /health -> Text
, Get UsersPath (Json [Text]) -- GET /users -> JSON array
]Handlers are plain IO functions. The type signature is the extraction: arguments are automatically pulled from the request.
healthHandler :: IO Text
healthHandler = pure "ok"
listUsersHandler :: IO (Json [Text])
listUsersHandler = pure (Json ["alice", "bob"])
-- Path captures are typed and extracted automatically:
getUser :: PathCapture Int -> IO (Json Text)
getUser (PathCapture uid) = pure (Json (T.pack ("user-" ++ show uid)))No monad transformers, no liftIO, no manual extraction boilerplate.
mkApi checks at compile time that you have the right number of
handlers and that each one matches its endpoint type. No wrapHandler,
no toHandler. Just pass your functions positionally.
import Spire (Service, (|>))
import Spire.Http (secureHeadersLayer, defaultSecureHeaders)
server :: Service IO (Request ByteString) (Response ByteString)
server = mkApi @API (healthHandler, listUsersHandler)For larger APIs, wrap endpoints with Named and use mkRecordApi to
match handlers by field name instead of position:
type NamedAPI =
'[ Named "health" (Get HealthPath Text)
, Named "listUsers" (Get UsersPath (Json [Text]))
]
data Handlers = Handlers
{ listUsers :: IO (Json [Text]) -- order doesn't matter
, health :: IO Text
}
server = mkRecordApi @NamedAPI Handlers { ... }Middleware composes with |>. Pick a backend: spire-server (zero
WAI) or spire-wai (warp).
import Spire.Server (runServerBS)
main :: IO ()
main = do
let app = server |> secureHeadersLayer defaultSecureHeaders
runServerBS 3000 appThat's it. You have a running server with OWASP security headers, compile-time route checking, and no WAI dependency.
Endpoints can declare what middleware they require. The compiler enforces that you provide it.
type EffectAPI =
'[ Requires Auth (Get UserPath (Json User)) -- needs auth
, Get HealthPath Text -- no requirements
]
app = run
$ provide @Auth authMiddleware
$ effectfulServer @EffectAPI (authHandler, healthHandler)Forget provide @Auth? You get a compile error:
Missing middleware effect: Auth
This effect is required by an endpoint but was not provided.
acolyte-test dispatches requests directly through the
spire Service. No ports, no sockets, deterministic.
import Acolyte.Test
test :: IO ()
test = do
let svc = mkServer @API (health, users)
resp <- get svc "/health"
resp `shouldHaveStatus` 200
resp `shouldHaveBody` "ok"WebSocket protocols are enforced at compile time via phantom-typed session handles. Each operation (send, recv, offer, select) transitions the type-level state, so the compiler rejects out-of-order messages.
import Spire.WebSocket
import Acolyte.Core.Session (SessionType (..))
type EchoProtocol = 'Send Text ('Recv Text 'End)
echoHandler :: Session EchoProtocol -> IO ()
echoHandler session = do
session' <- send ("hello" :: Text) session
(msg, session'') <- recv session'
close session''The protocol type drives correctness: calling recv when the protocol
expects send is a type error. Branching protocols use Offer / Select,
and recursive protocols use Rec / Var with recurse / loop.
The same API type drives REST and gRPC:
-- REST server:
restSvc = mkServer @API restHandlers
-- gRPC server (same API type!):
grpcSvc = grpcServer (mkGrpcServiceMap @API "pkg" "Svc" grpcHandlers)
-- Multiplex REST + gRPC on a single port:
combined = multiplex (adaptToBody restSvc) grpcSvc
-- .proto file with full message definitions:
proto = generateProto @API "pkg" "Svc"
-- Or go the other way (.proto to API types):
-- cabal run proto-codegen -- service.protoRun on HTTP/2 via spire-server (zero WAI):
main = runServerH2 (defaultH2Config 50051) grpcSvcFeatures: REST+gRPC multiplexing (Spire.Grpc.Multiplex), content
negotiation (Acolyte.Server.Negotiate), server reflection
(Spire.Grpc.Reflection), bidirectional .proto codegen, client
streaming and bidirectional streaming handlers, gRPC health check
service (Spire.Grpc.Health), and gzip compression
(Spire.Grpc.Compression).
See the gRPC guide for the full walkthrough.
┌────────────────────────────────────────┐
│ Top-level frameworks │
│ acolyte-server REST + spire │
│ acolyte-grpc gRPC + spire │
└─────────────────────┬──────────────────┘
│ produces a Service
▼
┌────────────────────────────────────────┐
│ Service layer │
│ spire / spire-http / spire-grpc │
│ Service / Layer / framing │
└─────────────────────┬──────────────────┘
│
▼
┌────────────────────────────────────────┐
│ Backend-agnostic core │
│ http-core │
│ Request / Response abstraction │
└─────────────────────┬──────────────────┘
│
▼
┌────────────────────────────────────────┐
│ Backend (pick one) │
│ spire-wai via warp │
│ spire-server via raw sockets │
│ HTTP/1.1, HTTP/2 │
└────────────────────────────────────────┘
spire-websocket: peer to spire-http / spire-grpc, adds linear session types
Each layer is independent. spire knows nothing about HTTP. http-core
knows nothing about WAI. spire-grpc knows nothing about API types.
The server produces a Service and doesn't know or care what runs it.
| Package | What it does |
|---|---|
acolyte-core |
Type-level API: endpoints, paths, effects, sessions, versioning. Depends on base only. |
spire |
Service/Layer/Middleware composition. Depends on base only. Standalone (use it anywhere). |
http-core |
Backend-agnostic Request, Response, Extensions (typed heterogeneous map). |
spire-http |
HTTP middleware: security headers, request ID, tracing, CORS, gzip, timeouts. |
spire-wai |
WAI/warp backend adapter. The only package that imports WAI. |
spire-server |
Spire-native HTTP/1.1 + HTTP/2 server with TLS. Zero WAI dependency. |
spire-grpc |
gRPC wire protocol: framing, status codes, service dispatch. No protobuf dependency. |
acolyte-server |
Handler wiring, routing, extractors, effect tracking. |
acolyte-client |
Type-safe HTTP client derived from the same API type. |
acolyte-openapi |
OpenAPI 3.1 + Swagger 2.0 spec generation from API types. Annotations (Describe, WithParams, WithHeaders, RespondsWith) populate real operation summaries, parameter schemas, status codes, and request/response body schemas. Custom types get schemas automatically via Generic-based ToSchema derivation. |
acolyte-codegen |
Generate API types from OpenAPI/Swagger specs. |
acolyte-grpc |
gRPC interpretation: GrpcCodec, GrpcReady, .proto generation, mkGrpcServiceMap. |
acolyte-test |
Direct-dispatch testing: no network, no ports. |
spire-websocket |
WebSocket session types: phantom-typed Session handle enforces send/recv protocol at compile time. |
acolyte |
Facade. Re-exports everything for convenience. |
The examples/ directory contains 12 complete applications:
examples/minimal: simplest possible server (1 endpoint)examples/hello-world: 3 endpoints, effect tracking, middleware stackexamples/crud: full CRUD with named routes, structured errors, and ValidatedBodyexamples/auth: custom authentication extractorsexamples/custom-extractors: writing your own request extractorsexamples/grpc-demo: gRPC server with .proto generationexamples/chat: session-typed WebSocket chatexamples/negotiate: content negotiation (JSON, XML, plain text)examples/versioned-api: API versioning with typed version headersexamples/streaming: Server-Sent Events with async streamingexamples/realworld: RealWorld spec API types split into 6 sub-APIsexamples/realworld-combined: full RealWorld backend, 15 endpoints across 6 sub-APIs with handlers, in-memory store, and combined effect tracking
- Promoted lists, not trees. APIs are
'[endpoint1, endpoint2, ...]. Instance resolution is one flat tuple match per arity instead of a recursive walk over Servant's:<|>tree. Seedocs/PERFORMANCE.mdfor a head-to-head compile-time bench. - IO handlers. No
ExceptT, noReaderT. State via extractors (AppState), errors viaEither. - spire Service as the boundary. The server produces it, the backend
consumes it. Middleware composes with
|>. - WAI is confined. Only
spire-waiimports WAI. Swap it forspire-server, a Lambda adapter, or anything else. - Compile times are first-class. 1 to 32 endpoints compile in roughly the same wall-clock time (~1.63s on an Apple Silicon laptop, dominated by GHC startup and dependency loading). The in-repo bench also runs the same API under Servant 0.20.3.0 for direct comparison.
- Runtime is fast. 142 ns dispatch, 14 ns gRPC decode, middleware adds zero overhead. See the Performance Guide for full benchmarks and analysis.
- Optional type-level annotations. Endpoints can be wrapped with
Describe,WithParams,WithHeaders,RespondsWith/PostCreated/DeleteNoContent,Versioned, and streaming markers (ServerStream,ClientStream,BidiStream) for richer OpenAPI specs and client generation, without changing routing or handler signatures.Versioned V1 (Get ...)routes to/v1/...and is supported by the server, client, and OpenAPI interpretations. See the tutorial for details. - Named endpoints. Wrap endpoints with
Named "fieldName"to enable record-based handler binding viamkRecordApi. Field order doesn't matter, the compiler matches by name.Namedalso setsoperationIdin OpenAPI specs. Fully opt-in: unnamed endpoints and positional tuples continue to work unchanged. On the client side,mkClientRecordconstructs a typed client record fromNamedAPIs viaGeneric. - Request validation.
ValidatedBody v adeserializes JSON and runs aValidate v acheck before the handler sees the data. Validation failures return 422 with a structured error. Define a validator type, write aValidateinstance, and useValidatedBodyin the handler signature: no manual error-checking boilerplate. - Async SSE streaming.
sseResponseruns the handler in a forked thread and delivers Server-Sent Events incrementally via chunked transfer encoding. For simple cases,sseResponseSynccollects all events first.
- Read the Tutorial: a step-by-step walkthrough building a complete API
- Read the Design Philosophy: why every decision was made, compile-time performance analysis, Servant comparison
- Read the Data Flow: request-to-response flow diagrams for HTTP/1.1, HTTP/2, gRPC, and the compile-time type checking flow
- Coming from Servant? Read the Migration Guide for a side-by-side comparison of every concept
- Read the Error Handling Guide for patterns on structured errors, early return, and fallible extractors
- Read the Streaming Guide for large request/response bodies
- Read the gRPC Guide for serving gRPC from the same API type
- Read the Performance Guide for compile-time and runtime benchmarks with analysis
- Browse the examples for patterns to copy
- See
ARCHITECTURE.mdfor the full design
BSD 3-Clause. See LICENSE.