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.

piecebehavior
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 matchThe 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.render escapes 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-line escapeHtml).
  • --method/--path push flags don't apply — routes come from the module.
  • /api/invoke refuses app functions — they're invoked through their routes. rusted run serves them locally with hot reload, and rusted new <name> --app scaffolds one.
  • Sessions without a database: request.cookies + context.setCookie + context.seal/open — see security.