Concepts
PlumeKit has one governing idea: write the app once, run it on any target. Everything else (the module layout, the capability bindings, the request lifecycle) exists to make that literally true, with no platform branches in your code. This page is the mental model; the feature docs are the detail.
One portable core, many adapters
PlumeKit is split into a platform-agnostic core and thin per-platform adapters.
PlumeCoreis the framework core: routing, request/response, middleware, thePlumeORMis the@Modelmacro, the row codec, the typedPlumeServeris the native adapter: a SwiftNIO HTTP/1.1 server, the nativePlumeWorkeris the Cloudflare adapter: the Wasm byte marshalling and thePlume/PlumeRuntimeare the templating language: the compiler and the
capability seam, auth, and the API surface. It uses no Foundation and no runtime reflection (bytes are [UInt8]), so it compiles to a tiny WebAssembly module as readily as it does a native binary.
query builder, and the migrator. It also compiles to Wasm, and it talks only to the SQL capability, so a model runs unchanged on native SQLite, Postgres, and Cloudflare D1.
binding drivers (SQLite, filesystem object storage, in-process queue, and so on), the plumekit serve runtime, and the interactive console.
async host-binding bridge that lets the module call Cloudflare's KV, D1, R2, and queues.
render runtime. They are one component of the framework, not its center, and are usable on their own.
Your app is a library of routes plus two thin entry points. Both entry points call the same buildApp():
Sources/App/ ── buildApp() ──▶ Application (routes + middleware)
│ │
┌─────────────┴─────────────┐ ┌───────────┴────────────┐
Sources/Server/main.swift Sources/Worker/main.swift
PlumeServer (native NIO) PlumeWorker (Wasm + JSPI)
`plumekit serve` `plumekit build --target cloudflare`The core knows nothing about NIO or Cloudflare. An adapter decodes a transport request into a Request, calls Application.handle(_:), and serializes the returned Response back onto the wire.
The capability seam
Your handlers never name a platform type: no env, no D1Database, no KVNamespace. Instead they reach host services through capability bindings: small concrete structs of async closures carried on each request's Context (KV, database, storage, queue, HTTP client, secrets, mailer, broadcaster, and a log function). Each capability is a protocol (the *adapter contract*) plus a concrete handle that wraps any conforming adapter via an opaque some generic.
Which adapter backs each capability is decided at the composition root, driven by a per-project plumekit.toml:
[capabilities] # which capabilities this app uses
kv = true
database = true
[targets.native] # native driver selection
database = "sqlite" # sqlite | postgres
[targets.cloudflare] # Cloudflare adapter selection
database = "d1"A build-tool plugin regenerates two files from this manifest on every build:
Bindings.swift: a typedrequest.bindingsview exposing exactly theComposition.swift: the native composition root that wires the selected
capabilities you declared. Reaching for one you did not declare is a *compile* error, because no accessor is generated for it.
drivers into a Context for plumekit serve.
Changing a driver is a one-line edit to plumekit.toml and a rebuild. No app-code change, no platform conditional. On Cloudflare the bindings are configured in wrangler.toml and bridged in by the generated Worker glue.
The request lifecycle
A request flows through the middleware stack, into a matched route, and back out as a response:
Request ─▶ middleware₀ ─▶ middleware₁ ─▶ … ─▶ route handler ─▶ Response
(each may short-circuit or transform on the way out)- Routing matches an HTTP method and a path pattern (with
:namepath - Middleware is a function
(Request, next) async throws -> Response. - Handlers are
async throwsclosures.asyncis what lets a handlerawaita
parameters). No match is a 404; a path that exists for another method is a 405.
Registration order is nesting order: the first-registered middleware is outermost. A middleware may inspect or rewrite the request, call next to continue, or return early to short-circuit.
host binding (a KV read, a SQL query); a thrown error becomes a 500.
The same Request and Response value types are used everywhere. Bodies are [UInt8]; convenience constructors (.text, .html, .json, .redirect) cover the common cases. See Routing and Middleware.
Async, natively and as Wasm
Handlers are async because host bindings are asynchronous on every target. On the native server, an await on a binding calls an in-process driver. On Cloudflare, the same await suspends the WebAssembly stack across the boundary to the JS host (via JSPI) while Cloudflare fetches from KV/D1/R2, then resumes the module. Your code is identical; only the adapter behind the closure differs.
Plume is one component
Plume, the templating language, is PlumeKit's built-in view layer, but it is not the framework's core. A .plume file compiles to a render function that writes HTML into a buffer; a handler calls it and returns the bytes as a response. The core stays view-engine-agnostic: it only ever sees [UInt8]. Because the language is a standalone module, an external static-site generator can use Plume without any of the web framework. See Plume views in PlumeKit.
Where to go next
- Getting started: build and run an app end to end.
- Bindings & drivers: the full capability catalogue and driver
- Portability: how one app targets both runtimes.
selection.