Web apps
The third surface a module can export: app — Express-style routes, middleware, and path parameters, all under one function's URL. With the database and HTML fragments, one pushed file is a complete interactive web app.
The builder
export const app = rusted
.app({ name: "todo-app", access: "public" })
.use(async (request, context, next) => { // runs before every matched route
request.user = await authenticate(context, request);
return next(); // or return a response to short-circuit
})
.get("/", homeHandler)
.get("/todos/{id}", showHandler) // captures → request.params.id
.post("/todos", createHandler)
.delete("/todos/{id}", removeHandler);
The chain reads like Express but executes into plain data at deploy time — the route table is inspected, validated, and stored exactly like mcp tools, so rusted verify lists every route and a missing handler cannot deploy. Handlers are ordinary (request, context) functions with the whole runtime available; an app module exports no default — routes are the interface.
| piece | behavior |
|---|---|
rusted.app(meta) | Starts the chain. name and access ("public" | "private") mean what they mean on the http export. |
.get/.post/.put/.patch/.delete(path, handler) | One route each; up to 64. {param} segments capture into request.params, URL-decoded. Requests also carry request.path, the subpath under the function root. |
.use(middleware) | (request, context, next), in registration order, before every matched route. Return without calling next() to short-circuit (auth checks, redirects). Mutating request is how middleware hands data to handlers. |
| no match | The dispatcher answers: unknown path → 404, known path with the wrong method → 405. Methods not declared by any route are refused before a sandbox boots. |
Serving HTML
A fragment is just a string with the right content type — context.html is context.text with text/html:
.get("/", async (request, context) => {
const todos = await context.db.query("SELECT * FROM todos ORDER BY id");
return context.html(`<ul>${todos.map(todoLi).join("")}</ul>`);
})
That composes naturally with htmx: elements request fragments, the server renders them, htmx swaps them in. The pattern that keeps it honest — write one renderer per thing (todoLi) and return it from the full page and from every mutation, so the DOM is always a reflection of server truth. Form posts arrive url-encoded: new URLSearchParams(request.body).get("title").
One URL subtlety: the page lives at /f/todo-app with no trailing slash, so relative asset and hx- URLs resolve from /f/ — write them as todo-app/todos, or absolute.
Templates in files
Past a certain size, HTML wants its own files. Two pieces make that the project shape:
import pageTpl from "./templates/page.html"; // bundles in as a string
import itemTpl from "./templates/todo-item.html";
.get("/", async (request, context) => {
const todos = await context.db.query("SELECT * FROM todos ORDER BY id");
const items = todos.map((todo) => context.render(itemTpl, todo)).join("");
return context.html(context.render(pageTpl, { items }));
})
.html imports work in the CLI bundler and the console editor's file panel alike; the deployed artifact is still one file, so rusted pull shows everything. context.render is minijinja: {{ expr }}, {% if %}/{% for %}, filters — with HTML auto-escaping always on, so templated projects need no escape helper. Composed HTML entering a template does so explicitly: {{ items|safe }}.
Each file keeps one interpolation owner — ${...} is inert in templates, {{ ... }} is inert in JS literals — so nothing is evaluated twice.
The complete examples
examples/todo-app is the whole idea in one commented file: routes + middleware + context.db + htmx fragments — push it and the URL is a working todo app. examples/todo-htmx is the same app restructured with its HTML in templates/*.html and no escape helper.
Notes
- Escaping:
context.renderescapes everything by default — prefer it for user input. Hand-built template literals escape nothing; if you interpolate user input into one, escape it yourself (todo-app ships a five-lineescapeHtml). --method/--pathpush flags don't apply — routes come from the module./api/invokerefuses app functions — they're invoked through their routes.rusted runserves them locally with hot reload, andrusted new <name> --appscaffolds one.- Sessions without a database:
request.cookies+context.setCookie+context.seal/open— see security.
rusted