nerack repo

This commit is contained in:
2026-09-13 15:31:00 -05:00
committed by nightshade
commit abac587d14
99 changed files with 4772 additions and 0 deletions
+772
View File
@@ -0,0 +1,772 @@
[← Back to README](./README.md)
# Guide
This guide builds a todo app one concept at a time. See the [Reference](./REFERENCE.md) for full options on each step, helper, and field. Nerack discovers assets by file location and seeds each one into the context of the module that owns it (see [Assets](./REFERENCE.md#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. Calling APIs](#5-calling-apis)
* [6. Tasks](#6-tasks)
* [7. Modules and Events](#7-modules-and-events)
### 1. Pages and Templates
Each `http(...)` declares a named resource and URL, and each verb pipeline is a list of steps. `html("home", "home_s")` renders the template asset `home` into context key `home_s`, and `http_response("home_s")` sends it. `{{url:verb:name}}` builds a link to a resource: the verb first, then the resource name. Any `:params` in the pattern are filled from the current scope by matching key names.
Both pages share a layout, so `home` doubles as the layout: it declares the nav and a `{{$body}}` block whose default is the welcome page. 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:get:home}}'>Home</a> · <a href='{{url:get: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}}
```
**`todo.c`**
```c
#include <nerack.h>
#include <http.h>
module(todo){
http("home", "/",
.get = {
html("home", "home_s"),
http_response("home_s")
}
);
http("todos", "/todos",
.get = {
html("todos", "todos_s"),
http_response("todos_s")
}
);
}
```
See [HTTP Pipelines](./REFERENCE.md#http-pipelines) and [Templates](./REFERENCE.md#templates).
### 2. Show Data
Bring in SQLite with `#include <sqlite.h>`, declare a database with `sqlite(...)`, and read with `sqlite_query()`. SQL files are assets like templates: `get_todos.sql` becomes the asset `get_todos`.
Three new SQL files:
**`create_todos_table.sql`**
```sql
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL
);
```
**`seed_todos.sql`**
```sql
INSERT INTO todos(title) VALUES('Learn Nerack');
```
**`get_todos.sql`**
```sql
select id, title from todos;
```
Render the rows Nerack 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:
**`todo.c`**
```diff
#include <nerack.h>
#include <http.h>
+#include <sqlite.h>
module(todo){
+ sqlite(
+ "todos_db",
+ "file:todos.db?mode=rwc",
+ {"create_todos_table"},
+ {"seed_todos"}
+ );
+
http("home", "/",
.get = {
html("home", "home_s"),
http_response("home_s")
}
);
http("todos", "/todos",
.get = {
+ sqlite_query({"todos_db", "get_todos", "todos_data"}),
html("todos", "todos_s"),
http_response("todos_s")
}
);
}
```
The three positional arguments are the database name, the SQL asset, and the context key for the result table (`todos_data`). The template walks the result with `{{#todos_data}}...{{/todos_data}}`. Migrations and seeds run on first connection. See [Databases](./REFERENCE.md#databases) and [query](./REFERENCE.md#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 on validation failure.
**`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>
+ <form method='post' action='{{url:post:todos}}'>
+ <input name='title' value='{{input:title}}'>
+ {{#error:title}}
+ <span class='error'>{{error_message:title}}</span>
+ {{/error:title}}
+ <button>Add</button>
+ </form>
<ul>
{{#todos_data}}
<li>{{title}}</li>
{{/todos_data}}
</ul>
{{/body}}
{{/home}}
```
Add a `.post` verb and an `.errors` handler:
**`todo.c`**
```diff
#include <nerack.h>
#include <http.h>
#include <sqlite.h>
module(todo){
sqlite(
"todos_db",
"file:todos.db?mode=rwc",
{"create_todos_table"},
{"seed_todos"}
);
http("home", "/",
.get = {
html("home", "home_s"),
http_response("home_s")
}
);
http("todos", "/todos",
.get = {
sqlite_query({"todos_db", "get_todos", "todos_data"}),
html("todos", "todos_s"),
http_response("todos_s")
- }
+ },
+ .post = {
+ input({"title", not_empty_input}),
+ sqlite_query({"todos_db", "create_todo"}),
+ http_redirect("todos")
+ },
+ .errors = {
+ {http_bad_request, {http_reroute("todos")}}
+ }
);
}
```
The form posts to `{{url:post:todos}}`. Because `post` is a state-changing verb, the helper appends both `http_method=post` and a fresh CSRF token to the action URL, so the form needs no hidden fields of its own.
`input()` validates and promotes `title` to app scope; the `{{title}}` in `create_todo.sql` binds as a prepared-statement parameter. On failure, `http_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](./REFERENCE.md#input), [Error and Repair Pipelines](./REFERENCE.md#error-and-repair-pipelines), and [redirect and reroute](./REFERENCE.md#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 IF NOT EXISTS 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:get:todo}}` resolves the `todo` resource's pattern (`/todos/:id`) and fills `:id` from the current scope, which inside `{{#todos_data}}` is the current row:
**`todos.mustache.html`**
```diff
{{#todos_data}}
- <li>{{title}}</li>
+ <li><a href='{{url:get:todo}}'>{{title}}</a></li>
{{/todos_data}}
```
Register the migration and add a `todo` resource:
**`todo.c`**
```diff
#include <nerack.h>
#include <http.h>
#include <sqlite.h>
module(todo){
sqlite(
"todos_db",
"file:todos.db?mode=rwc",
- {"create_todos_table"},
+ {"create_todos_table", "create_comments_table"},
{"seed_todos"}
);
http("home", "/",
.get = {
html("home", "home_s"),
http_response("home_s")
}
);
http("todos", "/todos",
.get = {
sqlite_query({"todos_db", "get_todos", "todos_data"}),
html("todos", "todos_s"),
http_response("todos_s")
},
.post = {
input({"title", not_empty_input}),
sqlite_query({"todos_db", "create_todo"}),
http_redirect("todos")
},
.errors = {
{http_bad_request, {http_reroute("todos")}}
}
);
+
+ http("todo", "/todos/:id",
+ .get = {
+ input({"id", positive_integer_input}),
+ sqlite_query(
+ {"todos_db", "get_todo", "todo_data", .error_on_empty = true},
+ {"todos_db", "get_comments", "comments"}
+ ),
+ join("todo_data", "id", "comments", "todo_id"),
+ html("todo", "todo_s"),
+ http_response("todo_s")
+ }
+ );
}
```
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}}`. `.error_on_empty = true` returns 404 when the id matches nothing. See [join](./REFERENCE.md#join) and [query](./REFERENCE.md#query).
### 5. Calling APIs
`http_fetch()` calls external HTTP services like a query calls a database. JSON parses into context tables; multiple items in one `http_fetch()` run concurrently.
Show the responses on the home page:
**`home.mustache.html`**
```diff
<html>
<body>
<nav><a href='{{url:get:home}}'>Home</a> · <a href='{{url:get:todos}}'>My Todos</a></nav>
<main>
- {{$body}}
- <h1>Welcome</h1>
- {{/body}}
+ {{$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:
**`todo.c`**
```diff
#include <nerack.h>
#include <http.h>
#include <sqlite.h>
module(todo){
sqlite(
"todos_db",
"file:todos.db?mode=rwc",
{"create_todos_table", "create_comments_table"},
{"seed_todos"}
);
http("home", "/",
.get = {
+ http_fetch(
+ {http_get, "https://api.quotes.dev/random", "quote"},
+ {http_get, "https://api.weather.dev/now", "weather"}
+ ),
html("home", "home_s"),
http_response("home_s")
}
);
http("todos", "/todos",
.get = {
sqlite_query({"todos_db", "get_todos", "todos_data"}),
html("todos", "todos_s"),
http_response("todos_s")
},
.post = {
input({"title", not_empty_input}),
sqlite_query({"todos_db", "create_todo"}),
http_redirect("todos")
},
.errors = {
{http_bad_request, {http_reroute("todos")}}
}
);
http("todo", "/todos/:id",
.get = {
input({"id", positive_integer_input}),
sqlite_query(
{"todos_db", "get_todo", "todo_data", .error_on_empty = true},
{"todos_db", "get_comments", "comments"}
),
join("todo_data", "id", "comments", "todo_id"),
html("todo", "todo_s"),
http_response("todo_s")
}
);
}
```
Both requests run concurrently under one `http_fetch()` call. The JSON parses into context tables the template walks with `{{#quote}}` and `{{#weather}}`. `http_fetch()` also supports other verbs, headers, request bodies, and interpolated URLs. See [fetch](./REFERENCE.md#fetch).
### 6. Tasks
A task is a named, reusable pipeline. Define it once with optional `.cron`; dispatch durable background runs with `dispatch("name")` (from `dispatch.h`).
Two new SQL files:
**`create_daily_stats_table.sql`**
```sql
CREATE TABLE IF NOT EXISTS 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 tasks, dispatch them from the POST:
**`todo.c`**
```diff
#include <nerack.h>
#include <http.h>
#include <sqlite.h>
+#include <dispatch.h>
module(todo){
sqlite(
"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 * * *");
+
+ task("notify_new_todo", {
+ http_fetch({
+ http_post,
+ "https://api.push.dev/notify",
+ .json = "{\"text\":\"New todo: {{title}}\"}"
+ })
+ }, .accepts = {"title"});
+
http("home", "/",
.get = {
http_fetch(
{http_get, "https://api.quotes.dev/random", "quote"},
{http_get, "https://api.weather.dev/now", "weather"}
),
html("home", "home_s"),
http_response("home_s")
}
);
http("todos", "/todos",
.get = {
sqlite_query({"todos_db", "get_todos", "todos_data"}),
html("todos", "todos_s"),
http_response("todos_s")
},
.post = {
input({"title", not_empty_input}),
sqlite_query({"todos_db", "create_todo"}),
+ dispatch("notify_new_todo"),
http_redirect("todos")
},
.errors = {
{http_bad_request, {http_reroute("todos")}}
}
);
http("todo", "/todos/:id",
.get = {
input({"id", positive_integer_input}),
sqlite_query(
{"todos_db", "get_todo", "todo_data", .error_on_empty = true},
{"todos_db", "get_comments", "comments"}
),
join("todo_data", "id", "comments", "todo_id"),
html("todo", "todo_s"),
http_response("todo_s")
}
);
}
```
`.cron` and `dispatch(...)` both run the task on a task reactor, off the request reactors, so the POST returns immediately. Dispatched tasks are durable: a crash mid-task resumes on the next boot. To hand values to a task, list them under `.accepts`; `notify_new_todo` pulls in `title` that way. `dispatch()` comes from `dispatch.h`. See [Task Pipelines](./REFERENCE.md#task-pipelines).
### 7. Modules and Events
Split features into modules that talk through pub/sub events. A module is any `.c` file declaring `module(name){ ... }`, registering that module's resources, databases, tasks, and subscribers. Nerack discovers every `module(...)` on disk. A module's assets live in its `name/` folder and are private to it; assets at the project root are shared across modules (see [Assets](./REFERENCE.md#assets)).
This step moves todos into its own module and adds an `activity` module that records an entry whenever a todo is created. The top-level file becomes `main.c` and keeps just the `home` resource; `home.mustache.html` stays at the project root, shared across modules:
```
.
├── main.c
├── todos/
│ ├── todos.c
│ ├── todos.mustache.html
│ ├── todo.mustache.html
│ ├── create_todos_table.sql
│ ├── create_comments_table.sql
│ ├── create_daily_stats_table.sql
│ ├── seed_todos.sql
│ ├── record_daily_stats.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`**
```diff
#include <nerack.h>
#include <http.h>
-#include <sqlite.h>
-#include <dispatch.h>
-module(todo){
+module(main){
- sqlite(
- "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 * * *");
-
- task("notify_new_todo", {
- http_fetch({
- http_post,
- "https://api.push.dev/notify",
- .json = "{\"text\":\"New todo: {{title}}\"}"
- })
- }, .accepts = {"title"});
-
http("home", "/",
.get = {
http_fetch(
{http_get, "https://api.quotes.dev/random", "quote"},
{http_get, "https://api.weather.dev/now", "weather"}
),
html("home", "home_s"),
http_response("home_s")
}
);
-
- http("todos", "/todos",
- .get = {
- sqlite_query({"todos_db", "get_todos", "todos_data"}),
- html("todos", "todos_s"),
- http_response("todos_s")
- },
- .post = {
- input({"title", not_empty_input}),
- sqlite_query({"todos_db", "create_todo"}),
- dispatch("notify_new_todo"),
- http_redirect("todos")
- },
- .errors = {
- {http_bad_request, {http_reroute("todos")}}
- }
- );
-
- http("todo", "/todos/:id",
- .get = {
- input({"id", positive_integer_input}),
- sqlite_query(
- {"todos_db", "get_todo", "todo_data", .error_on_empty = true},
- {"todos_db", "get_comments", "comments"}
- ),
- join("todo_data", "id", "comments", "todo_id"),
- html("todo", "todo_s"),
- http_response("todo_s")
- }
- );
}
```
Add an Activity link to the shared nav:
**`home.mustache.html`**
```diff
<html>
<body>
- <nav><a href='{{url:get:home}}'>Home</a> · <a href='{{url:get:todos}}'>My Todos</a></nav>
+ <nav><a href='{{url:get:home}}'>Home</a> · <a href='{{url:get:todos}}'>My Todos</a> · <a href='{{url:get:activity}}'>Activity</a></nav>
<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>
```
The todos logic moves into the module unchanged, with a `publish()` declaration and an `emit()` step added. Both todo resources move with it:
**`todos/todos.c`**
```c
#include <nerack.h>
#include <http.h>
#include <sqlite.h>
#include <pubsub.h>
#include <dispatch.h>
module(todos){
sqlite(
"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 * * *");
task("notify_new_todo", {
http_fetch({
http_post,
"https://api.push.dev/notify",
.json = "{\"text\":\"New todo: {{title}}\"}"
})
}, .accepts = {"title"});
http("todos", "/todos",
.get = {
sqlite_query({"todos_db", "get_todos", "todos_data"}),
html("todos", "todos_s"),
http_response("todos_s")
},
.post = {
input({"title", not_empty_input}),
sqlite_query({"todos_db", "create_todo"}),
dispatch("notify_new_todo"),
emit("todo_created"),
http_redirect("todos")
},
.errors = {
{http_bad_request, {http_reroute("todos")}}
}
);
http("todo", "/todos/:id",
.get = {
input({"id", positive_integer_input}),
sqlite_query(
{"todos_db", "get_todo", "todo_data", .error_on_empty = true},
{"todos_db", "get_comments", "comments"}
),
join("todo_data", "id", "comments", "todo_id"),
html("todo", "todo_s"),
http_response("todo_s")
}
);
}
```
The `activity` module owns its own table, query, template, and subscriber. Nothing in it references the todos module:
**`activity/create_activity_table.sql`**
```sql
CREATE TABLE IF NOT EXISTS 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 <nerack.h>
#include <http.h>
#include <sqlite.h>
#include <pubsub.h>
module(activity){
sqlite(
"activity_db",
"file:activity.db?mode=rwc",
{"create_activity_table"}
);
subscribe("todo_created", {
sqlite_query({"activity_db", "insert_activity"})
});
http("activity", "/activity",
.get = {
sqlite_query({"activity_db", "get_activities", "activities"}),
html("activity", "activity_s"),
http_response("activity_s")
}
);
}
```
When the POST calls `emit("todo_created")`, Nerack propagates the keys named in `publish(...).with` (`title`) to every subscriber. The `activity` module writes its row with no direct link to the publisher. Events are durable: undelivered ones replay after a crash. A third subscriber is another module with its own `subscribe(...)`, and the publisher still does not change. See [Modules and Composition](./REFERENCE.md#modules-and-composition) and [Event Pipelines](./REFERENCE.md#event-pipelines).