2198 lines
79 KiB
Markdown
2198 lines
79 KiB
Markdown
|
|
|
|||
|
|
# MACH
|
|||
|
|
|
|||
|
|
## Why MACH
|
|||
|
|
|
|||
|
|
MACH (Modern Asynchronous C Hypermedia) is a declarative framework for building asynchronous web applications in C23.
|
|||
|
|
|
|||
|
|
* **No build configuration.** Compilation, hot code reloading, and HMR are handled by the framework. There are no build scripts, package managers, or ORMs to set up. Assets like SQL and HTML templates are discovered automatically.
|
|||
|
|
* **Memory, concurrency, and I/O managed by the framework.** Application code does not call `malloc`/`free` or manage threads, mutexes, or locks. Database queries run as prepared statements. Pipeline steps emit OpenTelemetry spans, logs, and errors automatically.
|
|||
|
|
* **Durable tasks and events.** Both are persisted. If the process crashes, incomplete tasks resume at the step where they left off and undelivered events replay on the next boot.
|
|||
|
|
* **Bundled modules.** Modules for Datastar, HTMX, Tailwind, SQLite, Postgres, MySQL, Redis/Valkey, DuckDB, and auth. Multi-tenant database support is built in.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Table of Contents
|
|||
|
|
* [Quick Start](#quick-start)
|
|||
|
|
* [Philosophy](#philosophy)
|
|||
|
|
* [Guide](#guide)
|
|||
|
|
* [Reference](#reference)
|
|||
|
|
* [Architecture](#architecture)
|
|||
|
|
* [Tooling](#tooling)
|
|||
|
|
* [License](#license)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Quick Start
|
|||
|
|
|
|||
|
|
Everything runs in Docker, no other local dependencies are required.
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
mkdir myapp && cd myapp
|
|||
|
|
wget https://docker.nightshadecoder.dev/mach/compose.yml
|
|||
|
|
|
|||
|
|
# Dev server on :3000, telemetry on :4000
|
|||
|
|
# Includes file watching, auto compilation, hot code reloading, HMR
|
|||
|
|
docker compose up
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Create `main.c` with the example below. MACH watches for changes and hot-reloads on save. Use your own editor, or attach to the built-in TUI with `docker compose attach mach` for an integrated environment with editor, AI, LSP, and console.
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
#include <mach.h>
|
|||
|
|
|
|||
|
|
mach(){
|
|||
|
|
context("hello", "<h1>Hello, world!</h1>");
|
|||
|
|
resource("home", "/", .get = {mustache("hello")});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
The `mach()` function runs once at boot and declares the application by calling into the MACH API. Each call configures MACH: here, `context()` registers a named template inline (`hello`), and `resource()` declares the `home` endpoint that maps `/` to a GET pipeline whose only step renders that template. For a step-by-step walkthrough, see the [Guide](#guide).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Philosophy
|
|||
|
|
|
|||
|
|
An application is a data transformation: input arrives, goes through transformation, and leaves as output. MACH is a thin pipeline wrapper around that path, built to get from input to output with as little overhead as possible.
|
|||
|
|
|
|||
|
|
MACH uses standard formats and tools. Assets are raw SQL, JSON, Markdown, and HTML/CSS/JS via Mustache templates, not ORMs or bespoke formats. Business logic is plain C. The tooling is the same: lldb for debugging, Playwright and Criterion for testing, OpenTelemetry for observability, and OpenCode AI assistant. MACH uses these standard tools and formats and arranges them into pipelines, ordered lists of steps that turn a request into a response.
|
|||
|
|
|
|||
|
|
### Everything is a String
|
|||
|
|
|
|||
|
|
The web is text: HTTP, HTML, JSON, SQL. MACH takes this literally. The pipeline context stores and passes data as strings; there is no intermediate parsing or serialization layer. Data flows through the pipeline as strings, interpolated into SQL, templates, and URLs with `{{context_key}}`.
|
|||
|
|
|
|||
|
|
### CLAD
|
|||
|
|
|
|||
|
|
MACH is organized around four principles.
|
|||
|
|
|
|||
|
|
* **(C)omposable:** small, independent steps chain into feature pipelines.
|
|||
|
|
* **(L)ocality of Behavior:** the behavior of a unit of code is apparent from reading it. SQL, templates, and behavior for a feature live together rather than across separate model, view, and controller trees.
|
|||
|
|
* **(A)utonomous:** modules are self-contained: own schemas, migrations, seeds, routes, UI, and logic. The compiler enforces strict boundaries.
|
|||
|
|
* **(D)omain Based:** each module owns one slice of the app. A `todos` module defines everything related to todos and nothing else.
|
|||
|
|
|
|||
|
|
CLAD is influenced by:
|
|||
|
|
|
|||
|
|
* [Data Oriented Design](https://youtu.be/rX0ItVEVjHc)
|
|||
|
|
* [A Philosophy of Software Design](https://youtu.be/bmSAYlu0NcY)
|
|||
|
|
* [CUPID](https://youtu.be/cyZDLjLuQ9g)
|
|||
|
|
* [Self-Contained Systems](https://youtu.be/Jjrencq8sUQ)
|
|||
|
|
* [Locality of Behavior](https://htmx.org/essays/locality-of-behaviour)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Guide
|
|||
|
|
|
|||
|
|
This walkthrough builds a todo app one concept at a time. It stays high-level and points to the [Reference](#reference) for the full options on each step, helper, and field. MACH discovers assets (SQL, HTML templates, and so on) automatically and seeds each into the context of the module that owns it, based on file location (see [Assets](#assets)).
|
|||
|
|
|
|||
|
|
* [1. Pages and Templates](#1-pages-and-templates)
|
|||
|
|
* [2. Show Data](#2-show-data)
|
|||
|
|
* [3. Accept Input](#3-accept-input)
|
|||
|
|
* [4. Nested Data](#4-nested-data)
|
|||
|
|
* [5. Tasks](#5-tasks)
|
|||
|
|
* [6. Modules and Events](#6-modules-and-events)
|
|||
|
|
* [7. Calling APIs](#7-calling-apis)
|
|||
|
|
|
|||
|
|
### 1. Pages and Templates
|
|||
|
|
|
|||
|
|
`mach()` is the declarative entry point: each `resource(...)` call declares a named URL endpoint, and each verb pipeline is a list of steps. The `mustache("home")` step renders the template asset `home`, which is the file `home.mustache.html`. Reference resources by name with `{{url:...}}`, never by hard-coded path.
|
|||
|
|
|
|||
|
|
Both pages share the same layout, so the `home` page doubles as the layout: it declares the nav and a `{{$body}}` block whose default content is the welcome page, and the `todos` page extends it with `{{<home}}...{{/home}}`, overriding that block. Any template that declares a `{{$block}}` can be a parent; there is no special layout type.
|
|||
|
|
|
|||
|
|
**`home.mustache.html`**
|
|||
|
|
```html
|
|||
|
|
<html>
|
|||
|
|
<body>
|
|||
|
|
<nav><a href='{{url:home}}'>Home</a> · <a href='{{url:todos}}'>My Todos</a></nav>
|
|||
|
|
<main>{{$body}}<h1>Welcome</h1>{{/body}}</main>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`todos.mustache.html`**
|
|||
|
|
```html
|
|||
|
|
{{<home}}
|
|||
|
|
{{$body}}
|
|||
|
|
<h1>My Todos</h1>
|
|||
|
|
<p>Nothing yet.</p>
|
|||
|
|
{{/body}}
|
|||
|
|
{{/home}}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`main.c`**
|
|||
|
|
```c
|
|||
|
|
#include <mach.h>
|
|||
|
|
|
|||
|
|
mach(){
|
|||
|
|
resource("home", "/", .get = {mustache("home")});
|
|||
|
|
resource("todos", "/todos", .get = {mustache("todos")});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
See [Resource Pipelines](#resource-pipelines) for more configuration options, and [Templates](#templates) for the full template feature set.
|
|||
|
|
|
|||
|
|
### 2. Show Data
|
|||
|
|
|
|||
|
|
Bring in the SQLite engine with `#include <sqlite.h>`, declare a database with `sqlite_database(...)`, and read from it with a `sqlite_query()` step. SQL files are assets just like templates: `get_todos.sql` becomes the asset `get_todos`, `seed_todos.sql` becomes `seed_todos`.
|
|||
|
|
|
|||
|
|
Three new SQL files:
|
|||
|
|
|
|||
|
|
**`create_todos_table.sql`**
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE todos (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
title TEXT NOT NULL
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`seed_todos.sql`**
|
|||
|
|
```sql
|
|||
|
|
INSERT INTO todos(title) VALUES('Learn MACH');
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`get_todos.sql`**
|
|||
|
|
```sql
|
|||
|
|
select id, title from todos;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Render the rows MACH stores under `todos_data`:
|
|||
|
|
|
|||
|
|
**`todos.mustache.html`**
|
|||
|
|
```diff
|
|||
|
|
{{<home}}
|
|||
|
|
{{$body}}
|
|||
|
|
<h1>My Todos</h1>
|
|||
|
|
- <p>Nothing yet.</p>
|
|||
|
|
+ <ul>{{#todos_data}}<li>{{title}}</li>{{/todos_data}}</ul>
|
|||
|
|
{{/body}}
|
|||
|
|
{{/home}}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Wire up the module, database, and query:
|
|||
|
|
|
|||
|
|
**`main.c`**
|
|||
|
|
```diff
|
|||
|
|
#include <mach.h>
|
|||
|
|
+#include <sqlite.h>
|
|||
|
|
|
|||
|
|
mach(){
|
|||
|
|
+ sqlite_database(
|
|||
|
|
+ "todos_db",
|
|||
|
|
+ "file:todos.db?mode=rwc",
|
|||
|
|
+ {"create_todos_table"},
|
|||
|
|
+ {"seed_todos"}
|
|||
|
|
+ );
|
|||
|
|
+
|
|||
|
|
resource("home", "/", .get = {mustache("home")});
|
|||
|
|
- resource("todos", "/todos", .get = {mustache("todos")});
|
|||
|
|
+
|
|||
|
|
+ resource("todos", "/todos",
|
|||
|
|
+ .get = {
|
|||
|
|
+ sqlite_query({"todos_db", "get_todos", "todos_data"}),
|
|||
|
|
+ mustache("todos")
|
|||
|
|
+ }
|
|||
|
|
+ );
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
The query's parameters are the database name, the SQL asset, and the context key the result table is stored under (`todos_data`). The `.seeds` entry is the asset name of `seed_todos.sql`. The template walks the result with `{{#todos_data}}...{{/todos_data}}`. Migrations/Seeds run on first connection if needed. See [Databases](#databases) and [query](#query).
|
|||
|
|
|
|||
|
|
### 3. Accept Input
|
|||
|
|
|
|||
|
|
Add a `.post` verb that validates, inserts, and redirects (POST-redirect-GET). A resource-scoped `.errors` handler re-renders the form when validation fails.
|
|||
|
|
|
|||
|
|
**`create_todo.sql`**
|
|||
|
|
```sql
|
|||
|
|
insert into todos(title) values({{title}});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Add the form, repopulating the field and showing the error after a failed submit:
|
|||
|
|
|
|||
|
|
**`todos.mustache.html`**
|
|||
|
|
```diff
|
|||
|
|
{{<home}}
|
|||
|
|
{{$body}}
|
|||
|
|
<h1>My Todos</h1>
|
|||
|
|
<ul>{{#todos_data}}<li>{{title}}</li>{{/todos_data}}</ul>
|
|||
|
|
+ <form method='post' action='{{url:todos}}'>
|
|||
|
|
+ {{csrf:input}}
|
|||
|
|
+ <input name='title' value='{{input:title}}'>
|
|||
|
|
+ {{#error:title}}<span>{{error_message:title}}</span>{{/error:title}}
|
|||
|
|
+ <button>Add</button>
|
|||
|
|
+ </form>
|
|||
|
|
{{/body}}
|
|||
|
|
{{/home}}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Add a `.post` verb and an `.errors` handler:
|
|||
|
|
|
|||
|
|
**`main.c`**
|
|||
|
|
```diff
|
|||
|
|
#include <mach.h>
|
|||
|
|
#include <sqlite.h>
|
|||
|
|
|
|||
|
|
mach(){
|
|||
|
|
sqlite_database(
|
|||
|
|
"todos_db",
|
|||
|
|
"file:todos.db?mode=rwc",
|
|||
|
|
{"create_todos_table"},
|
|||
|
|
{"seed_todos"}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
resource("home", "/", .get = {mustache("home")});
|
|||
|
|
|
|||
|
|
resource("todos", "/todos",
|
|||
|
|
.get = {
|
|||
|
|
sqlite_query({"todos_db", "get_todos", "todos_data"}),
|
|||
|
|
mustache("todos")
|
|||
|
|
- }
|
|||
|
|
+ },
|
|||
|
|
+ .post = {
|
|||
|
|
+ input({"title", m_not_empty}),
|
|||
|
|
+ sqlite_query({"todos_db", "create_todo"}),
|
|||
|
|
+ redirect("todos")
|
|||
|
|
+ },
|
|||
|
|
+ .errors = {
|
|||
|
|
+ {m_bad_request, {reroute("todos")}}
|
|||
|
|
+ }
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`input()` validates and promotes `title` to app scope; the `{{title}}` in `create_todo.sql` is bound as a prepared-statement parameter. On failure, `m_bad_request` triggers the handler, which `reroute`s back into the GET pipeline in-process. The `input:` and `error:` scopes survive the reroute, so the form repopulates with `{{input:title}}` and shows `{{error_message:title}}`. See [input](#input), [Error and Repair Pipelines](#error-and-repair-pipelines), and [redirect and reroute](#redirect-and-reroute).
|
|||
|
|
|
|||
|
|
### 4. Nested Data
|
|||
|
|
|
|||
|
|
A `/todos/:id` page fetches a todo and its comments concurrently, then nests the comments inside the todo with `join()`.
|
|||
|
|
|
|||
|
|
Three new SQL files and one new template:
|
|||
|
|
|
|||
|
|
**`create_comments_table.sql`**
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE comments (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
todo_id INTEGER NOT NULL REFERENCES todos(id),
|
|||
|
|
body TEXT NOT NULL
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`get_todo.sql`**
|
|||
|
|
```sql
|
|||
|
|
select id, title from todos where id = {{id}};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`get_comments.sql`**
|
|||
|
|
```sql
|
|||
|
|
select id, todo_id, body from comments where todo_id = {{id}};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Enter `{{#todo_data}}` first; after the join, `comments` lives inside each todo record:
|
|||
|
|
|
|||
|
|
**`todo.mustache.html`**
|
|||
|
|
```html
|
|||
|
|
{{<home}}
|
|||
|
|
{{$body}}
|
|||
|
|
{{#todo_data}}
|
|||
|
|
<h1>{{title}}</h1>
|
|||
|
|
<h2>Comments</h2>
|
|||
|
|
<ul>{{#comments}}<li>{{body}}</li>{{/comments}}</ul>
|
|||
|
|
{{/todo_data}}
|
|||
|
|
{{/body}}
|
|||
|
|
{{/home}}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Link each list item to its detail page. `{{url:todo}}` resolves to the `todo` resource's pattern (`/todos/:id`) and fills `:id` from the current row in context, so no argument is needed:
|
|||
|
|
|
|||
|
|
**`todos.mustache.html`**
|
|||
|
|
```diff
|
|||
|
|
{{<home}}
|
|||
|
|
{{$body}}
|
|||
|
|
<h1>My Todos</h1>
|
|||
|
|
- <ul>{{#todos_data}}<li>{{title}}</li>{{/todos_data}}</ul>
|
|||
|
|
+ <ul>{{#todos_data}}<li><a href='{{url:todo}}'>{{title}}</a></li>{{/todos_data}}</ul>
|
|||
|
|
<form method='post' action='{{url:todos}}'>
|
|||
|
|
{{csrf:input}}
|
|||
|
|
<input name='title' value='{{input:title}}'>
|
|||
|
|
{{#error:title}}<span>{{error_message:title}}</span>{{/error:title}}
|
|||
|
|
<button>Add</button>
|
|||
|
|
</form>
|
|||
|
|
{{/body}}
|
|||
|
|
{{/home}}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Register the migration and add a `todo` resource:
|
|||
|
|
|
|||
|
|
**`main.c`**
|
|||
|
|
```diff
|
|||
|
|
#include <mach.h>
|
|||
|
|
#include <sqlite.h>
|
|||
|
|
|
|||
|
|
mach(){
|
|||
|
|
sqlite_database(
|
|||
|
|
"todos_db",
|
|||
|
|
"file:todos.db?mode=rwc",
|
|||
|
|
- {"create_todos_table"},
|
|||
|
|
+ {"create_todos_table", "create_comments_table"},
|
|||
|
|
{"seed_todos"}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
resource("home", "/", .get = {mustache("home")});
|
|||
|
|
|
|||
|
|
resource("todos", "/todos",
|
|||
|
|
.get = {
|
|||
|
|
sqlite_query({"todos_db", "get_todos", "todos_data"}),
|
|||
|
|
mustache("todos")
|
|||
|
|
},
|
|||
|
|
.post = {
|
|||
|
|
input({"title", m_not_empty}),
|
|||
|
|
sqlite_query({"todos_db", "create_todo"}),
|
|||
|
|
redirect("todos")
|
|||
|
|
},
|
|||
|
|
.errors = {
|
|||
|
|
{m_bad_request, {reroute("todos")}}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
+
|
|||
|
|
+ resource("todo", "/todos/:id",
|
|||
|
|
+ .get = {
|
|||
|
|
+ input({"id", m_integer}),
|
|||
|
|
+ sqlite_query(
|
|||
|
|
+ {"todos_db", "get_todo", "todo_data", .must_exist = true},
|
|||
|
|
+ {"todos_db", "get_comments", "comments"}
|
|||
|
|
+ ),
|
|||
|
|
+ join("todo_data", "id", "comments", "todo_id"),
|
|||
|
|
+ mustache("todo")
|
|||
|
|
+ }
|
|||
|
|
+ );
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Both queries in one `sqlite_query()` call run concurrently. `join()` lifts `comments` inside each `todo_data` record, so the template reaches `{{#comments}}` from within `{{#todo_data}}`. `.must_exist = true` returns a 404 when the id matches nothing. See [join](#join) and [query](#query).
|
|||
|
|
|
|||
|
|
### 5. Tasks
|
|||
|
|
|
|||
|
|
A task is a named pipeline that runs off the request path on a task reactor. Define it once with an optional `.cron`, and enqueue on-demand runs with the `run("name")` step.
|
|||
|
|
|
|||
|
|
Two new SQL files:
|
|||
|
|
|
|||
|
|
**`create_daily_stats_table.sql`**
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE daily_stats (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
todo_count INTEGER NOT NULL
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`record_daily_stats.sql`**
|
|||
|
|
```sql
|
|||
|
|
insert into daily_stats(todo_count) select count(*) from todos;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Register the migration, define the task, and enqueue it from the POST:
|
|||
|
|
|
|||
|
|
**`main.c`**
|
|||
|
|
```diff
|
|||
|
|
#include <mach.h>
|
|||
|
|
#include <sqlite.h>
|
|||
|
|
+#include <task.h>
|
|||
|
|
|
|||
|
|
mach(){
|
|||
|
|
sqlite_database(
|
|||
|
|
"todos_db",
|
|||
|
|
"file:todos.db?mode=rwc",
|
|||
|
|
- {"create_todos_table", "create_comments_table"},
|
|||
|
|
+ {"create_todos_table", "create_comments_table", "create_daily_stats_table"},
|
|||
|
|
{"seed_todos"}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
+ task("record_daily_stats", {
|
|||
|
|
+ sqlite_query({"todos_db", "record_daily_stats"})
|
|||
|
|
+ }, .cron = "0 0 * * *");
|
|||
|
|
+
|
|||
|
|
resource("home", "/", .get = {mustache("home")});
|
|||
|
|
|
|||
|
|
resource("todos", "/todos",
|
|||
|
|
.get = {
|
|||
|
|
sqlite_query({"todos_db", "get_todos", "todos_data"}),
|
|||
|
|
mustache("todos")
|
|||
|
|
},
|
|||
|
|
.post = {
|
|||
|
|
input({"title", m_not_empty}),
|
|||
|
|
sqlite_query({"todos_db", "create_todo"}),
|
|||
|
|
+ run("record_daily_stats"),
|
|||
|
|
redirect("todos")
|
|||
|
|
},
|
|||
|
|
.errors = {
|
|||
|
|
{m_bad_request, {reroute("todos")}}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
resource("todo", "/todos/:id",
|
|||
|
|
.get = {
|
|||
|
|
input({"id", m_integer}),
|
|||
|
|
sqlite_query(
|
|||
|
|
{"todos_db", "get_todo", "todo_data", .must_exist = true},
|
|||
|
|
{"todos_db", "get_comments", "comments"}
|
|||
|
|
),
|
|||
|
|
join("todo_data", "id", "comments", "todo_id"),
|
|||
|
|
mustache("todo")
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
The `.cron` schedule and the `run("record_daily_stats")` step both run the task on a task reactor, off the request reactors, so the POST returns immediately. Tasks are durable, so a crash mid-task resumes on the next boot. To hand values to a task, list them under `.accepts`. See [Task Pipelines](#task-pipelines).
|
|||
|
|
|
|||
|
|
### 6. Modules and Events
|
|||
|
|
|
|||
|
|
As the app grows, split features into modules that talk through pub/sub events. A module is a folder with a matching `.c` file (`todos/todos.c`) that declares `module(todos){ ... }`, registering the module's resources, databases, tasks, and subscribers. Assets in that folder belong to the module. `main.c` composes modules by `#include`ing each `.c` file; the include is all that is needed, each module registers itself automatically.
|
|||
|
|
|
|||
|
|
This step moves todos into its own module and adds an `activity` module that records an entry whenever a todo is created. Move the todos assets into `todos/`, add the new `activity/` folder, and leave `home` (which is also the shared layout) at the root:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
.
|
|||
|
|
├── todos/
|
|||
|
|
│ ├── todos.c
|
|||
|
|
│ ├── todos.mustache.html
|
|||
|
|
│ ├── todo.mustache.html
|
|||
|
|
│ ├── create_todos_table.sql
|
|||
|
|
│ ├── seed_todos.sql
|
|||
|
|
│ ├── get_todos.sql
|
|||
|
|
│ ├── get_todo.sql
|
|||
|
|
│ ├── get_comments.sql
|
|||
|
|
│ └── create_todo.sql
|
|||
|
|
├── activity/
|
|||
|
|
│ ├── activity.c
|
|||
|
|
│ ├── activity.mustache.html
|
|||
|
|
│ ├── create_activity_table.sql
|
|||
|
|
│ ├── get_activities.sql
|
|||
|
|
│ └── insert_activity.sql
|
|||
|
|
├── home.mustache.html
|
|||
|
|
└── main.c
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`main.c` collapses to a thin composer. Including a module's `.c` file registers it automatically, so there are no setup calls left in `mach()`:
|
|||
|
|
|
|||
|
|
**`main.c`**
|
|||
|
|
```diff
|
|||
|
|
#include <mach.h>
|
|||
|
|
-#include <sqlite.h>
|
|||
|
|
-#include <task.h>
|
|||
|
|
+#include "todos/todos.c"
|
|||
|
|
+#include "activity/activity.c"
|
|||
|
|
|
|||
|
|
mach(){
|
|||
|
|
- sqlite_database(
|
|||
|
|
- "todos_db",
|
|||
|
|
- "file:todos.db?mode=rwc",
|
|||
|
|
- {"create_todos_table", "create_comments_table", "create_daily_stats_table"},
|
|||
|
|
- {"seed_todos"}
|
|||
|
|
- );
|
|||
|
|
-
|
|||
|
|
- task("record_daily_stats", {
|
|||
|
|
- sqlite_query({"todos_db", "record_daily_stats"})
|
|||
|
|
- }, .cron = "0 0 * * *");
|
|||
|
|
-
|
|||
|
|
resource("home", "/", .get = {mustache("home")});
|
|||
|
|
-
|
|||
|
|
- resource("todos", "/todos",
|
|||
|
|
- .get = {
|
|||
|
|
- sqlite_query({"todos_db", "get_todos", "todos_data"}),
|
|||
|
|
- mustache("todos")
|
|||
|
|
- },
|
|||
|
|
- .post = {
|
|||
|
|
- input({"title", m_not_empty}),
|
|||
|
|
- sqlite_query({"todos_db", "create_todo"}),
|
|||
|
|
- run("record_daily_stats"),
|
|||
|
|
- redirect("todos")
|
|||
|
|
- },
|
|||
|
|
- .errors = {
|
|||
|
|
- {m_bad_request, {reroute("todos")}}
|
|||
|
|
- }
|
|||
|
|
- );
|
|||
|
|
-
|
|||
|
|
- resource("todo", "/todos/:id",
|
|||
|
|
- .get = {
|
|||
|
|
- input({"id", m_integer}),
|
|||
|
|
- sqlite_query(
|
|||
|
|
- {"todos_db", "get_todo", "todo_data", .must_exist = true},
|
|||
|
|
- {"todos_db", "get_comments", "comments"}
|
|||
|
|
- ),
|
|||
|
|
- join("todo_data", "id", "comments", "todo_id"),
|
|||
|
|
- mustache("todo")
|
|||
|
|
- }
|
|||
|
|
- );
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Add an Activity link to the shared nav in `home`:
|
|||
|
|
|
|||
|
|
**`home.mustache.html`**
|
|||
|
|
```diff
|
|||
|
|
<html>
|
|||
|
|
<body>
|
|||
|
|
- <nav><a href='{{url:home}}'>Home</a> · <a href='{{url:todos}}'>My Todos</a></nav>
|
|||
|
|
+ <nav><a href='{{url:home}}'>Home</a> · <a href='{{url:todos}}'>My Todos</a> · <a href='{{url:activity}}'>Activity</a></nav>
|
|||
|
|
<main>{{$body}}<h1>Welcome</h1>{{/body}}</main>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
The todos logic moves into the module unchanged, gaining a `publish()`, and an `emit()` step. Both todo resources (the list and the detail page from step 4) come along:
|
|||
|
|
|
|||
|
|
**`todos/todos.c`**
|
|||
|
|
```c
|
|||
|
|
#include <mach.h>
|
|||
|
|
#include <sqlite.h>
|
|||
|
|
#include <pubsub.h>
|
|||
|
|
#include <task.h>
|
|||
|
|
|
|||
|
|
module(todos){
|
|||
|
|
sqlite_database(
|
|||
|
|
"todos_db",
|
|||
|
|
"file:todos.db?mode=rwc",
|
|||
|
|
{"create_todos_table", "create_comments_table", "create_daily_stats_table"},
|
|||
|
|
{"seed_todos"}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
publish("todo_created", .with = {"title"});
|
|||
|
|
|
|||
|
|
task("record_daily_stats", {
|
|||
|
|
sqlite_query({"todos_db", "record_daily_stats"})
|
|||
|
|
}, .cron = "0 0 * * *");
|
|||
|
|
|
|||
|
|
resource("todos", "/todos",
|
|||
|
|
.get = {
|
|||
|
|
sqlite_query({"todos_db", "get_todos", "todos_data"}),
|
|||
|
|
mustache("todos")
|
|||
|
|
},
|
|||
|
|
.post = {
|
|||
|
|
input({"title", m_not_empty}),
|
|||
|
|
sqlite_query({"todos_db", "create_todo"}),
|
|||
|
|
run("record_daily_stats"),
|
|||
|
|
emit("todo_created"),
|
|||
|
|
redirect("todos")
|
|||
|
|
},
|
|||
|
|
.errors = {
|
|||
|
|
{m_bad_request, {reroute("todos")}}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
resource("todo", "/todos/:id",
|
|||
|
|
.get = {
|
|||
|
|
input({"id", m_integer}),
|
|||
|
|
sqlite_query(
|
|||
|
|
{"todos_db", "get_todo", "todo_data", .must_exist = true},
|
|||
|
|
{"todos_db", "get_comments", "comments"}
|
|||
|
|
),
|
|||
|
|
join("todo_data", "id", "comments", "todo_id"),
|
|||
|
|
mustache("todo")
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
The `activity` module owns its own table, query, template, and event subscriber. Nothing in it references the todos module:
|
|||
|
|
|
|||
|
|
**`activity/create_activity_table.sql`**
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE activities (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
kind TEXT NOT NULL,
|
|||
|
|
ref TEXT NOT NULL,
|
|||
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`activity/insert_activity.sql`**
|
|||
|
|
```sql
|
|||
|
|
insert into activities(kind, ref) values('created', {{title}});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`activity/get_activities.sql`**
|
|||
|
|
```sql
|
|||
|
|
select kind, ref, created_at from activities order by created_at desc;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`activity/activity.mustache.html`**
|
|||
|
|
```html
|
|||
|
|
{{<home}}
|
|||
|
|
{{$body}}
|
|||
|
|
<h1>Activity</h1>
|
|||
|
|
<ul>{{#activities}}<li>{{kind}}: {{ref}} ({{created_at}})</li>{{/activities}}</ul>
|
|||
|
|
{{/body}}
|
|||
|
|
{{/home}}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`activity/activity.c`**
|
|||
|
|
```c
|
|||
|
|
#include <mach.h>
|
|||
|
|
#include <sqlite.h>
|
|||
|
|
#include <pubsub.h>
|
|||
|
|
|
|||
|
|
module(activity){
|
|||
|
|
sqlite_database(
|
|||
|
|
"activity_db",
|
|||
|
|
"file:activity.db?mode=rwc",
|
|||
|
|
{"create_activity_table"}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
subscribe("todo_created", {
|
|||
|
|
sqlite_query({"activity_db", "insert_activity"})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
resource("activity", "/activity",
|
|||
|
|
.get = {
|
|||
|
|
sqlite_query({"activity_db", "get_activities", "activities"}),
|
|||
|
|
mustache("activity")
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
When the POST calls `emit("todo_created")`, MACH propagates the keys named in `publish(...).with` (here `title`) to every subscriber. The `activity` module writes its row, with no direct link between the two modules. Events are durable: undelivered ones replay after a crash. Adding a third subscriber is a new file with its own `subscribe(...)`; the publisher does not change. See [Modules and Composition](#modules-and-composition) and [Event Pipelines](#event-pipelines).
|
|||
|
|
|
|||
|
|
### 7. Calling APIs
|
|||
|
|
|
|||
|
|
`fetch()` calls external HTTP services like a query calls a database. JSON parses into context tables; multiple items in one `fetch()` run concurrently.
|
|||
|
|
|
|||
|
|
Show the responses on the home page:
|
|||
|
|
|
|||
|
|
**`home.mustache.html`**
|
|||
|
|
```diff
|
|||
|
|
<html>
|
|||
|
|
<body>
|
|||
|
|
<nav><a href='{{url:home}}'>Home</a> · <a href='{{url:todos}}'>My Todos</a> · <a href='{{url:activity}}'>Activity</a></nav>
|
|||
|
|
- <main>{{$body}}<h1>Welcome</h1>{{/body}}</main>
|
|||
|
|
+ <main>{{$body}}
|
|||
|
|
+ <h1>Welcome</h1>
|
|||
|
|
+ {{#weather}}<p>{{city}}: {{precision:temp_c:0}}°C, {{conditions}}</p>{{/weather}}
|
|||
|
|
+ {{#quote}}<blockquote>{{content}}, {{author}}</blockquote>{{/quote}}
|
|||
|
|
+ {{/body}}</main>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Fetch both services concurrently before rendering:
|
|||
|
|
|
|||
|
|
**`main.c`**
|
|||
|
|
```diff
|
|||
|
|
#include <mach.h>
|
|||
|
|
#include "todos/todos.c"
|
|||
|
|
#include "activity/activity.c"
|
|||
|
|
|
|||
|
|
mach(){
|
|||
|
|
- resource("home", "/", .get = {mustache("home")});
|
|||
|
|
+ resource("home", "/",
|
|||
|
|
+ .get = {
|
|||
|
|
+ fetch(
|
|||
|
|
+ {"https://api.quotes.dev/random", "quote"},
|
|||
|
|
+ {"https://api.weather.dev/now", "weather"}
|
|||
|
|
+ ),
|
|||
|
|
+ mustache("home")
|
|||
|
|
+ }
|
|||
|
|
+ );
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Both requests run concurrently under one `fetch()` call; the JSON parses into context tables the template walks with `{{#quote}}` and `{{#weather}}`. `fetch()` also supports other verbs, headers, request bodies, and interpolated URLs. See [fetch](#fetch).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
## Reference
|
|||
|
|
|
|||
|
|
* [Context](#context)
|
|||
|
|
* [Templates](#templates)
|
|||
|
|
* [Assets](#assets)
|
|||
|
|
* [Databases](#databases)
|
|||
|
|
* [Resource Pipelines](#resource-pipelines)
|
|||
|
|
* [Pipeline Steps](#pipeline-steps)
|
|||
|
|
* [Imperative API](#imperative-api)
|
|||
|
|
* [Conditionals](#conditionals)
|
|||
|
|
* [Iteration](#iteration)
|
|||
|
|
* [Error and Repair Pipelines](#error-and-repair-pipelines)
|
|||
|
|
* [Event Pipelines](#event-pipelines)
|
|||
|
|
* [Task Pipelines](#task-pipelines)
|
|||
|
|
* [Modules and Composition](#modules-and-composition)
|
|||
|
|
* [Module Reference](#module-reference)
|
|||
|
|
* [Static Files](#static-files)
|
|||
|
|
* [External Dependencies](#external-dependencies)
|
|||
|
|
|
|||
|
|
### Context
|
|||
|
|
|
|||
|
|
Pipelines read from and write to a shared, scoped key-value store that lives for the duration of one request. Every step draws inputs from context and writes outputs back to it.
|
|||
|
|
|
|||
|
|
The context uses three scopes: `input:xxx` for raw request parameters, `error:xxx` for validation/error data, and unprefixed names for app scope (query results, validated inputs, computed values). The `input()` step promotes values from the `input:` scope to app scope.
|
|||
|
|
|
|||
|
|
Keys are also scoped by module, and resolve **bottom-up** along the module chain. A step looks up a key in its own pipeline's module first, then in each ancestor module out to the root `main`, and uses the nearest match. This applies to every value in context. A pipeline can therefore read a key owned by its own module or by any ancestor, but never one owned by a sibling or descendant. A module that sets a key already present in an ancestor shadows the ancestor's value for its own pipelines, while other modules keep resolving to the ancestor's. This keeps shared values such as a root `home` layout reachable everywhere, while a module's internal keys stay private to it and its descendants.
|
|||
|
|
|
|||
|
|

|
|||
|
|
|
|||
|
|
### Templates
|
|||
|
|
|
|||
|
|
MACH uses Mustache and MDM (Mustache + Markdown) templates (others can be added via modules). MACH supports the full Mustache base spec with one exception: dot notation. Use a section instead. `{{a.b}}` does not work; `{{#a}}{{b}}{{/a}}` does.
|
|||
|
|
|
|||
|
|
Supported base-spec features:
|
|||
|
|
- **Interpolation**: `{{name}}` (HTML-escaped), `{{{name}}}` or `{{&name}}` (unescaped).
|
|||
|
|
- **Sections**: `{{#name}}...{{/name}}` renders when truthy and iterates over arrays.
|
|||
|
|
- **Inverted sections**: `{{^name}}...{{/name}}` renders when falsy or empty.
|
|||
|
|
- **Comments**: `{{! ignored }}`.
|
|||
|
|
- **Set delimiters**: `{{=<% %>=}}`.
|
|||
|
|
- **Partials**: `{{>name}}` inlines the asset named `name`, rendered against the current scope.
|
|||
|
|
- **Layout inheritance**: `{{<parent}}{{$block}}override{{/block}}{{/parent}}` renders `parent` with each `{{$block}}default{{/block}}` block in the parent replaced by the override here. Any asset that declares blocks can be a parent.
|
|||
|
|
|
|||
|
|
No Mustache extensions are supported. There are no lambdas, no `{{#if}}` / `{{else}}`, no `{{#each}}`. Branching and iteration come from sections; counts and conditions are computed in SQL or `exec()` and rendered as plain context values.
|
|||
|
|
|
|||
|
|
On top of base Mustache, templates and other interpolated strings support built-in helpers with `{{helper:args}}` syntax. Arguments are given in order, colon-separated; each can be a literal or a context key.
|
|||
|
|
|
|||
|
|
**`{{precision:field:N}}`**: format a numeric value with N decimal places.
|
|||
|
|
```html
|
|||
|
|
<p>Total: ${{precision:total:2}}</p>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`{{input:field}}`**: raw, unvalidated request parameter from the `input` scope. Typically used to repopulate form fields after a validation error.
|
|||
|
|
```html
|
|||
|
|
<input name='title' value='{{input:title}}'>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`{{error:field}}`**: truthy when `field` has an error. Used as a Mustache section to conditionally render markup.
|
|||
|
|
```html
|
|||
|
|
{{#error:title}}<span class='error'>invalid</span>{{/error:title}}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`{{error_message:field}}`**: human-readable message for a field error, from `input()`'s message or from `error_set()`.
|
|||
|
|
```html
|
|||
|
|
<span>{{error_message:title}}</span>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`{{error_code:field}}`**: HTTP status code associated with a field error (e.g. `400`, `404`).
|
|||
|
|
```html
|
|||
|
|
<p>Code: {{error_code:title}}</p>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`{{url:name}}`**: resolve a resource name to its URL. `:params` in the URL pattern are read from the current scope by name; no other arguments.
|
|||
|
|
```html
|
|||
|
|
<a href='{{url:todos}}'>All</a> <!-- /todos -->
|
|||
|
|
{{#todos_data}}<a href='{{url:todo}}'>{{title}}</a>{{/todos_data}} <!-- /todos/:id, filled per row -->
|
|||
|
|
{{#todo}}<a href='{{url:todo}}'>{{title}}</a>{{/todo}} <!-- /todos/:id, from a single record -->
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`{{asset:filename}}`**: resolve a file in `public/` to a cache-busted URL (content checksum + immutable cache headers). See [Static Files](#static-files).
|
|||
|
|
```html
|
|||
|
|
<link rel='stylesheet' href='{{asset:styles.css}}'>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`{{csrf:token}}`**: emit a CSRF token, for use in URL query strings. Generates a random hash, sets it on an httponly/secure/samesite cookie, and outputs the same value inline.
|
|||
|
|
```html
|
|||
|
|
<a href='{{url:logout}}?csrf={{csrf:token}}'>Log out</a>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`{{csrf:input}}`**: emit a hidden `<input>` carrying a CSRF token, for use inside a `<form>`. Same cookie-setting behavior as `{{csrf:token}}`.
|
|||
|
|
```html
|
|||
|
|
<form>{{csrf:input}}<input name='title'><button>Add</button></form>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> **CSRF verification is automatic.** MACH checks that the incoming token (from the form field or query parameter) matches the value stored in the CSRF cookie and rejects mismatches with a 403. The cookie is httponly, secure, and samesite, so nothing beyond emitting `{{csrf:token}}` or `{{csrf:input}}` in the rendered response is required.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Assets
|
|||
|
|
|
|||
|
|
Every file in the project that is not a `.c` source file is an asset. Assets are embedded into the binary at compile time and loaded into context at startup, each under its name, in the module it belongs to, exactly as if `context(name, contents)` had been called there. From that point it is an ordinary context value. Common assets are Mustache templates (`*.mustache.html`), Markdown (`*.md`), and SQL (`*.sql`), but the rule is general: any non-`.c` file is handled this way.
|
|||
|
|
|
|||
|
|
An asset's name is its filename's basename, the part before the first dot. So `get_todos.sql` seeds the key `get_todos`, `todos.mustache.html` seeds `todos`, and `home.md` seeds `home`. Steps read these like any other context value: `mustache("todos")`, `sqlite_query({"todos_db", "get_todos", "todos_data"})`, and the `.migrations`/`.seeds` entries on a database (`{"create_todos_table"}`). A file is just one way to put a value in context, and the most common.
|
|||
|
|
|
|||
|
|
Steps take a context key, `mustache()`, `mdm()`, and the engine `*_query()` steps each read a string from context by key and interpret it: `mustache()` as a Mustache template, `mdm()` as Markdown-with-Mustache, `*_query()` as SQL. They do not care how the string got there; any source that puts a value in context works. The step interprets whatever is under the key at the moment it runs.
|
|||
|
|
|
|||
|
|
`context(name, value)` does the same seeding as a file, from a string instead, useful for content too small to warrant its own file. Declared at boot inside `mach()` or a `module()`, it seeds the key in that module and is read like any other value. A template: `context("hello", "<h1>Hello, world!</h1>")` then `mustache("hello")`. A query: `context("ping", "select 1")` then `sqlite_query({"db", "ping"})`.
|
|||
|
|
|
|||
|
|
An asset is seeded into context in the module whose folder it sits in. A folder is a module when it contains a matching `<folder>.c` file (so `todos/` is a module when `todos/todos.c` exists); folders without one are organizational only and pass their files up to the enclosing module. The project root is the `main` module, defined by `main.c`. Walking up from a file's location, it is seeded in the nearest module folder:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
.
|
|||
|
|
├── main.c # the "main" module (root)
|
|||
|
|
├── home.mustache.html # → main
|
|||
|
|
├── partials/
|
|||
|
|
│ └── footer.mustache.html # → main (partials/ has no partials.c)
|
|||
|
|
└── todos/
|
|||
|
|
├── todos.c # the "todos" module
|
|||
|
|
├── todos.mustache.html # → todos
|
|||
|
|
└── detail/
|
|||
|
|
└── todo.mustache.html # → todos (detail/ has no detail.c)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Once seeded, the key is read like any other context value, including how it resolves across modules and shadows ancestors; see [Context](#context).
|
|||
|
|
|
|||
|
|
### Databases
|
|||
|
|
|
|||
|
|
Each database engine is a module: `#include` its header (e.g. `#include <sqlite.h>`), which activates it, then register a database with its `<engine>_database(...)` call (for example `sqlite_database(...)`). Migrations and Seeds are forward-only and index-based: they run in array order, each applied once, with new migrations and seeds appended to the end. Both are tracked in a `mach_meta` table.
|
|||
|
|
|
|||
|
|
Multi-tenant databases use `{{interpolation}}` in `.connect`. Connections are pooled with LRU eviction: active tenants stay warm, idle connections are reclaimed.
|
|||
|
|
|
|||
|
|
**`.name`**: identifier referenced by the first value of `query()` steps.
|
|||
|
|
```c
|
|||
|
|
.name = "todos_db"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.connect`**: engine-specific connection string. Supports `{{interpolation}}` for multi-tenancy.
|
|||
|
|
```c
|
|||
|
|
.connect = "file:{{user_id}}_todo.db?mode=rwc"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.migrations`**: array of SQL migration entries, applied once each in order. Each entry is a context key holding the SQL (typically a `.sql` file).
|
|||
|
|
```c
|
|||
|
|
.migrations = {"create_todos_table", "create_comments_table"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.seeds`**: array of SQL seed entries, applied once each in order. Each entry is a context key holding the SQL (typically a `.sql` file).
|
|||
|
|
```c
|
|||
|
|
.seeds = {"seed_todos"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined:
|
|||
|
|
```c
|
|||
|
|
#include <sqlite.h>
|
|||
|
|
|
|||
|
|
sqlite_database(
|
|||
|
|
"blog_db",
|
|||
|
|
"file:{{user_id}}_blog.db?mode=rwc",
|
|||
|
|
{"create_blogs_table", "create_comments_table"},
|
|||
|
|
{"seed_blogs"}
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Engine include / query / register:** `#include <sqlite.h>` + `sqlite_query()` + `sqlite_database()`, and likewise `postgres_*`, `mysql_*`, `redis_*`, `duckdb_*`.
|
|||
|
|
|
|||
|
|

|
|||
|
|
|
|||
|
|
### Resource Pipelines
|
|||
|
|
|
|||
|
|
MACH is resource-based, not route-based. Each `resource(...)` call defines a named URL endpoint with HTTP verb pipelines. Resources are identified by name. `{{url:name}}`, `redirect()`, and `reroute()` all take only the resource name; any `:params` in the URL pattern are read from the current scope by matching key names. Path specificity is automatic: exact matches (`/todos/active`) take priority over parameterized matches (`/todos/:id`) regardless of definition order.
|
|||
|
|
|
|||
|
|
Clients select a verb via the request method, or by passing `http_method` as a query/form parameter. This lets HTML forms (limited to GET/POST) reach any verb, and gives SSE a connection path: `/todos?http_method=sse`.
|
|||
|
|
|
|||
|
|
**Resource name *(by order)***: resource identifier used by `{{url:name}}`, `redirect()`, and `reroute()`.
|
|||
|
|
```c
|
|||
|
|
resource("todos", "/todos", .get = { ... });
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**URL pattern *(by order)***: URL pattern. Supports `:params`.
|
|||
|
|
```c
|
|||
|
|
resource("todo", "/todos/:id", .get = { ... });
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.all`**: shared steps that run before every verb pipeline on the resource.
|
|||
|
|
```c
|
|||
|
|
resource("todo", "/todos/:id",
|
|||
|
|
.all = { input({"id", m_integer, "must be a number"}) },
|
|||
|
|
.get = { ... },
|
|||
|
|
.delete = { ... }
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.mime`**: default response content type for the resource. Values: `m_html`, `m_txt`, `m_sse`, `m_json`, `m_js` (default `m_html`).
|
|||
|
|
```c
|
|||
|
|
resource("feed", "/feed.json", .mime = m_json, .get = { ... });
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.get` `.post` `.put` `.patch` `.delete`**: verb pipelines: ordered arrays of steps that transform a request into a response.
|
|||
|
|
```c
|
|||
|
|
resource("todos", "/todos",
|
|||
|
|
.get = { sqlite_query({"db", "get_todos", "todos_data"}), mustache("todos") },
|
|||
|
|
.post = { input({"title", m_not_empty}), redirect("todos") }
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.sse`**: persistent SSE channel. The first value is the channel name (supports `{{interpolation}}`); any remaining steps run on connect.
|
|||
|
|
```c
|
|||
|
|
resource("todos", "/todos",
|
|||
|
|
.sse = {"todos:{{user_id}}",
|
|||
|
|
sqlite_query({"db", "get_todos", "todos_data"}),
|
|||
|
|
sse(.event = "initial", .data = {"{{todos_data}}"})
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.errors` / `.repairs`**: resource-scoped error and repair pipelines. See [Error and Repair Pipelines](#error-and-repair-pipelines).
|
|||
|
|
```c
|
|||
|
|
resource("todos", "/todos",
|
|||
|
|
.post = { ... },
|
|||
|
|
.errors = {{m_bad_request, {mustache("form")}}}
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined:
|
|||
|
|
```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") },
|
|||
|
|
.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")}}}
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Pipeline Steps
|
|||
|
|
|
|||
|
|
Steps are the units of work in a pipeline. Each receives the current context, acts on it, and passes control to the next. All steps accept `.if_context` and `.unless_context` for [conditional execution](#conditionals), and `.table_key` for concurrent fan-out across rows of a context table (see [Iteration](#iteration)).
|
|||
|
|
|
|||
|
|
> **Concurrency comes from multiple items inside one step, not from multiple steps in sequence.** `sqlite_query({...}, {...})` runs both queries concurrently; two back-to-back `sqlite_query({...})` steps run serially. The same applies to `fetch()` and to `.table_key` iteration.
|
|||
|
|
|
|||
|
|
* [input](#input)
|
|||
|
|
* [query](#query)
|
|||
|
|
* [join](#join)
|
|||
|
|
* [fetch](#fetch)
|
|||
|
|
* [exec and worker](#exec-and-worker)
|
|||
|
|
* [emit](#emit)
|
|||
|
|
* [run](#run)
|
|||
|
|
* [sse](#sse)
|
|||
|
|
* [render](#render)
|
|||
|
|
* [headers and cookies](#headers-and-cookies)
|
|||
|
|
* [redirect and reroute](#redirect-and-reroute)
|
|||
|
|
* [nest](#nest)
|
|||
|
|
|
|||
|
|

|
|||
|
|
|
|||
|
|
#### input
|
|||
|
|
|
|||
|
|
Checks request parameters (query string, form body, URL params) against regex patterns. On success, each value is promoted from `input:name` to app scope. On failure, errors land in `error:name` and a `400 Bad Request` triggers the nearest [error/repair pipeline](#error-and-repair-pipelines). All validations in one call complete before the error fires, so all errors are available together for form re-rendering.
|
|||
|
|
|
|||
|
|
Built-in regex macros are defined in `mach.h`; define your own the same way: `#define m_zipcode "^\\d{5}$"`.
|
|||
|
|
|
|||
|
|
**`.param_key` *(by order)***: name of the parameter to validate.
|
|||
|
|
```c
|
|||
|
|
input({"title", "^\\S+$", "required"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.matches` *(by order)***: regex pattern, or a built-in validator macro.
|
|||
|
|
```c
|
|||
|
|
input({"email", m_email, "bad email"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.message` *(by order)***: human-readable error shown via `{{error_message:name}}`.
|
|||
|
|
```c
|
|||
|
|
input({"age", m_integer, "must be a number"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.optional`**: skip validation when the parameter is absent.
|
|||
|
|
```c
|
|||
|
|
input({"filter", "^(active|done)$", .optional = true})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.fallback`**: default value injected when the parameter is absent.
|
|||
|
|
```c
|
|||
|
|
input({"page", m_integer, .fallback = "1"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined:
|
|||
|
|
```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)$", "must be 'active' or 'done'", .optional = true},
|
|||
|
|
{"username", m_username, "must be alphanumeric"}
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
For checks beyond regex (uniqueness, cross-field rules, lookups), pair `input()` with a query and `exec()`:
|
|||
|
|
```c
|
|||
|
|
input({"username", m_username, "must be alphanumeric"}),
|
|||
|
|
sqlite_query({"users_db", "find_username", "existing"}),
|
|||
|
|
exec(^(){
|
|||
|
|
auto rows = get("existing");
|
|||
|
|
if (rows && table_count(rows) > 0)
|
|||
|
|
error_set("username", (error){m_bad_request, "already taken"});
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Built-in validators:**
|
|||
|
|
- Strings: `m_not_empty`, `m_alpha`, `m_alphanumeric`, `m_slug`, `m_no_html`
|
|||
|
|
- Numbers: `m_integer`, `m_positive`, `m_float`, `m_percentage`
|
|||
|
|
- Identity: `m_email`, `m_uuid`, `m_username`
|
|||
|
|
- Dates & times: `m_date`, `m_time`, `m_datetime`
|
|||
|
|
- Web: `m_url`, `m_ipv4`, `m_hex_color`
|
|||
|
|
- Codes: `m_zipcode_us`, `m_phone_e164`, `m_cron`
|
|||
|
|
- Security: `m_token`, `m_base64`
|
|||
|
|
- Boolean: `m_boolean`, `m_yes_no`, `m_on_off`
|
|||
|
|
|
|||
|
|
#### query
|
|||
|
|
|
|||
|
|
Each database engine provides its own query step: `sqlite_query()`, `postgres_query()`, `mysql_query()`, `redis_query()`, `duckdb_query()`. All share the same `query_config` shape. Given in order, the first value is the database `.name`, the second is the context key holding the SQL to run, and the third is the `.set_key` the result table is stored under (even single-row results are tables). Multiple items in a single step run **concurrently**. Queries use prepared statements; interpolated `{{values}}` are bound, not spliced. For transactions, put `BEGIN`/`COMMIT`/`ROLLBACK` directly in the SQL.
|
|||
|
|
|
|||
|
|
**`.db` *(by order)***: name of the database, matching the name a `<engine>_database(...)` was registered with.
|
|||
|
|
```c
|
|||
|
|
sqlite_query({"todos_db", "get_todos", "todos_data"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.query` *(by order)***: context key holding the SQL to run, from any source.
|
|||
|
|
```c
|
|||
|
|
sqlite_query({"todos_db", "get_todos", "todos_data"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.set_key` *(by order)***: context key for the result table. Optional; omit it when the result isn't needed (e.g. an insert without `RETURNING`).
|
|||
|
|
```c
|
|||
|
|
sqlite_query({"todos_db", "create_todo"}) // no result captured
|
|||
|
|
sqlite_query({"todos_db", "get_todos", "todos_data"}) // result under "todos_data"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.must_exist`**: when true, raise `404 Not Found` if the query affects/returns zero rows. Default is false.
|
|||
|
|
```c
|
|||
|
|
sqlite_query({"todos_db", "get_todo", "todo", .must_exist = true})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.if_context` / `.unless_context`** *(per item)*: conditionally include or skip individual queries while running the others concurrently.
|
|||
|
|
```c
|
|||
|
|
sqlite_query(
|
|||
|
|
{"db", "get_todos", "todos_data"},
|
|||
|
|
{"db", "get_urgent", "urgent", .if_context = "show_urgent"}
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined:
|
|||
|
|
```c
|
|||
|
|
sqlite_query(
|
|||
|
|
{"todos_db", "get_todos", "todos_data"},
|
|||
|
|
{"todos_db", "get_todo", "todo", .must_exist = true},
|
|||
|
|
{"todos_db", "get_urgent", "urgent", .if_context = "show_urgent"}
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### join
|
|||
|
|
|
|||
|
|
Nests records from one context table into each matching record of another, like a SQL JOIN performed in memory. Useful when records come from separate databases or queries and need to be combined. After the step, each outer record gains a new field holding its matched inner records.
|
|||
|
|
|
|||
|
|
**`.parent_key`**: outer table whose records receive nested children.
|
|||
|
|
```c
|
|||
|
|
.parent_key = "projects"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.field_key`**: field on the outer table to match against.
|
|||
|
|
```c
|
|||
|
|
.field_key = "id"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.child_key`**: inner table whose records get nested.
|
|||
|
|
```c
|
|||
|
|
.child_key = "todos"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.child_field_key`**: field on the inner table that points at the outer.
|
|||
|
|
```c
|
|||
|
|
.child_field_key = "project_id"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.join_field_key`**: new field on outer records holding the matched inner records. (defaults to .child_key)
|
|||
|
|
```c
|
|||
|
|
.join_field_key = "todos"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined:
|
|||
|
|
```c
|
|||
|
|
join("projects", "id", "todos", "project_id")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Full context example.** Concurrent query → `join()` → `mustache()`: fetch parent and children from separate queries, then render them as one nested structure. Blog + comments, single database:
|
|||
|
|
|
|||
|
|
**`blog.mustache.html`**
|
|||
|
|
```html
|
|||
|
|
<article>
|
|||
|
|
{{#blog}}
|
|||
|
|
<h1>{{title}}</h1>
|
|||
|
|
<div>{{content}}</div>
|
|||
|
|
<h2>Comments</h2>
|
|||
|
|
<ul>{{#comments}}<li>{{body}}</li>{{/comments}}</ul>
|
|||
|
|
{{/blog}}
|
|||
|
|
</article>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
resource("blog", "/blogs/:id",
|
|||
|
|
.get = {
|
|||
|
|
input({"id", m_integer}),
|
|||
|
|
|
|||
|
|
// Fetch both concurrently: one query() call, two items
|
|||
|
|
sqlite_query(
|
|||
|
|
{"blog_db", "get_blog", "blog"},
|
|||
|
|
{"blog_db", "get_comments", "comments"}
|
|||
|
|
),
|
|||
|
|
|
|||
|
|
// Nest each comment into its matching blog record
|
|||
|
|
join("blog", "id", "comments", "blog_id"),
|
|||
|
|
|
|||
|
|
// Enter {{#blog}} first; after join(), comments lives INSIDE each blog record
|
|||
|
|
mustache("blog")
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Shape of the context at each step:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
after query(): { blog: [{id, title, content}],
|
|||
|
|
comments: [{id, blog_id, body}, ...] } // two sibling tables
|
|||
|
|
|
|||
|
|
after join(): { blog: [{id, title, content,
|
|||
|
|
comments: [{id, blog_id, body}, ...]}] } // nested inside blog
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### fetch
|
|||
|
|
|
|||
|
|
Makes one or more HTTP requests and stores responses in context. JSON is parsed into tables and records (with nested tables for nested JSON); plain-text responses are stored as a string. Like `query()`, multiple items in a single step run **concurrently**, so a page can fan out to several services and join the results downstream.
|
|||
|
|
|
|||
|
|
**`.url` *(by order)***: request URL; supports `{{interpolation}}`.
|
|||
|
|
```c
|
|||
|
|
fetch({"https://api.weather.dev/forecast?city={{city}}", "w"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.set_key`**: context key for the response.
|
|||
|
|
```c
|
|||
|
|
fetch({"https://api.weather.dev/now", "weather"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.method`**: HTTP method. Defaults to `m_get`. Values: `m_get`, `m_post`, `m_put`, `m_patch`, `m_delete`, `m_sse_method`
|
|||
|
|
```c
|
|||
|
|
fetch({"https://api.dev/charge", "r", m_post})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.headers`**: array of name/value pairs.
|
|||
|
|
```c
|
|||
|
|
fetch({"https://api.dev/me", "r", .headers = {{"Authorization", "Bearer {{token}}"}}})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.json`**: context key serialized as the JSON request body.
|
|||
|
|
```c
|
|||
|
|
fetch({"https://api.dev/charge", "receipt", m_post, "order"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.text`**: context key sent as the plain-text request body.
|
|||
|
|
```c
|
|||
|
|
fetch({"https://api.dev/log", "r", m_post, .text = "raw_body"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.if_context` / `.unless_context`** *(per item)*: conditionally include or skip individual requests while running the others concurrently.
|
|||
|
|
```c
|
|||
|
|
fetch(
|
|||
|
|
{"https://api.weather.dev/now", "weather"},
|
|||
|
|
{"https://api.quotes.dev/random", "quote", .if_context = "show_quote"}
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined, single request:
|
|||
|
|
```c
|
|||
|
|
fetch({"https://api.payments.dev/charge",
|
|||
|
|
"receipt",
|
|||
|
|
m_post,
|
|||
|
|
"order",
|
|||
|
|
{
|
|||
|
|
{"Authorization", "Bearer {{api_key}}"},
|
|||
|
|
{"Idempotency-Key", "{{order_id}}"}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined, concurrent fan-out:
|
|||
|
|
```c
|
|||
|
|
fetch(
|
|||
|
|
{"https://api.weather.dev/now?city={{city}}", "weather"},
|
|||
|
|
{"https://api.news.dev/headlines?topic={{topic}}", "news"},
|
|||
|
|
{"https://api.quotes.dev/random", "quote"}
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### exec and worker
|
|||
|
|
|
|||
|
|
`exec()` calls a C function or block with access to the context via the Imperative API. It is where business logic and data shaping lives: enriching query results with computed fields, aggregating across rows, transforming data between steps, setting flags for [conditional](#conditionals) downstream steps. To trigger an error/repair pipeline from inside, call `error_set()`.
|
|||
|
|
|
|||
|
|
`worker()` takes the same configuration as `exec()` but is for blocking or CPU-bound work such as calling external C libraries, blocking I/O, or heavy computation. The work is dispatched to the shared thread pool, which releases the reactor; the pipeline resumes on the original reactor when the call returns. Use `worker()` when the body would otherwise stall a request reactor.
|
|||
|
|
|
|||
|
|
**Block *(by order)***: inline block, for short logic specific to this pipeline. Here, attaching each challenger's opponent id so the template can render two voting forms with the right winner/loser pairing:
|
|||
|
|
```c
|
|||
|
|
exec(^(){
|
|||
|
|
auto const t = get("challengers");
|
|||
|
|
auto const p0 = table_get(t, 0);
|
|||
|
|
auto const p1 = table_get(t, 1);
|
|||
|
|
record_set(p0, "opponent_id", record_get(p1, "id"));
|
|||
|
|
record_set(p1, "opponent_id", record_get(p0, "id"));
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.call`**: reference to a named C function, for logic reuse across pipelines.
|
|||
|
|
```c
|
|||
|
|
exec(.call = assign_opponents)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Inside `exec`/`worker` blocks and functions, context, memory, errors, tables, and records are manipulated through the [Imperative API](#imperative-api).
|
|||
|
|
|
|||
|
|
#### emit
|
|||
|
|
|
|||
|
|
Triggers an internal pub/sub event. Subscribers in other modules react in their `subscribe()` pipelines, with no direct dependency on the emitter. See [Event Pipelines](#event-pipelines).
|
|||
|
|
|
|||
|
|
**Event name *(by order)***: name of the event to publish.
|
|||
|
|
```c
|
|||
|
|
emit("todo_created")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### run
|
|||
|
|
|
|||
|
|
Adds a named job to the task database; the calling pipeline continues immediately. Task reactors pick up queued jobs and execute their pipelines. The task must be defined elsewhere with a `task(name, { ... })` registration. See [Task Pipelines](#task-pipelines).
|
|||
|
|
|
|||
|
|
**Task name *(by order)***: name of a defined task.
|
|||
|
|
```c
|
|||
|
|
run("record_daily_stats")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### sse
|
|||
|
|
|
|||
|
|
Pushes a Server-Sent Event. With `.channel`, the event is broadcast to all clients on that channel. Without `.channel`, the event is returned directly to the requesting client. See [Resource Pipelines](#resource-pipelines).
|
|||
|
|
|
|||
|
|
**`.channel` *(by order)***: channel to broadcast on; supports `{{interpolation}}`.
|
|||
|
|
```c
|
|||
|
|
sse("todos:{{user_id}}", .event = "new_todo", .data = {"{{todo}}"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.event`**: SSE `event:` line value.
|
|||
|
|
```c
|
|||
|
|
sse(.event = "ping")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.data`**: array of strings, one per SSE `data:` line (multi-line data).
|
|||
|
|
```c
|
|||
|
|
sse(.event = "msg", .data = {"line one", "line two"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.comment`**: SSE `:` comment line value, useful for keep-alives.
|
|||
|
|
```c
|
|||
|
|
sse(.comment = "keep-alive")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined:
|
|||
|
|
```c
|
|||
|
|
sse("todos:{{user_id}}",
|
|||
|
|
.event = "todo_updated",
|
|||
|
|
.data = {"id: {{todo_id}}", "title: {{title}}"},
|
|||
|
|
.comment = "broadcast at {{timestamp}}"
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### render
|
|||
|
|
|
|||
|
|
Outputs a template using the current context. `mustache()` renders Mustache; `mdm()` renders Markdown-with-Mustache; `json()` renders json. All take the same `render_config`.
|
|||
|
|
|
|||
|
|
**`.context_key` *(by order)***: key of the context value to render, from any source. The value is the template string itself.
|
|||
|
|
```c
|
|||
|
|
mustache("todos")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.status`**: HTTP response status (defaults to `m_ok`). 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).
|
|||
|
|
```c
|
|||
|
|
mustache("not_found", .status = m_not_found)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.mime`**: override the response content type. Values: `m_html`, `m_txt`, `m_sse`, `m_json`, `m_js`
|
|||
|
|
```c
|
|||
|
|
mustache("plain", .mime = m_txt)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Json:
|
|||
|
|
```c
|
|||
|
|
json("todos")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Markdown-with-Mustache:
|
|||
|
|
```c
|
|||
|
|
context("welcome", "# Welcome, {{user_name}}");
|
|||
|
|
mdm("welcome")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### headers and cookies
|
|||
|
|
|
|||
|
|
Set HTTP response headers and cookies declaratively. Both accept an array of name/value pairs; values support `{{interpolation}}`.
|
|||
|
|
|
|||
|
|
**Pairs *(by order)***: array of `{name, value}` entries.
|
|||
|
|
```c
|
|||
|
|
headers({{"X-Request-Id", "{{request_id}}"}})
|
|||
|
|
```
|
|||
|
|
```c
|
|||
|
|
cookies({{"session", "{{session_id}}"}})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined:
|
|||
|
|
```c
|
|||
|
|
headers({
|
|||
|
|
{"X-Request-Id", "{{request_id}}"},
|
|||
|
|
{"Cache-Control", "no-store"}
|
|||
|
|
}),
|
|||
|
|
cookies({
|
|||
|
|
{"session", "{{session_id}}"},
|
|||
|
|
{"theme", "{{theme}}"}
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### redirect and reroute
|
|||
|
|
|
|||
|
|
`redirect()` returns a 302 to the client, causing the browser to navigate. `reroute()` re-enters the router server-side, executing another resource's pipeline within the same request. Both take only the target resource name. `:params` in the target's URL pattern are read from the current context by matching key names; no other arguments.
|
|||
|
|
|
|||
|
|
**Resource name *(by order)***: target resource name. Required `:params` are read from context by name.
|
|||
|
|
```c
|
|||
|
|
redirect("todos") // 302 to /todos
|
|||
|
|
redirect("todo") // 302 to /todos/{{id}}, id read from context
|
|||
|
|
redirect("org_todo") // 302 to /orgs/{{org}}/todos/{{id}}, org and id read from context
|
|||
|
|
reroute("todo") // run that pipeline in-process, id read from context
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### nest
|
|||
|
|
|
|||
|
|
Groups multiple steps into a single composite step. Useful when applying one `.if_context`/`.unless_context` to several steps, to avoid repeating the condition on each.
|
|||
|
|
|
|||
|
|
**`.steps` *(by order)***: array of steps that run as a unit.
|
|||
|
|
```c
|
|||
|
|
nest({sqlite_query({...}), emit("urgent_todo"), mustache("urgent")})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.if_context` / `.unless_context`**: condition applied to the whole group.
|
|||
|
|
```c
|
|||
|
|
nest({sqlite_query({...}), emit("urgent_todo"), mustache("urgent")},
|
|||
|
|
.if_context = "is_urgent")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Imperative API
|
|||
|
|
|
|||
|
|
Functions called from `exec()`/`worker()` blocks and `.call` functions to read and write context, allocate memory, raise errors, and manipulate tables and records.
|
|||
|
|
|
|||
|
|
* [context](#context-1)
|
|||
|
|
* [memory](#memory)
|
|||
|
|
* [errors](#errors)
|
|||
|
|
* [tables](#tables)
|
|||
|
|
* [records](#records)
|
|||
|
|
|
|||
|
|
#### context
|
|||
|
|
|
|||
|
|
Read, write, and test context keys, and resolve `{{interpolation}}` against the current scope.
|
|||
|
|
|
|||
|
|
**`get(name)`**: returns the value stored under `name`, or `nullptr` if absent. The returned pointer is whatever was stored: a `string` for scalars, a `table` for query and fetch results.
|
|||
|
|
```c
|
|||
|
|
auto todos = get("todos");
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`set(name, value)`**: writes `value` to `name`, exposing it to downstream steps and to templates.
|
|||
|
|
```c
|
|||
|
|
set("is_urgent", "1");
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`has(name)`**: returns true when `name` exists in the current scope.
|
|||
|
|
```c
|
|||
|
|
if (has("user_id")) { ... }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`format(fmt)`**: returns `fmt` with `{{name}}` interpolations resolved against the current context. Same scopes and helpers as templates.
|
|||
|
|
```c
|
|||
|
|
auto greeting = format("Hello, {{user_name}}");
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined:
|
|||
|
|
```c
|
|||
|
|
exec(^(){
|
|||
|
|
auto rows = get("todos");
|
|||
|
|
if (table_count(rows) > 5) {
|
|||
|
|
set("is_urgent", "1");
|
|||
|
|
set("banner", format("{{user_name}} has more than 5 open todos"));
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### memory
|
|||
|
|
|
|||
|
|
Pipeline-arena allocation and deferred cleanup of foreign pointers. Both clear when the request completes.
|
|||
|
|
|
|||
|
|
**`allocate(bytes)`**: returns a buffer from the pipeline arena. Reclaimed automatically on request completion.
|
|||
|
|
```c
|
|||
|
|
auto buf = allocate(256);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`defer_free(ptr)`**: schedules `free()` for a pointer returned by an external library. Runs when the arena is released.
|
|||
|
|
```c
|
|||
|
|
auto out = third_party_alloc(256);
|
|||
|
|
defer_free(out);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined:
|
|||
|
|
```c
|
|||
|
|
worker(^(){
|
|||
|
|
auto url = allocate(512);
|
|||
|
|
build_signed_url(url, 512, get("path"));
|
|||
|
|
set("signed_url", url);
|
|||
|
|
|
|||
|
|
auto raw = third_party_render_md(get("markdown"));
|
|||
|
|
defer_free(raw);
|
|||
|
|
set("html", raw);
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### errors
|
|||
|
|
|
|||
|
|
Raise field-scoped errors from inside `exec()` to trigger error/repair pipelines. The keys land in the `error:name` scope, visible to templates as `{{error:name}}`, `{{error_code:name}}`, and `{{error_message:name}}`.
|
|||
|
|
|
|||
|
|
**`error_set(name, err)`**: associates an error with `name` and triggers the nearest [error or repair pipeline](#error-and-repair-pipelines).
|
|||
|
|
```c
|
|||
|
|
error_set("token", (error){ m_bad_request, "token has expired" });
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`error_get(name)`**: returns the `error` previously set on `name`.
|
|||
|
|
```c
|
|||
|
|
auto e = error_get("token");
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`error_has(name)`**: returns true when `name` has an error.
|
|||
|
|
```c
|
|||
|
|
if (error_has("token")) { ... }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined:
|
|||
|
|
```c
|
|||
|
|
exec(^(){
|
|||
|
|
auto token = get("token");
|
|||
|
|
if (!token || strlen(token) < 16) {
|
|||
|
|
error_set("token", (error){
|
|||
|
|
m_bad_request,
|
|||
|
|
"token must be at least 16 characters"
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### tables
|
|||
|
|
|
|||
|
|
Tables are ordered collections of records, the shape `query()` produces and `fetch()` parses JSON into. Use these to build derived results.
|
|||
|
|
|
|||
|
|
**`table_new()`**: returns an empty table in the pipeline arena.
|
|||
|
|
```c
|
|||
|
|
auto t = table_new();
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`table_count(t)`**: number of records in `t`.
|
|||
|
|
```c
|
|||
|
|
auto n = table_count(get("todos"));
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`table_get(t, i)`**: record at index `i`, or `nullptr` if out of range.
|
|||
|
|
```c
|
|||
|
|
auto first = table_get(get("todos"), 0);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`table_add(t, r)`**: appends `r` to `t`.
|
|||
|
|
```c
|
|||
|
|
table_add(t, record_new());
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`table_remove(t, r)`**: removes record `r` from `t`.
|
|||
|
|
```c
|
|||
|
|
table_remove(t, r);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`table_remove_at(t, i)`**: removes the record at index `i`.
|
|||
|
|
```c
|
|||
|
|
table_remove_at(t, 0);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined:
|
|||
|
|
```c
|
|||
|
|
exec(^(){
|
|||
|
|
auto source = get("raw_users");
|
|||
|
|
auto active = table_new();
|
|||
|
|
for (int i = 0; i < table_count(source); i++) {
|
|||
|
|
auto u = table_get(source, i);
|
|||
|
|
auto status = record_get(u, "status");
|
|||
|
|
if (status && strcmp(status, "active") == 0) {
|
|||
|
|
table_add(active, u);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
set("active_users", active);
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### records
|
|||
|
|
|
|||
|
|
Records are name-value bags, the shape of one row from `query()` or one object from `fetch()`. All values are strings; see [Everything is a String](#everything-is-a-string).
|
|||
|
|
|
|||
|
|
**`record_new()`**: returns an empty record in the pipeline arena.
|
|||
|
|
```c
|
|||
|
|
auto r = record_new();
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`record_get(r, name)`**: string value of `name`, or `nullptr` if absent.
|
|||
|
|
```c
|
|||
|
|
auto title = record_get(r, "title");
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`record_set(r, name, value)`**: writes `value` to `name` on `r`.
|
|||
|
|
```c
|
|||
|
|
record_set(r, "title", "New title");
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`record_remove(r, name)`**: removes `name` from `r`.
|
|||
|
|
```c
|
|||
|
|
record_remove(r, "draft");
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined:
|
|||
|
|
```c
|
|||
|
|
exec(^(){
|
|||
|
|
auto todos = get("todos");
|
|||
|
|
for (int i = 0; i < table_count(todos); i++) {
|
|||
|
|
auto t = table_get(todos, i);
|
|||
|
|
auto title = record_get(t, "title");
|
|||
|
|
if (title && strlen(title) > 40) {
|
|||
|
|
record_set(t, "is_long", "1");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Conditionals
|
|||
|
|
|
|||
|
|
Every step accepts `.if_context` and `.unless_context`, which name a context variable. They work for any context value: validated inputs, query results, framework flags such as `is_htmx`, or flags set from `exec()`.
|
|||
|
|
|
|||
|
|
**`.if_context`**: context key. Step runs only when the value is present.
|
|||
|
|
```c
|
|||
|
|
mustache("fragment", .if_context = "is_htmx")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.unless_context`**: context key. Step runs only when the value is absent.
|
|||
|
|
```c
|
|||
|
|
mustache("full_page", .unless_context = "is_htmx")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
For multi-state branching, set context flags from `exec()`, then key downstream steps off them:
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
exec(.call = classify_todo),
|
|||
|
|
mustache("urgent_confirmation", .if_context = "is_urgent"),
|
|||
|
|
mustache("standard_confirmation", .unless_context = "is_urgent")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Iteration
|
|||
|
|
|
|||
|
|
`.table_key` names a context table to iterate over. The step runs once per row with that row's fields in scope, and all rows run **concurrently**, the same as multiple items in `query()` or `fetch()`. With a `.set_key`, results are collected into a table aligned with the input, one entry per row.
|
|||
|
|
|
|||
|
|
**`.table_key`**: name of a context table to iterate over.
|
|||
|
|
```c
|
|||
|
|
// One request per row in `users`, all concurrent.
|
|||
|
|
// Responses collected into `profiles`, aligned with `users`.
|
|||
|
|
fetch({"https://api.users.dev/{{id}}", "profiles", .table_key = "users"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Use `.table_key` for per-row work that doesn't fit a single query or request, such as external APIs without bulk endpoints.
|
|||
|
|
|
|||
|
|
### Error and Repair Pipelines
|
|||
|
|
|
|||
|
|
When a pipeline step fails, execution halts and MACH looks for a handler matching the error code. Handlers can be registered at different scopes: on the resource (the `.errors`/`.repairs` fields) and on a module (an `error()`/`repair()` call). Handlers are not context values, but they are found the same way context keys resolve ([Context](#context)): MACH starts at the failing step's own pipeline, walks out through each module to the root, and uses the first matching handler it finds. A handler at a nearer scope therefore overrides one for the same code further out, so a resource can handle a code locally while leaving every other resource on the module or root handler.
|
|||
|
|
|
|||
|
|
Errors are terminal: the handler sends a response and ends the request. Repairs are resumable: they fix the context and resume the original pipeline at the step after the failure. Repairs are resolved first by the same bottom-up search; if no matching repair is found, resolution falls through to errors. Unhandled errors fall through to MACH's internal handler, which renders the error message as `text/plain` with the error code as the HTTP status, and surfaces in the TUI console and telemetry.
|
|||
|
|
|
|||
|
|
The `error` scope is shared across `input()` failures and `error_set()` calls: `{{error:name}}`, `{{error_code:name}}`, `{{error_message:name}}`. The raw input value remains available in `input:name` for re-rendering forms.
|
|||
|
|
|
|||
|
|
**Resource-scoped (`.errors` / `.repairs` fields):**
|
|||
|
|
```c
|
|||
|
|
resource("todos", "/todos",
|
|||
|
|
.post = { ... },
|
|||
|
|
.errors = {
|
|||
|
|
{m_not_found, {mustache("404")}},
|
|||
|
|
{m_bad_request, {mustache("form")}}
|
|||
|
|
},
|
|||
|
|
.repairs = {
|
|||
|
|
{m_not_authorized, {exec(.call = refresh_session_token)}}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Module/root-scoped (`error()` / `repair()` calls):**
|
|||
|
|
```c
|
|||
|
|
mach(){
|
|||
|
|
error(m_error, {mustache("5xx")});
|
|||
|
|
error(m_not_found, {mustache("404")});
|
|||
|
|
repair(m_not_authorized, {exec(.call = refresh_session_token)});
|
|||
|
|
// ... resources ...
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Built-in error codes:** `m_ok` (200), `m_created` (201), `m_redirect` (302), `m_bad_request` (400), `m_not_authorized` (401), `m_not_found` (404), `m_error` (500). Any integer works; the `m_*` constants are convenience names. Define your own for domain-specific errors, e.g. `#define err_quota_exceeded 723`.
|
|||
|
|
|
|||
|
|

|
|||
|
|
|
|||
|
|
### Event Pipelines
|
|||
|
|
|
|||
|
|
Internal pub/sub for cross-module communication. The publisher does not know who listens; the subscriber does not know who emits. Adding a subscriber means adding a new module with a `subscribe(...)` call, with no changes to the publisher. Activate the system with `#include <pubsub.h>` in any module that publishes or subscribes.
|
|||
|
|
|
|||
|
|
Events are durable by default. When a publisher is declared anywhere in the app, MACH creates a `mach_events` database to track delivery. If the process crashes, undelivered events are replayed on the next boot.
|
|||
|
|
|
|||
|
|
**`publish(event, .with = {...})`**: declares an outbound event contract. The first value is the event name; `.with` lists context keys to pass along.
|
|||
|
|
```c
|
|||
|
|
publish("todo_created", .with = {"user_id", "title"});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`subscribe(event, { steps })`**: registers a subscriber pipeline keyed by event name.
|
|||
|
|
```c
|
|||
|
|
subscribe("todo_created", {
|
|||
|
|
sqlite_query({"activity_db", "insert_activity"})
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`emit(event)`**: a pipeline step that fires the event (see [emit](#emit)).
|
|||
|
|
```c
|
|||
|
|
emit("todo_created")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.errors` / `.repairs`** *(per subscriber)*: each `subscribe(...)` can declare its own handlers, resolved with the same bottom-up search as resource pipelines. See [Error and Repair Pipelines](#error-and-repair-pipelines).
|
|||
|
|
```c
|
|||
|
|
subscribe("todo_created", {
|
|||
|
|
sqlite_query({"activity_db", "insert_activity"})
|
|||
|
|
}, .errors = {{m_error, {exec(.call = log_subscriber_failure)}}});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined:
|
|||
|
|
```c
|
|||
|
|
// todos/todos.c: publisher
|
|||
|
|
module(todos){
|
|||
|
|
publish("todo_created", .with = {"user_id", "title"});
|
|||
|
|
publish("todo_deleted", .with = {"user_id", "todo_id"});
|
|||
|
|
|
|||
|
|
resource("todos", "/todos",
|
|||
|
|
.post = {
|
|||
|
|
input({"title", m_not_empty}),
|
|||
|
|
sqlite_query({"todos_db", "insert_todo"}),
|
|||
|
|
emit("todo_created"),
|
|||
|
|
redirect("todos")
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// activity/activity.c: subscriber
|
|||
|
|
module(activity){
|
|||
|
|
subscribe("todo_created", {
|
|||
|
|
sqlite_query({"activity_db", "insert_created_activity"})
|
|||
|
|
});
|
|||
|
|
subscribe("todo_deleted", {
|
|||
|
|
sqlite_query({"activity_db", "insert_deleted_activity"})
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|

|
|||
|
|
|
|||
|
|
### Task Pipelines
|
|||
|
|
|
|||
|
|
Tasks are named pipelines that run asynchronously on task reactors. Fire-and-forget: the calling pipeline continues immediately. Defined at module or root scope. Triggered on demand with the `run("name")` step or on a schedule via `.cron`. Tasks can enqueue more tasks.
|
|||
|
|
|
|||
|
|
A task is registered with `task(name, { pipeline }, ...)`: the name, a pipeline body, and optional `.cron`/`.accepts`. Registration and enqueuing are separate calls: `task(...)` defines the task, and the `run("name")` step ([enqueue step](#run)) enqueues it from inside a pipeline.
|
|||
|
|
|
|||
|
|
Tasks are durable: when a task is defined, MACH creates a `mach_tasks` database and checkpoints the context after each step. A crash mid-task resumes at the step where it left off on the next boot.
|
|||
|
|
|
|||
|
|
**Task name *(by order)***: task identifier, enqueued via `run("name")`.
|
|||
|
|
```c
|
|||
|
|
task("recount", {
|
|||
|
|
sqlite_query({"db", "recount_todos"})
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Pipeline *(by order)***: the task's pipeline body, the second value, a brace block.
|
|||
|
|
```c
|
|||
|
|
task("name", { sqlite_query({...}), emit("done"), run("followup") });
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.accepts`**: context keys to pull from the caller into the task.
|
|||
|
|
```c
|
|||
|
|
task("recount_todos", {
|
|||
|
|
sqlite_query({"db", "recount"})
|
|||
|
|
}, .accepts = {"user_id"});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.cron`**: standard cron schedule for recurring tasks (no caller required).
|
|||
|
|
```c
|
|||
|
|
task("daily_digest", {
|
|||
|
|
sqlite_query({"db", "digest"})
|
|||
|
|
}, .cron = "0 8 * * *");
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.errors` / `.repairs`** *(per task)*: each task can declare its own handlers, resolved with the same bottom-up search as resource pipelines. See [Error and Repair Pipelines](#error-and-repair-pipelines).
|
|||
|
|
```c
|
|||
|
|
task("send_invoice", {
|
|||
|
|
fetch({"https://api.billing.dev/invoices/{{invoice_id}}", "inv"})
|
|||
|
|
}, .repairs = {{m_not_authorized, {exec(.call = refresh_billing_token)}}});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined:
|
|||
|
|
```c
|
|||
|
|
// on-demand: enqueued via run("recount_todos")
|
|||
|
|
task("recount_todos", {
|
|||
|
|
sqlite_query({"todos_db", "recount"})
|
|||
|
|
}, .accepts = {"user_id"});
|
|||
|
|
|
|||
|
|
// recurring: runs on schedule, no caller
|
|||
|
|
task("daily_digest", {
|
|||
|
|
sqlite_query({"todos_db", "digest"}),
|
|||
|
|
emit("digest_ready")
|
|||
|
|
}, .cron = "0 8 * * *");
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Modules and Composition
|
|||
|
|
|
|||
|
|
The root `main.c` defines the app with the `mach()` macro, the entry point. A module is declared with the `module(name)` macro in a `name/name.c` file. A module owns its own resources, databases, migrations, tasks, event contracts, and middleware. Compose by `#include`ing a module's `.c` file.
|
|||
|
|
|
|||
|
|
A module owns the files that sit in its folder, which are seeded into its context at startup. Which module a file seeds is covered under [Assets](#assets); how a step's keys resolve across modules is covered under [Context](#context).
|
|||
|
|
|
|||
|
|
**`module(name)`**: declares a module. The body registers the module's resources, databases, tasks, and subscribers.
|
|||
|
|
```c
|
|||
|
|
// todos/todos.c
|
|||
|
|
module(todos){
|
|||
|
|
// resources, databases, tasks, subscribers ...
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`middleware(step)`**: registers a shared step that runs on every request within the current config scope. Cross-cutting setup like session loading or tenant resolution lives here. Call once per step.
|
|||
|
|
```c
|
|||
|
|
mach(){ middleware(session()); /* modules, resources, ... */ }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`error(...)` / `repair(...)`**: config-scoped error and repair handlers (see [Error and Repair Pipelines](#error-and-repair-pipelines)). Resolved bottom-up with resource and module handlers; the innermost handler wins.
|
|||
|
|
|
|||
|
|
**Pipeline composition.** A request composes shared steps from every config layer with the resource's own steps and the verb pipeline, executing **bottom-up**: resource `.all` steps first, then enclosing `middleware()` from innermost out (module → root), then the verb pipeline.
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
// main.c: root config
|
|||
|
|
#include <mach.h>
|
|||
|
|
#include <session_auth.h>
|
|||
|
|
#include "todos/todos.c"
|
|||
|
|
|
|||
|
|
mach(){
|
|||
|
|
middleware(session());
|
|||
|
|
|
|||
|
|
resource("home", "/", .get = {mustache("home")});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// todos/todos.c: resources require login
|
|||
|
|
#include <mach.h>
|
|||
|
|
#include <sqlite.h>
|
|||
|
|
#include <session_auth.h>
|
|||
|
|
|
|||
|
|
module(todos){
|
|||
|
|
resource("todos", "/todos",
|
|||
|
|
.all = {logged_in()},
|
|||
|
|
.get = { sqlite_query({"todos_db", "get_todos", "todos_data"}), mustache("todos") },
|
|||
|
|
.post = { input({"title", m_not_empty}), sqlite_query({"todos_db", "create_todo"}), redirect("todos") }
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
resource("todo", "/todos/:id",
|
|||
|
|
.all = {
|
|||
|
|
logged_in(),
|
|||
|
|
input({"id", m_positive})
|
|||
|
|
},
|
|||
|
|
.delete = { sqlite_query({"todos_db", "delete_todo", .must_exist = true}), redirect("todos") }
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
For `GET /todos/5` the executed order is: `logged_in()`, `input({"id", ...})` (resource `.all`), `session()` (root `middleware`), then the verb pipeline `sqlite_query({"get_todo", ..., .must_exist = true})`, `mustache("todo")`.
|
|||
|
|
|
|||
|
|
**Complete module file.** A module registers the same kinds of things the root does (resources, databases, tasks, subscribers). A `blogs/blogs.c`:
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
#include <mach.h>
|
|||
|
|
#include <sqlite.h>
|
|||
|
|
|
|||
|
|
module(blogs){
|
|||
|
|
sqlite_database(
|
|||
|
|
"blog_db",
|
|||
|
|
"file:blogs.db?mode=rwc",
|
|||
|
|
{"create_blogs_table", "create_comments_table"}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
resource("blog", "/blogs/:id",
|
|||
|
|
.get = {...}
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Bring the module into scope by `#include`ing its `.c` file from `main.c`; the include registers it:
|
|||
|
|
```c
|
|||
|
|
// main.c
|
|||
|
|
#include <mach.h>
|
|||
|
|
#include "blogs/blogs.c"
|
|||
|
|
|
|||
|
|
mach(){}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
A typical project layout:
|
|||
|
|
```
|
|||
|
|
├── todos/ # todos module (folder + matching .c)
|
|||
|
|
│ ├── todos.c # module(todos){ ... }
|
|||
|
|
│ ├── todos.mustache.html # → todos
|
|||
|
|
│ ├── create_todos_table.sql # → todos
|
|||
|
|
│ └── get_todos.sql # → todos
|
|||
|
|
├── activity/ # activity module
|
|||
|
|
│ └── activity.c
|
|||
|
|
├── home.mustache.html # → main (also the shared layout)
|
|||
|
|
├── public/ # static files, served directly
|
|||
|
|
│ └── favicon.png
|
|||
|
|
└── main.c # mach() { ... }, the main module, composes the rest
|
|||
|
|
```
|
|||
|
|
|
|||
|
|

|
|||
|
|

|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Module Reference
|
|||
|
|
|
|||
|
|
Bundled modules. Activate each by `#include`ing its header; the include registers it.
|
|||
|
|
|
|||
|
|
* [htmx](#htmx)
|
|||
|
|
* [datastar](#datastar)
|
|||
|
|
* [tailwind](#tailwind)
|
|||
|
|
* [session_auth](#session_auth)
|
|||
|
|
* [Database engines](#database-engines)
|
|||
|
|
|
|||
|
|
#### htmx
|
|||
|
|
|
|||
|
|
Activate with `#include <htmx.h>`. Serves the htmx runtime and exposes it as the `{{>htmx}}` partial, and sets the `is_htmx` context flag on requests that carry the `HX-Request` header. Pair the flag with `.if_context`/`.unless_context` to return a fragment to htmx and a full page to a direct visit, or use `hx-boost` to upgrade ordinary links and forms into AJAX swaps.
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
#include <mach.h>
|
|||
|
|
#include <htmx.h>
|
|||
|
|
|
|||
|
|
mach(){
|
|||
|
|
resource("todos", "/todos",
|
|||
|
|
.get = {
|
|||
|
|
sqlite_query({"todos_db", "get_todos", "todos_data"}),
|
|||
|
|
mustache("todos_fragment", .if_context = "is_htmx"),
|
|||
|
|
mustache("todos_page", .unless_context = "is_htmx")
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Include the runtime once in the page `<head>`:
|
|||
|
|
```html
|
|||
|
|
<head>{{>htmx}}</head>
|
|||
|
|
<body hx-boost='true'>...</body>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### datastar
|
|||
|
|
|
|||
|
|
Activate with `#include <datastar.h>`. Serves the Datastar runtime as the `{{>datastar}}` partial and provides the `datastar_sse()` step for pushing reactive fragment and signal patches over an SSE channel. A page opens an SSE connection (a resource `.sse` channel); pipelines elsewhere push patches to that channel, and Datastar applies them in the DOM.
|
|||
|
|
|
|||
|
|
`datastar_sse()` renders a template and patches it into the page by CSS selector. The first value is the channel (supports `{{interpolation}}`).
|
|||
|
|
|
|||
|
|
**`.channel` *(by order)***: channel to push to.
|
|||
|
|
```c
|
|||
|
|
datastar_sse("todos:{{user_id}}", .target = "#todo-list", .mode = mode_append, .elements = {"todo_row"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.target`**: CSS selector for the element to patch; supports `{{interpolation}}`.
|
|||
|
|
```c
|
|||
|
|
.target = "#todo-{{id}}"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.mode`**: how the rendered fragment is applied to the target (a `datastar_mode`).
|
|||
|
|
```c
|
|||
|
|
.mode = mode_replace
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.elements`**: a `render_config` (a template asset name) producing the fragment to patch in. Not required for `mode_remove`.
|
|||
|
|
```c
|
|||
|
|
.elements = {"todo_row"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.signals`**: context key holding signal state to merge into the client store.
|
|||
|
|
```c
|
|||
|
|
.signals = "ui_state"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`.js`**: JavaScript to execute on the client.
|
|||
|
|
```c
|
|||
|
|
.js = "window.scrollTo(0, document.body.scrollHeight)"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Patch modes (`datastar_mode`):** `mode_outer`, `mode_inner`, `mode_replace`, `mode_prepend`, `mode_append`, `mode_before`, `mode_after`, `mode_remove`.
|
|||
|
|
|
|||
|
|
Worked example: a POST inserts a row, returns it with `RETURNING`, and appends it to every connected client's list.
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
resource("todos", "/todos",
|
|||
|
|
// Each browser opens this channel and listens for patches.
|
|||
|
|
.sse = {"todos:{{user_id}}"},
|
|||
|
|
|
|||
|
|
.post = {
|
|||
|
|
input({"title", m_not_empty}),
|
|||
|
|
// RETURNING gives the new row back; capture it under "todo".
|
|||
|
|
sqlite_query({"todos_db", "insert_todo", "todo", .must_exist = true}),
|
|||
|
|
// Patch the new row into the list for everyone on the channel.
|
|||
|
|
datastar_sse("todos:{{user_id}}",
|
|||
|
|
.target = "#todo-list",
|
|||
|
|
.mode = mode_append,
|
|||
|
|
.elements = {"todo_row"}
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Removing an element needs only a selector:
|
|||
|
|
```c
|
|||
|
|
datastar_sse("todos:{{user_id}}", .target = "#todo-{{id}}", .mode = mode_remove)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Datastar sets a context flag on requests it originates, usable with `.if_context`.
|
|||
|
|
|
|||
|
|
Include the runtime once in the page `<head>`:
|
|||
|
|
```html
|
|||
|
|
<head>{{>datastar}}</head>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|

|
|||
|
|
|
|||
|
|
#### tailwind
|
|||
|
|
|
|||
|
|
Activate with `#include <tailwind.h>`. Compiles Tailwind utility classes used across the project's templates and serves the stylesheet as the `{{>tailwind}}` partial. Use Tailwind utility classes directly in templates; no build step or config file is required.
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<head>{{>tailwind}}</head>
|
|||
|
|
<body class='bg-gray-950 text-white min-h-screen'>
|
|||
|
|
<h1 class='text-3xl font-bold text-center mb-8'>Vote for which is roundest</h1>
|
|||
|
|
</body>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### session_auth
|
|||
|
|
|
|||
|
|
Activate with `#include <session_auth.h>`. Provides cookie-based authentication as pipeline steps. `session()` loads the current `user` record (and `user_id`) into context from the session cookie; run it as `middleware()` so every pipeline can see who is signed in. `logged_in()` guards a resource, redirecting anonymous visitors to the login page. `login()`, `logout()`, and `signup()` perform the corresponding actions. The login page template is the asset named `login`.
|
|||
|
|
|
|||
|
|
**`session()`**: loads the current user into context from the session cookie. Use as middleware.
|
|||
|
|
```c
|
|||
|
|
middleware(session());
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`logged_in()`**: requires an authenticated session; redirects to login otherwise. Use in a resource `.all`.
|
|||
|
|
```c
|
|||
|
|
resource("todos", "/todos", .all = {logged_in()}, .get = { ... });
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`login()` / `logout()` / `signup()`**: authentication actions for the corresponding verb pipelines.
|
|||
|
|
```c
|
|||
|
|
resource("login", "/login",
|
|||
|
|
.get = { mustache("login") },
|
|||
|
|
.post = { login() }
|
|||
|
|
);
|
|||
|
|
resource("logout", "/logout", .post = { logout() });
|
|||
|
|
resource("signup", "/signup",
|
|||
|
|
.get = { mustache("signup") },
|
|||
|
|
.post = { signup() }
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Combined: load the session globally, gate a module's resources, and read user fields in templates.
|
|||
|
|
```c
|
|||
|
|
// main.c
|
|||
|
|
#include <mach.h>
|
|||
|
|
#include <session_auth.h>
|
|||
|
|
#include "todos/todos.c"
|
|||
|
|
|
|||
|
|
mach(){
|
|||
|
|
middleware(session());
|
|||
|
|
|
|||
|
|
resource("login", "/login", .get = {mustache("login")}, .post = {login()});
|
|||
|
|
resource("logout", "/logout", .post = {logout()});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
```html
|
|||
|
|
<!-- once a session() has loaded the user, templates can read it -->
|
|||
|
|
{{#user}}<span>Hi, {{short_name}}</span>{{/user}}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Database engines
|
|||
|
|
|
|||
|
|
Each engine is its own module: `#include` its header to activate it, then use `<engine>_database(...)` to register a database and `<engine>_query({...})` as a pipeline step. They share the `database_config` and `query_config` shapes documented in [Databases](#databases) and [query](#query); only the `.connect` string is engine-specific.
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
#include <sqlite.h> // sqlite_database("...", "file:app.db?mode=rwc", ...); sqlite_query({...});
|
|||
|
|
#include <postgres.h> // postgres_database("...", "postgres://...", ...); postgres_query({...});
|
|||
|
|
#include <mysql.h> // mysql_database("...", "mysql://...", ...); mysql_query({...});
|
|||
|
|
#include <redis.h> // redis_database("...", "redis://...", ...); redis_query({...});
|
|||
|
|
#include <duckdb.h> // duckdb_database("...", "duckdb:analytics.db", ...); duckdb_query({...});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Static Files
|
|||
|
|
|
|||
|
|
Files placed in `public/` are served directly by the framework. Reference them in templates with `{{asset:filename}}`, which resolves to a content-checksummed, cache-busted URL with immutable cache headers, so updates invalidate caches automatically while unchanged files cache forever.
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
public/
|
|||
|
|
├── favicon.png
|
|||
|
|
├── styles.css
|
|||
|
|
└── logo.svg
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<link rel='icon' href='{{asset:favicon.png}}'>
|
|||
|
|
<link rel='stylesheet' href='{{asset:styles.css}}'>
|
|||
|
|
<img src='{{asset:logo.svg}}' alt='Logo'>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
This differs from assets like SQL and HTML templates, which are embedded from files anywhere in the project and seeded into context for steps to read by key (see [Assets](#assets) for which module a file seeds, and [Context](#context) for how keys resolve). `public/` holds opaque files served to the browser.
|
|||
|
|
|
|||
|
|
### External Dependencies
|
|||
|
|
|
|||
|
|
Drop third-party C libraries into a `/vendor` directory; MACH compiles and links them with the app. Call into them from `exec()`/`worker()` steps. Memory returned by a library that must be freed manually is registered with `defer_free()` so it is reclaimed when the request completes (see [memory](#memory)).
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
vendor/
|
|||
|
|
└── cmark/
|
|||
|
|
├── cmark.c
|
|||
|
|
└── cmark.h
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
#include <mach.h>
|
|||
|
|
#include "vendor/cmark/cmark.h"
|
|||
|
|
|
|||
|
|
void render_markdown(){
|
|||
|
|
worker(^(){
|
|||
|
|
auto md = get("markdown");
|
|||
|
|
auto html = cmark_markdown_to_html(md, strlen(md), 0);
|
|||
|
|
defer_free(html); // library-owned pointer
|
|||
|
|
set("html_content", html);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
For dependencies that aren't plain source (system packages, build tooling), provide a custom `Dockerfile`. MACH builds from it instead of the default image, so anything installable in the container is available at compile and run time.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Architecture
|
|||
|
|
|
|||
|
|
* [Data-Oriented Pipelines](#data-oriented-pipelines)
|
|||
|
|
* [Multi-Reactor Architecture](#multi-reactor-architecture)
|
|||
|
|
* [Safe by Default](#safe-by-default)
|
|||
|
|
|
|||
|
|
### Data-Oriented Pipelines
|
|||
|
|
|
|||
|
|
The `mach()` function runs once at boot, alongside each module's constructor. Their registration calls (`resource()`, `sqlite_database()`, `task()`, `middleware()`, `publish()`, and so on) are processed into an execution graph with precompiled pipelines, queries, and templates. Each incoming request then executes its matching pipeline as a sequence of pre-warmed steps.
|
|||
|
|
|
|||
|
|

|
|||
|
|
|
|||
|
|
### Multi-Reactor Architecture
|
|||
|
|
|
|||
|
|
MACH runs two types of reactors backed by a shared thread pool. The request/task/cpu ratio can be set in `compose.yml`.
|
|||
|
|
|
|||
|
|
- **Request reactors** handle HTTP traffic; each gets its own dedicated CPU core and event loop.
|
|||
|
|
- **Task reactors** handle background work; each gets its own dedicated core, monitors the task database for pending and incomplete tasks, and processes cron schedules.
|
|||
|
|
- **Shared thread pool** handles CPU-bound and blocking I/O work on the remaining cores.
|
|||
|
|
|
|||
|
|
When a pipeline executes a `worker()` step, the work is dispatched to the shared thread pool, which releases the reactor; when the call completes, the pipeline resumes on the original reactor. (An `exec()` step runs inline on the reactor for short, non-blocking logic.) The `run()` step adds jobs to the task database, where they are picked up by task reactors. Tasks can call `run()` themselves to enqueue additional work.
|
|||
|
|
|
|||
|
|
Application code does not manage threads, mutexes, or locks. The architecture isolates request state to the pipeline's context.
|
|||
|
|
|
|||
|
|

|
|||
|
|
|
|||
|
|
### Safe by Default
|
|||
|
|
|
|||
|
|
MACH prevents common C and web vulnerabilities at the framework level.
|
|||
|
|
|
|||
|
|
#### Memory Safety
|
|||
|
|
|
|||
|
|
Each reactor maintains a pool of arena allocators. When a request arrives, the pipeline is assigned an arena, and all allocations draw from it. When the pipeline completes, the arena is cleared and returned to the pool. Application code does not call `malloc` or `free` (it uses `allocate()` and `defer_free()` from the [Imperative API](#memory) when it needs raw buffers), which avoids leaks, double-frees, and use-after-free.
|
|||
|
|
|
|||
|
|
All framework data structures (tables, records, strings) enforce bounds checking. Out-of-bounds reads and missing context values return `nullptr` rather than faulting (`table_get` past the end, `get`/`record_get` of an absent key). Pipelines that exceed their memory limit (default 5MB, configurable in `compose.yml`) abort with a 500, mitigating OOM denial-of-service.
|
|||
|
|
|
|||
|
|
#### SQL Injection Prevention
|
|||
|
|
|
|||
|
|
Interpolations such as `{{user_id}}` inside a query's SQL (whether the SQL comes from a `.sql` file or a `context()`-registered string) are bound as parameters in prepared statements, preventing SQL injection at the framework level.
|
|||
|
|
|
|||
|
|
#### XSS Prevention
|
|||
|
|
|
|||
|
|
The `mustache()` and `mdm()` steps auto-escape context values, so malicious input is rendered as text. Raw HTML requires explicit opt-in via Mustache's standard unescape syntax: `{{{field}}}` or `{{&field}}`.
|
|||
|
|
|
|||
|
|
#### CSRF Prevention
|
|||
|
|
|
|||
|
|
State-changing requests are verified against a per-session CSRF token. Emit the token with `{{csrf:input}}` (a hidden form field) or `{{csrf:token}}` (a value for query strings); MACH sets it on an httponly, secure, samesite cookie and rejects requests whose token does not match. See [Templates](#templates).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Tooling
|
|||
|
|
|
|||
|
|
* [Development Environment](#development-environment)
|
|||
|
|
* [Introspection](#introspection)
|
|||
|
|
* [Testing](#testing)
|
|||
|
|
* [Debugging](#debugging)
|
|||
|
|
* [Deployment](#deployment)
|
|||
|
|
* [Observability](#observability)
|
|||
|
|
* [Project Management](#project-management)
|
|||
|
|
* [Built With](#built-with)
|
|||
|
|
|
|||
|
|
### Development Environment
|
|||
|
|
Built-in TUI editor with HMR, LSP support, integrated source control, and a topology-aware AI assistant. The AI uses the `app_info` command to inspect the full application topology (routes, pipelines, database schemas, event contracts, and module boundaries), so it reasons about the application's actual execution graph rather than the source text alone.
|
|||
|
|
|
|||
|
|
### Introspection
|
|||
|
|
```bash
|
|||
|
|
app_info # view topology
|
|||
|
|
app_info resources # list all resources
|
|||
|
|
app_info pipelines # inspect pipelines
|
|||
|
|
app_info events # view pub/sub map
|
|||
|
|
app_info databases # inspect schemas
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Testing
|
|||
|
|
Built-in test runners for unit and end-to-end testing; no external framework setup required.
|
|||
|
|
```bash
|
|||
|
|
unit_tests # fast, criterion-based tests
|
|||
|
|
e2e_tests # playwright-powered browser tests
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Debugging
|
|||
|
|
Built-in debugging with pipeline-aware commands. Halt on individual pipeline steps, step through execution, and inspect the full pipeline context including nested tables and records.
|
|||
|
|
```bash
|
|||
|
|
app_debug # interactive debugger in the TUI
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Deployment
|
|||
|
|
MACH deploys as a standard Docker container. It does not terminate TLS; production deployments place MACH behind a reverse proxy or load balancer (Nginx, Caddy, AWS ALB) to handle HTTPS.
|
|||
|
|
```bash
|
|||
|
|
app_build # outputs a minimal production Docker image
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Observability
|
|||
|
|
Each pipeline step emits OpenTelemetry spans. Logs, traces, errors, and auto-profiling are visualized on the telemetry server at port 4000. No manual instrumentation required.
|
|||
|
|
|
|||
|
|
### Project Management
|
|||
|
|
MACH ships with integrated project infrastructure: source control, issue tracking, wiki, forum, and a project website.
|
|||
|
|
|
|||
|
|
### Built With
|
|||
|
|
|
|||
|
|
| | |
|
|||
|
|
|---|---|
|
|||
|
|
| [C23](https://en.cppreference.com/w/c/23) | Language standard |
|
|||
|
|
| [Docker](https://www.docker.com/) | Development environment, production images, stack orchestration |
|
|||
|
|
| [libmicrohttpd](https://www.gnu.org/software/libmicrohttpd/) / [libuv](https://libuv.org/) | HTTP server, event loops, async I/O, file watching, shared thread pool |
|
|||
|
|
| [Mustach](https://gitlab.com/jobol/mustach) | Templating and string interpolation |
|
|||
|
|
| [Jansson](https://github.com/akheron/jansson) | JSON parsing and generation |
|
|||
|
|
| [curl](https://curl.se/) | HTTP client for fetch steps |
|
|||
|
|
| [Fossil](https://fossil-scm.org/) | Source control, wiki, forum, issue tracker, project site |
|
|||
|
|
| [Fresh](https://getfresh.dev/) | TUI editor |
|
|||
|
|
| [clangd](https://clangd.llvm.org/) | Language server |
|
|||
|
|
| [LLDB](https://lldb.llvm.org/) | Debugger |
|
|||
|
|
| [Criterion](https://github.com/Snaipe/Criterion) | Unit testing |
|
|||
|
|
| [Playwright](https://playwright.dev/) | End-to-end testing |
|
|||
|
|
| [SigNoz](https://signoz.io/) + [OpenTelemetry](https://opentelemetry.io/) | APM, traces, logs, errors, dashboards |
|
|||
|
|
| [Open Code](https://opencode.ai/) | AI assistant with custom agent and skill files |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## License
|
|||
|
|
|
|||
|
|
MACH is licensed under the [LGPL](./LICENSE).
|