$ forge add bastionBastion is March's web framework — the Phoenix to March's Elixir.
It gives you server-rendered HTML by default, typed WASM islands for the parts of the page that need to be interactive, pattern-matched routing, typed middleware pipelines, and real-time WebSocket channels — all written in March, typed end to end.
---
Bastion is built around a handful of opinions:
server and shipped as HTML. Interactivity is added surgically through
*islands* — self-contained `init / update / render` components (Elm-style)
that are written in March, server-rendered for the first paint, and hydrated
as WASM in the browser. There is no monolithic SPA and no client/server type
drift: the same typed island runs in both places.Conn`through a pipeline of *plugs* (`Conn -> Conn`). Middleware, routing,
controllers, and after-send hooks are all just plugs. This is the plug/Conn
model from Phoenix, made typed.heads, middleware pipelines track the conn's state transitions in the type
system, forms validate through changeset-style `Gate` pipelines, and island
messages/state are typed March values. If it compiles, the wiring is sound.headers, caching, rate limiting, PubSub/channels, uploads, idempotency,
telemetry, and a Depot (Postgres) integration all ship in `lib/`. They are
ordinary March modules you compose explicitly — nothing is injected behind
your back.build a plug, hand it to `BastionServer.start_plug`, and that is your app.
Generators (`forge bastion.gen.*`) scaffold code you own and can read.---
forge build tool. Bastion depends on the Marchcompiler at `/Users/80197052/code/march` (see `CLAUDE.md`); build `forge` from
that toolchain and put it on your `PATH`.shasum (from coreutils) for content-hashing WASM island bundles — see[Known limitations](#known-limitations).Bastion is a March library (type = "lib" in forge.toml). To use it in an application, add it as a dependency in your app's forge.toml`
[deps]
bastion = { path = "/path/to/bastion" }
# or, once published to a git remote:
# bastion = { git = "https://github.com/march-language/bastion.git", branch = "main" }To work on Bastion itself:
git clone <bastion-repo> bastion
cd bastion
forge check # fast typecheck of the whole project
forge test # run the test suiteScaffold a brand-new application with the generator:
forge bastion.new my_app
cd my_app
forge bastion.server # dev server with live reload, on http://localhost:4000---
Routing matches method + path; handlers are plugs (Conn -> Conn). You compose middleware into a pipeline and hand the resulting plug to the server:
mod Hello do
fn home(conn : Conn) : Conn do
Response.html(conn, 200, "<h1>Hello from Bastion</h1>")
end
fn health(conn : Conn) : Conn do
Response.json(conn, 200, "{\"status\":\"ok\"}")
end
fn main() : Unit do
let router =
Router.new()
|> Router.get("/", home)
|> Router.get("/health", health)
let plug = fn conn ->
Middleware.pipeline([
Middleware.logger,
Middleware.request_id,
Router.to_plug(router)
], conn)
BastionServer.start_plug(plug, Bastion.default_opts(4000))
end
endA fuller, runnable version — scopes, path/query params, JSON bodies, static files, and a 404 fallback — lives in [examples/blog.march](examples/blog.march).
Request.query_param, Request.path_param, Request.json_body`Response.html, Response.json, Response.text``Response.not_found_`, `Response.bad_request`, plus `redirect` / `assign` /
`send_resp` from the controller helpers.Router.get/post/..., Router.scope("/api/v1", sub_router)``Router.to_plug`.Middleware.pipeline([...]) runs plugs in order and haltsearly if a plug marks the conn sent/halted; `Middleware.compose` is the
two-plug shorthand.An island is an init / update / render component. It runs on the server for SSR and, once the WASM browser target lands, hydrates client-side from the same code:
mod CounterIsland do
import Islands
type State = { count : Int }
type Msg = Increment | Decrement | Reset | SetValue(Int)
fn initial() : State do { count: 0 } end
fn update(state : State, msg : Msg) : State do
match msg do
Increment -> { count: state.count + 1 }
Decrement -> { count: state.count - 1 }
Reset -> { count: 0 }
SetValue(n) -> { count: n }
end
end
fn render(state : State) : String do
"<div class=\"counter\">" ++
"<button data-on-click=\"Decrement\">-</button>" ++
"<span>" ++ int_to_string(state.count) ++ "</span>" ++
"<button data-on-click=\"Increment\">+</button>" ++
"</div>"
end
-- Wrap render output with hydration metadata for a page template:
fn ssr(state : State) : String do
Islands.wrap("CounterIsland", Islands.eager(), encode_state(state), render(state))
end
enddata-on-click="Increment" tells the JS runtime to route the click back into update as a typed Msg. The island can be tested entirely on the server, in pure March, with no browser — see [examples/counter_island.march](examples/counter_island.march) and Bastion.Test.Island`
---
Bastion's tooling is registered as forge bastion.* tasks (see forge.toml`
| Command | What it does | |---------|--------------| | forge check&
.march file, run forge check to typecheck before proceeding.---
Implemented and usable today: the HTTP conn/plug pipeline, pattern-matched routing, typed middleware, island SSR + hydration runtime, ~H templates, static file serving, auth / sessions / CSRF / CORS / security headers, Gate form validation, flash messages, cookie sessions, caching (ETag + fragment + response), rate limiting, PubSub + channels, uploads, idempotency keys, telemetry + metrics + structured logging, a Depot (Postgres) integration, a dev dashboard, and the full generator + release toolchain.
The main gaps are the WASM browser target itself (a March compiler roadmap item), panic-recovery in a couple of dev/error paths, and real OTLP export — all blocked on lower-level March toolchain primitives.
---
forge bastion.build.islands computes 8-char content hashes for compiled WASM files by shelling out to shasum -a 256 (available on macOS and most Linux distributions via coreutils`
This is a platform dependency. A future improvement would replace it with a pure-March SHA-256 implementation so the command works on any target without requiring system utilities.
Workaround on systems without shasum: install coreutils (e.g. brew install coreutils on macOS if missing, or apt install coreutils on Debian/Ubuntu), or use forge bastion.gen.island --compile for single-island builds, which skips hashing entirely and serves files with a short cache TTL.