559 lines
17 KiB
Plaintext
559 lines
17 KiB
Plaintext
# MACH
|
|
|
|
> MACH (Modern Asynchronous C Hypermedia) is a declarative framework for building asynchronous web applications in C23. No build configuration, no malloc/free, no thread management — the framework handles compilation, hot reload, memory, concurrency, I/O, and observability automatically.
|
|
|
|
## Quick Start
|
|
|
|
Everything runs in Docker.
|
|
|
|
```bash
|
|
mkdir myapp && cd myapp
|
|
wget https://docker.nightshadecoder.dev/mach/compose.yml
|
|
docker compose up # dev server :3000, telemetry :4000
|
|
```
|
|
|
|
Minimal app (`main.c`):
|
|
|
|
```c
|
|
#include <mach.h>
|
|
|
|
mach(main){
|
|
context("hello", "<h1>Hello, world!</h1>");
|
|
resource("home", "/", .get = {respond("hello")});
|
|
}
|
|
```
|
|
|
|
`mach(main)` runs once at boot. `context()` registers a named template inline. `resource()` declares an endpoint.
|
|
|
|
---
|
|
|
|
## Core Philosophy
|
|
|
|
An application is a data transformation: input arrives, gets transformed, leaves as output. MACH wraps that path as **pipelines** — ordered lists of steps that turn a request into a response.
|
|
|
|
### Everything is a String
|
|
|
|
The web is text. The pipeline context stores and passes data as strings. Strings are interpolated into SQL, templates, and URLs with `{{context_key}}`.
|
|
|
|
### CLAD Principles
|
|
|
|
- **(C)omposable** — small, independent steps chain into feature pipelines.
|
|
- **(L)ocality of Behavior** — SQL, templates, and logic for a feature live together.
|
|
- **(A)utonomous** — modules own their schemas, migrations, seeds, routes, UI, and logic.
|
|
- **(D)omain Based** — each module owns one slice of the app.
|
|
|
|
---
|
|
|
|
## Context
|
|
|
|
Pipelines share a scoped key-value store for one request. Three scopes:
|
|
|
|
- `input:xxx` — raw request parameters
|
|
- `error:xxx` — validation/error data
|
|
- unprefixed — app scope (query results, validated inputs, computed values)
|
|
|
|
`input()` promotes values from `input:` to app scope.
|
|
|
|
---
|
|
|
|
## Assets
|
|
|
|
Every non-`.c` file is an asset embedded at compile time. The asset name is the filename basename before the first dot:
|
|
|
|
- `get_todos.sql` → asset key `get_todos`
|
|
- `todos.mustache.html` → asset key `todos`
|
|
- `home.md` → asset key `home`
|
|
|
|
Assets seed into the module whose folder they live in. `context(name, value)` does the same from a string.
|
|
|
|
---
|
|
|
|
## Templates
|
|
|
|
MACH uses Mustache (`.mustache.html`) and MDM — Markdown + Mustache (`.md`).
|
|
|
|
**Core syntax:**
|
|
- `{{name}}` — HTML-escaped interpolation
|
|
- `{{{name}}}` / `{{&name}}` — unescaped
|
|
- `{{#name}}...{{/name}}` — section (truthy / iterates arrays)
|
|
- `{{^name}}...{{/name}}` — inverted section
|
|
- `{{> partial}}` — inline another asset
|
|
- `{{< parent}}{{$block}}override{{/block}}{{/parent}}` — layout inheritance
|
|
|
|
**Built-in helpers:**
|
|
- `{{precision:field:N}}` — format number to N decimals
|
|
- `{{input:field}}` — raw request param (repopulate forms)
|
|
- `{{error:field}}` — truthy section when field has error
|
|
- `{{error_message:field}}` — human-readable error message
|
|
- `{{error_code:field}}` — HTTP status code of error
|
|
- `{{url:name}}` — resolve resource name to URL; `:params` filled from current scope
|
|
- `{{asset:filename}}` — cache-busted URL for a file in `public/`
|
|
- `{{csrf:token}}` — CSRF token value
|
|
- `{{csrf:input}}` — hidden `<input>` with CSRF token
|
|
|
|
---
|
|
|
|
## Resource Pipelines
|
|
|
|
Resources are named URL endpoints. HTTP verb pipelines are ordered arrays of steps.
|
|
|
|
```c
|
|
resource("todo", "/todos/:id",
|
|
.all = {input({"id", m_positive, "must be a number"})},
|
|
.get = {
|
|
sqlite_query({"todos_db", "get_todo", "todo", .must_exist = true}),
|
|
mustache("todo", "todo_s"),
|
|
respond("todo_s")
|
|
},
|
|
.patch = {
|
|
input({"title", m_not_empty, "required"}),
|
|
sqlite_query({"todos_db", "update_todo"}),
|
|
redirect("todo")
|
|
},
|
|
.delete = {
|
|
sqlite_query({"todos_db", "delete_todo"}),
|
|
redirect("todos")
|
|
},
|
|
.sse = {"todo:{{id}}", sse(.event = "ready")},
|
|
.errors = {{m_not_found, {
|
|
mustache("404", "not_found_s"),
|
|
respond("not_found_s")
|
|
}}}
|
|
);
|
|
```
|
|
|
|
**Fields:**
|
|
- `.all` — steps that run before every verb pipeline
|
|
- `.get` `.post` `.put` `.patch` `.delete` — verb pipelines
|
|
- `.sse` — persistent SSE channel; first value is channel name
|
|
- `.mime` — default response content type (`m_html`, `m_json`, `m_txt`, `m_sse`, `m_js`)
|
|
- `.errors` / `.repairs` — resource-scoped error and repair handlers
|
|
|
|
Clients can override the verb via `?http_method=patch` — allows HTML forms to reach any verb.
|
|
|
|
---
|
|
|
|
## Pipeline Steps
|
|
|
|
All steps accept `.if_context` and `.unless_context` for conditional execution, and `.table_key` for concurrent fan-out across table rows.
|
|
|
|
### input
|
|
|
|
Validates request parameters against regex. On success, promotes to app scope. On failure, fires nearest error/repair handler with all errors collected.
|
|
|
|
```c
|
|
input(
|
|
{"email", m_email, "must be a valid email"},
|
|
{"title", m_not_empty, "cannot be empty"},
|
|
{"page", m_integer, "must be a number", .fallback = "1"},
|
|
{"filter", "^(active|done)$", .optional = true}
|
|
)
|
|
```
|
|
|
|
**Built-in validators:** `m_not_empty` `m_alpha` `m_alphanumeric` `m_slug` `m_no_html` `m_integer` `m_positive` `m_float` `m_percentage` `m_email` `m_uuid` `m_username` `m_date` `m_time` `m_datetime` `m_url` `m_ipv4` `m_hex_color` `m_zipcode_us` `m_phone_e164` `m_cron` `m_token` `m_base64` `m_boolean` `m_yes_no` `m_on_off`
|
|
|
|
### query
|
|
|
|
Each engine has its own step: `sqlite_query()`, `postgres_query()`, `mysql_query()`, `redis_query()`, `duckdb_query()`. Multiple items in one call run **concurrently**. Values interpolated into SQL (`{{user_id}}`) are bound as prepared-statement parameters.
|
|
|
|
```c
|
|
sqlite_query(
|
|
{"todos_db", "get_todos", "todos_data"},
|
|
{"todos_db", "get_urgent", "urgent", .if_context = "show_urgent"},
|
|
{"todos_db", "get_todo", "todo", .must_exist = true}
|
|
)
|
|
```
|
|
|
|
**Fields:** `.db` (database name) · `.query` (context key with SQL) · `.set_key` (result table key) · `.must_exist` (404 on zero rows) · `.if_context` / `.unless_context`
|
|
|
|
### join
|
|
|
|
Nests records from one context table into each matching record of another (in-memory JOIN). Used when data comes from separate databases or queries.
|
|
|
|
```c
|
|
// after: comments lives inside each blog record
|
|
join("blog", "id", "comments", "blog_id")
|
|
```
|
|
|
|
### fetch
|
|
|
|
Makes HTTP requests and stores responses in context. JSON parses into tables; plain text stored as string. Multiple items run **concurrently**.
|
|
|
|
```c
|
|
fetch(
|
|
{"https://api.weather.dev/now?city={{city}}", "weather"},
|
|
{"https://api.news.dev/headlines", "news"},
|
|
{"https://api.quotes.dev/random", "quote", .if_context = "show_quote"}
|
|
)
|
|
```
|
|
|
|
**Fields:** `.url` · `.set_key` · `.method` (`m_get` `m_post` `m_put` `m_patch` `m_delete` `m_sse_method`) · `.headers` · `.json` (context key → JSON body) · `.text` (context key → plain body)
|
|
|
|
### exec and worker
|
|
|
|
`exec()` — runs a C block or function for business logic, data shaping, setting flags. Runs inline on the reactor.
|
|
|
|
`worker()` — same as `exec()` but dispatches to the shared thread pool for blocking/CPU-bound work.
|
|
|
|
```c
|
|
exec(^(){
|
|
auto rows = get("todos");
|
|
if (table_count(rows) > 5)
|
|
set("is_urgent", "1");
|
|
})
|
|
```
|
|
|
|
### emit
|
|
|
|
Fires an internal pub/sub event. Subscribers in other modules react; no direct coupling.
|
|
|
|
```c
|
|
emit("todo_created")
|
|
```
|
|
|
|
### run
|
|
|
|
Enqueues a named task; the calling pipeline continues immediately.
|
|
|
|
```c
|
|
run("record_daily_stats")
|
|
```
|
|
|
|
### sse
|
|
|
|
Pushes a Server-Sent Event. With `.channel`, broadcasts to all clients on that channel.
|
|
|
|
```c
|
|
sse("todos:{{user_id}}", .event = "todo_updated", .data = {"id: {{todo_id}}", "title: {{title}}"})
|
|
```
|
|
|
|
### render
|
|
|
|
`mustache(template_key, set_key)` — Mustache template
|
|
`mdm(template_key, set_key)` — Markdown + Mustache
|
|
`json(template_key, set_key)` — JSON serialization
|
|
|
|
### respond
|
|
|
|
Sends a context value as the HTTP response.
|
|
|
|
```c
|
|
respond("todos_s")
|
|
respond("not_found_s", .status = m_not_found)
|
|
respond("data_j", .mime = m_json)
|
|
```
|
|
|
|
**Status values:** `m_ok` (200) · `m_created` (201) · `m_redirect` (302) · `m_bad_request` (400) · `m_not_authorized` (401) · `m_not_found` (404) · `m_error` (500)
|
|
|
|
### headers and cookies
|
|
|
|
```c
|
|
headers({{"X-Request-Id", "{{request_id}}"}, {"Cache-Control", "no-store"}}),
|
|
cookies({{"session", "{{session_id}}"}})
|
|
```
|
|
|
|
### redirect and reroute
|
|
|
|
`redirect("todos")` — 302 to the named resource; `:params` read from context.
|
|
`reroute("todos")` — re-enters the router server-side, executes another resource's pipeline in-process.
|
|
|
|
### nest
|
|
|
|
Groups steps into a composite so one `.if_context` applies to all:
|
|
|
|
```c
|
|
nest({sqlite_query({...}), mustache("urgent", "s"), respond("s")}, .if_context = "is_urgent")
|
|
```
|
|
|
|
---
|
|
|
|
## Conditionals
|
|
|
|
Every step accepts `.if_context` (run only when key is present) and `.unless_context` (run only when key is absent). Set flags from `exec()`, key downstream steps off them.
|
|
|
|
```c
|
|
mustache("fragment", "frag_s", .if_context = "is_htmx"),
|
|
respond("frag_s", .if_context = "is_htmx"),
|
|
mustache("full_page", "page_s",.unless_context = "is_htmx"),
|
|
respond("page_s", .unless_context = "is_htmx")
|
|
```
|
|
|
|
---
|
|
|
|
## Iteration
|
|
|
|
`.table_key` names a context table; the step runs once per row **concurrently**, with that row's fields in scope.
|
|
|
|
```c
|
|
// One HTTP request per row in `users`, all concurrent; responses in `profiles`
|
|
fetch({"https://api.users.dev/{{id}}", "profiles", .table_key = "users"})
|
|
```
|
|
|
|
---
|
|
|
|
## Error and Repair Pipelines
|
|
|
|
When a step fails, MACH finds a handler matching the error code — checking resource-scoped handlers first, then module-scoped.
|
|
|
|
- **Errors** — terminal; handler sends response and ends the request.
|
|
- **Repairs** — resumable; fix context, then resume the pipeline after the failure point.
|
|
|
|
```c
|
|
// Resource-scoped
|
|
.errors = {{m_bad_request, {mustache("form", "form_s"), respond("form_s")}}}
|
|
.repairs = {{m_not_authorized, {exec(.call = refresh_session_token)}}}
|
|
|
|
// Module-scoped
|
|
error(m_not_found, {mustache("404", "not_found_s"), respond("not_found_s")});
|
|
repair(m_not_authorized, {exec(.call = refresh_token)});
|
|
```
|
|
|
|
**Built-in codes:** `m_bad_request` (400) · `m_not_authorized` (401) · `m_not_found` (404) · `m_error` (500). Any integer works; define custom codes with `#define`.
|
|
|
|
---
|
|
|
|
## Event Pipelines (Pub/Sub)
|
|
|
|
Cross-module communication with no direct dependency. Activate with `#include <pubsub.h>`.
|
|
|
|
Events are **durable**: undelivered events replay on next boot after a crash.
|
|
|
|
```c
|
|
// Publisher (todos/todos.c)
|
|
publish("todo_created", .with = {"user_id", "title"});
|
|
// ...
|
|
emit("todo_created") // pipeline step
|
|
|
|
// Subscriber (activity/activity.c)
|
|
subscribe("todo_created", {
|
|
sqlite_query({"activity_db", "insert_activity"})
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Task Pipelines
|
|
|
|
Named pipelines that run asynchronously on task reactors. Fire-and-forget; the calling pipeline continues immediately.
|
|
|
|
Tasks are **durable**: context is checkpointed after each step; a crash mid-task resumes on next boot.
|
|
|
|
```c
|
|
// On-demand
|
|
task("recount_todos", {
|
|
sqlite_query({"todos_db", "recount"})
|
|
}, .accepts = {"user_id"}); // pulls these keys from caller
|
|
|
|
// Recurring
|
|
task("daily_digest", {
|
|
sqlite_query({"todos_db", "digest"}),
|
|
emit("digest_ready")
|
|
}, .cron = "0 8 * * *");
|
|
|
|
// Enqueue from a pipeline
|
|
run("recount_todos")
|
|
```
|
|
|
|
---
|
|
|
|
## Modules and Composition
|
|
|
|
A module is a folder with a matching `<name>/<name>.c` file declaring `mach(name){ ... }`.
|
|
|
|
- Modules own their assets, databases, migrations, tasks, event contracts, and middleware.
|
|
- Compose by `#include`ing a module's `.c` file into `main.c`.
|
|
- `middleware(steps)` — shared steps that run on every request in the module.
|
|
- Request execution order: resource `.all` → module `middleware()` → verb pipeline.
|
|
|
|
```c
|
|
// main.c
|
|
#include <mach.h>
|
|
#include "todos/todos.c"
|
|
#include "activity/activity.c"
|
|
|
|
mach(main){
|
|
middleware(session());
|
|
resource("home", "/", .get = {mustache("home", "home_s"), respond("home_s")});
|
|
}
|
|
|
|
// todos/todos.c
|
|
mach(todos){
|
|
middleware(logged_in(), session());
|
|
sqlite_database(.name = "todos_db", .connect = "file:todos.db?mode=rwc",
|
|
.migrations = {"create_todos_table"}, .seeds = {"seed_todos"});
|
|
resource("todos", "/todos",
|
|
.get = {sqlite_query({"todos_db", "get_todos", "todos_data"}), mustache("todos", "todos_s"), respond("todos_s")},
|
|
.post = {input({"title", m_not_empty}), sqlite_query({"todos_db", "create_todo"}), redirect("todos")},
|
|
.errors = {{m_bad_request, {reroute("todos")}}}
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Databases
|
|
|
|
Activate an engine with `#include <sqlite.h>` (or `postgres.h`, `mysql.h`, `redis.h`, `duckdb.h`). Register with `<engine>_database(...)`. Query with `<engine>_query({...})`.
|
|
|
|
Migrations and seeds are forward-only and index-based; tracked in `mach_meta`. Multi-tenant databases use `{{interpolation}}` in `.connect`.
|
|
|
|
```c
|
|
sqlite_database(
|
|
.name = "todos_db",
|
|
.connect = "file:{{user_id}}_todo.db?mode=rwc",
|
|
.migrations = {"create_todos_table", "create_comments_table"},
|
|
.seeds = {"seed_todos"}
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## Imperative API
|
|
|
|
Used inside `exec()` and `worker()` blocks for custom logic.
|
|
|
|
### Context
|
|
```c
|
|
get("key") // returns string or table, nullptr if absent
|
|
set("key", "value") // write to app scope
|
|
has("key") // bool presence check
|
|
format("Hello {{name}}") // interpolate against current context
|
|
```
|
|
|
|
### Memory
|
|
```c
|
|
allocate(256) // arena buffer, freed on request completion
|
|
defer_free(ptr) // schedule free() for external library pointer
|
|
```
|
|
|
|
### Errors
|
|
```c
|
|
error_set("field", (error){m_bad_request, "message"})
|
|
error_get("field") // returns error struct
|
|
error_has("field") // bool
|
|
```
|
|
|
|
### Tables
|
|
```c
|
|
table_new() // empty table in arena
|
|
table_count(t) // row count
|
|
table_get(t, i) // record at index i
|
|
table_add(t, r) // append record
|
|
table_remove(t, r) // remove record
|
|
table_remove_at(t, i) // remove at index
|
|
```
|
|
|
|
### Records
|
|
```c
|
|
record_new() // empty record in arena
|
|
record_get(r, "field") // string value or nullptr
|
|
record_set(r, "field", "value") // write field
|
|
record_remove(r, "field") // remove field
|
|
```
|
|
|
|
---
|
|
|
|
## Bundled Modules
|
|
|
|
### htmx (`#include <htmx.h>`)
|
|
|
|
Serves the htmx runtime as `{{> htmx }}`. Sets `is_htmx` context flag on htmx requests. Pair with `.if_context`/`.unless_context` to return fragments vs full pages.
|
|
|
|
### datastar (`#include <datastar.h>`)
|
|
|
|
Serves the Datastar runtime as `{{> datastar }}`. Provides `datastar_sse()` for pushing reactive fragment and signal patches over an SSE channel.
|
|
|
|
```c
|
|
mustache("todo_row", "todo_row_s"),
|
|
datastar_sse("todos:{{user_id}}",
|
|
.target = "#todo-list",
|
|
.mode = mode_append,
|
|
.elements = "todo_row_s"
|
|
)
|
|
```
|
|
|
|
Patch modes: `mode_outer` `mode_inner` `mode_replace` `mode_prepend` `mode_append` `mode_before` `mode_after` `mode_remove`
|
|
|
|
### tailwind (`#include <tailwind.h>`)
|
|
|
|
Compiles Tailwind classes used in templates. Serves stylesheet as `{{> tailwind }}`. No config or build step required.
|
|
|
|
### session_auth (`#include <session_auth.h>`)
|
|
|
|
Cookie-based authentication as pipeline steps.
|
|
|
|
```c
|
|
session() // middleware: loads user from session cookie into context
|
|
logged_in() // guard: redirects anonymous visitors to login
|
|
login() // action step
|
|
logout() // action step
|
|
signup() // action step
|
|
```
|
|
|
|
---
|
|
|
|
## Static Files
|
|
|
|
Files in `public/` are served directly. Reference with `{{asset:filename}}` for a content-checksummed, cache-busted URL with immutable cache headers.
|
|
|
|
---
|
|
|
|
## External Dependencies
|
|
|
|
Drop third-party C source into `vendor/`. MACH compiles and links them automatically. For non-source dependencies, provide a custom `Dockerfile`.
|
|
|
|
```c
|
|
#include "vendor/cmark/cmark.h"
|
|
|
|
worker(^(){
|
|
auto html = cmark_markdown_to_html(get("markdown"), strlen(get("markdown")), 0);
|
|
defer_free(html);
|
|
set("html_content", html);
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
### Boot-Time Compilation
|
|
|
|
`mach(main)` runs once at boot. Registration calls are processed into a precompiled execution graph. Each incoming request executes a pre-warmed pipeline sequence — no runtime parsing.
|
|
|
|
### Multi-Reactor Architecture
|
|
|
|
- **Request reactors** — one per dedicated CPU core; handles HTTP traffic.
|
|
- **Task reactors** — one per dedicated core; monitors task database, processes cron.
|
|
- **Shared thread pool** — remaining cores; handles `worker()` dispatches (blocking/CPU-bound I/O).
|
|
|
|
### Safe by Default
|
|
|
|
- **Memory** — arena allocators per request; no malloc/free in application code; bounds-checked data structures; 5MB pipeline memory limit (configurable).
|
|
- **SQL injection** — `{{interpolation}}` in SQL is always bound as a prepared-statement parameter.
|
|
- **XSS** — `mustache()`/`mdm()` auto-escape all context values; raw HTML requires explicit `{{{field}}}`.
|
|
- **CSRF** — per-session token verified on state-changing requests; set via `{{csrf:input}}` or `{{csrf:token}}`.
|
|
|
|
---
|
|
|
|
## Tooling
|
|
|
|
| Command | Purpose |
|
|
|---|---|
|
|
| `app_info` | View full app topology |
|
|
| `app_info resources` | List all resources |
|
|
| `app_info pipelines` | Inspect pipelines |
|
|
| `app_info events` | View pub/sub map |
|
|
| `app_info databases` | Inspect schemas |
|
|
| `app_debug` | Interactive pipeline-aware debugger in TUI |
|
|
| `unit_tests` | Criterion-based unit tests |
|
|
| `e2e_tests` | Playwright end-to-end tests |
|
|
| `app_build` | Build minimal production Docker image |
|
|
|
|
Telemetry (traces, logs, errors, profiling) auto-emitted per pipeline step via OpenTelemetry; visualized at port 4000.
|
|
|
|
---
|
|
|
|
## License
|
|
|
|
MACH is licensed under the LGPL.
|