https://github.com/llllOllOOll/spider
https://github.com/llllOllOOll/spider
Last synced: 7 days ago
JSON representation
- Host: GitHub
- URL: https://github.com/llllOllOOll/spider
- Owner: llllOllOOll
- License: mit
- Created: 2026-04-30T08:31:10.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2026-06-06T00:54:35.000Z (12 days ago)
- Last Synced: 2026-06-06T02:13:10.839Z (12 days ago)
- Language: C
- Size: 4.47 MB
- Stars: 32
- Watchers: 0
- Forks: 3
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
Awesome Lists containing this project
- awesome-zig - llllOllOOll/spider - A web framework for Zig with a focus on ergonomics and performance. (Network & Web / Web Framework)
README
#
Spider v0.6.2
Build web servers in Zig โ performant, productive, and batteries-included.
**Batteries included:** PostgreSQL, SQLite, MySQL, JWT auth, Google OAuth,
Clerk, Keycloak, WebSockets, SSE, Web Push, Cloudflare R2, multipart upload,
HTMX support, CLI tool, and a powerful template engine.
๐ **Documentation:** this README
๐ง **CLI:** `spider new myapp`
---
## Installation
### Quick Install (Recommended)
```bash
curl -fsSL https://spiderme.org/install.sh | bash
```
Or specify a version:
```bash
curl -fsSL https://spiderme.org/install.sh | bash -s -- --version v0.6.2
```
### Manual Install
Add Spider as a dependency in your `build.zig.zon`:
```bash
zig fetch --save git+https://github.com/llllOllOOll/spider#main
```
Then in your `build.zig`:
```zig
const spider_dep = b.dependency("spider", .{ .target = target });
const spider_mod = spider_dep.module("spider");
const exe = b.addExecutable(.{
.name = "myapp",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "spider", .module = spider_mod },
},
}),
});
```
Alternatively, use the **build helper** for one-line setup:
```zig
const spider_build = @import("spider_build");
spider_build.setup(b, exe, spider_dep);
```
This automatically detects `spider.config.zig` and runs the template generator.
---
## Requirements
- Zig `0.17.0-dev` or compatible
```bash
zig version
# 0.17.0-dev.93+76174e1bc
```
---
## Quick Start
```zig
const std = @import("std");
const spider = @import("spider");
// Embed templates (optional โ one line enables embed mode)
pub const spider_templates = @import("embedded_templates.zig").EmbeddedTemplates;
pub fn main() void {
var server = spider.app(.{});
defer server.deinit();
server
.get("/", homeHandler)
.get("/users/:id", userHandler)
.post("/users", createUserHandler)
.listen(.{ .port = 3000 }) catch {};
}
fn homeHandler(c: *spider.Ctx) !spider.Response {
return c.json(.{ .message = "Hello from Spider!" }, .{});
}
fn userHandler(c: *spider.Ctx) !spider.Response {
const id = c.param("id") orelse "unknown";
return c.json(.{ .user_id = id }, .{});
}
fn createUserHandler(c: *spider.Ctx) !spider.Response {
const User = struct { name: []const u8, email: []const u8 };
const body = try c.bodyJson(User);
return c.json(.{ .created = true, .name = body.name }, .{ .status = .created });
}
```
```bash
zig build run
# Server listening on http://127.0.0.1:3000
# Starting 12 worker threads
```
`listen` accepts both `port` and `host` โ any field not set falls back to the values in `spider.config.zig`:
```zig
.listen(.{ .port = 8080 }) // override port only
.listen(.{ .host = "0.0.0.0" }) // override host only
.listen(.{ .port = 8080, .host = "0.0.0.0" }) // override both
.listen(.{}) // use config values
```
---
## Context โ `c: *spider.Ctx`
Every handler receives a `*spider.Ctx`. It provides everything you need โ no allocators, no I/O wiring required.
### Responses
```zig
// JSON
return c.json(.{ .id = 1, .name = "Alice" }, .{});
// JSON with custom status
return c.json(.{ .error = "not found" }, .{ .status = .not_found });
// JSON with custom headers
return c.json(.{ .ok = true }, .{
.headers = &.{.{ "X-Powered-By", "Spider" }},
});
// Plain text
return c.text("Hello!", .{});
// HTML
return c.html("
Hello
", .{});
// Redirect
return c.redirect("/dashboard");
// Render template by name (auto-detects .html/.md extension)
return c.view("users/index", .{ .users = users }, .{});
// Render template string directly
return c.render("Hello { name }!", .{ .name = "World" }, .{});
```
### Reading Requests
```zig
// URL parameter: /users/:id
const id = c.param("id") orelse "unknown";
// Query string: /search?q=zig
const q = c.query("q") orelse "";
// Request header
const ua = c.header("User-Agent") orelse "";
// Cookie
const session = c.cookie("token") orelse "";
// Raw body
const raw = c.getBody() orelse "";
// Parse JSON body
const User = struct { name: []const u8, email: []const u8 };
const user = try c.bodyJson(User);
// Parse form body (auto-detects url-encoded and multipart)
const input = try c.parseForm(FormInput);
// Parse multipart form (when you need file uploads)
const mp = try c.parseMultipart();
const title = mp.getValue("title") orelse "";
const files = mp.getFile("avatar") orelse &.{};
```
### Arena Allocator
```zig
// Allocate freely โ Spider cleans up after each request
const msg = try std.fmt.allocPrint(c.arena, "Hello, {s}!", .{name});
return c.json(.{ .message = msg }, .{});
```
### HTMX Detection
```zig
fn handler(c: *spider.Ctx) !spider.Response {
if (c.isHtmx()) {
// return partial HTML fragment
return c.view("users/_list", data, .{});
}
// return full page
return c.view("users/index", data, .{});
}
```
### Cookies
```zig
// Read a cookie
const token = c.cookie("session") orelse "";
// Set a cookie (returns the Set-Cookie string)
const cookie = try c.setCookie("session", jwt, .{
.http_only = true,
.secure = true,
.same_site = "Lax",
.path = "/",
.max_age = 86400 * 7,
});
// Set a cookie via ResponseOptions helper
const opts = try c.withCookie("session", jwt, .{
.max_age = 86400,
});
// Include cookie in response
return c.json(.{ .ok = true }, .{
.headers = &.{.{ "Set-Cookie", cookie }},
});
```
### Database inside Context
```zig
// If you've registered a database via server.db(), use c.db()
pub fn handler(c: *spider.Ctx) !spider.Response {
const users = try c.db().query(User, "SELECT * FROM users WHERE active = $1", .{true});
return c.json(users, .{});
}
```
---
## Routing
```zig
server
.get("/", homeHandler)
.post("/users", createUser)
.get("/users/:id", getUser)
.put("/users/:id", updateUser)
.delete("/users/:id", deleteUser)
.patch("/users/:id", patchUser)
.head("/users/:id", headUser);
```
### Route Groups
Groups allow sharing middleware across a set of routes.
```zig
fn dashboardRoutes(s: *spider.Server, prefix: []const u8, mws: []const spider.MiddlewareFn) void {
s.addRoute(.GET, "/dashboard", mws, dashHandler);
s.addRoute(.GET, "/dashboard/users", mws, usersHandler);
}
server
.group("/dashboard", &.{authMiddleware}, dashboardRoutes)
.get("/login", loginHandler);
```
### Route-specific Middleware
```zig
// Register a route with specific middlewares
server.addRoute(.POST, "/admin/users", &.{authMiddleware, adminMiddleware}, createUser);
```
---
## Middleware
```zig
// Global โ applies to all routes
server.use(loggerMiddleware);
// By path prefix
server.useAt("/api/*", apiMiddleware);
// Per group
server.group("/admin", &.{authMiddleware, adminMiddleware}, adminRoutes);
// Global error handler
server.onError(errorHandler);
```
### Writing Middleware
```zig
fn loggerMiddleware(c: *spider.Ctx, next: spider.NextFn) !spider.Response {
std.log.info("{s} {s}", .{ c.getMethod(), c.getPath() });
const res = try next(c);
std.log.info(" โ {d}", .{@intFromEnum(res.status)});
return res;
}
fn authMiddleware(c: *spider.Ctx, next: spider.NextFn) !spider.Response {
const token = c.cookie("token") orelse
return c.redirect("/login");
_ = try spider.auth.jwtVerify(spider.auth.Claims, c.arena, c._io, token, secret);
return next(c);
}
```
### Built-in Logger Middleware
Spider includes a colorized request logger:
```zig
server.use(spider.logger);
// GET /users 200 12ms
// POST /api 401 3ยตs
```
---
## Templates
Spider's template engine uses an **AST parser** with support for variables, loops, conditions, includes, layout inheritance, **components** (PascalCase), **named slots**, and **Markdown**.
### Template Syntax
```html
My App
{ slot }
```
```html
extends "layout"
Users
for (users) |user| {
}
```
```zig
// Handler โ just the name, Spider handles the rest
fn usersHandler(c: *spider.Ctx) !spider.Response {
const users = try db.query(User, "SELECT * FROM users", .{});
return c.view("users/index", .{ .users = users }, .{});
}
```
### Conditionals
```html
if (user.active) {
Active
} else {
Inactive
}
// else if chains
if (role == "admin") {
} else if (role == "moderator") {
} else {
}
```
### Coalescing (defaults)
```html
Hello, { name ?? "Guest" }
```
### List length
```html
if (users.len > 0) {
{ users.len } users found
}
```
### Components (PascalCase)
Create reusable components with PascalCase naming:
```html
{ name }
{ email }
{ slot }
```
```html
Extra content here
```
### Named Slots
```html
{ slot_header }
{ slot }
{ slot_sidebar }
Dashboard
Welcome back!
...
```
### Markdown Support
Spider auto-detects Markdown files via `--doc` signature in frontmatter:
```markdown
--doc
title: API Documentation
layout: docs_layout
--
# API Reference
Welcome to the API docs...
```
```zig
// Handler โ auto-detects .md extension
return c.view("docs/api", .{}, .{});
```
### Template Tags
| Tag | Description |
|-----|-------------|
| `{ variable }` | Variable interpolation |
| `{ variable ?? "default" }` | Coalescing operator (default value) |
| `if (condition) { ... }` | Conditional |
| `if (a) { ... } else if (b) { ... } else { ... }` | If / else if / else |
| `for (items) \|item\| { ... }` | Loop with capture |
| `extends "layout"` | Layout inheritance (top of file) |
| `` | PascalCase component (with slot) |
| `` | Self-closing component |
| `{ slot }` | Default slot content |
| `{ slot_name }` | Named slot content |
### Template Modes
Spider has two template modes. Both produce **byte-identical output** โ the only difference is when templates are loaded.
**Embed mode** โ templates compiled into the binary (recommended for production):
```zig
// root.zig or main.zig โ one line enables embed mode
pub const spider_templates = @import("embedded_templates.zig").EmbeddedTemplates;
```
Spider automatically generates `embedded_templates.zig` on every `zig build` by scanning `src/` recursively for `.html` and `.md` files. The build helper (`spider_build.setup`) handles this automatically.
**Runtime mode** โ reads from disk at request time (useful in development):
```zig
// main.zig โ nothing needed, just don't declare spider_templates
// Spider scans views_dir and serves templates from disk
```
Detection uses `@hasDecl(@import("root"), "spider_templates")` โ same pattern as `std_options` in the Zig stdlib.
#### spider.config.zig
When using runtime mode, create `spider.config.zig` in your project root to configure the template directory:
```zig
// spider.config.zig
const spider = @import("spider");
pub const config = spider.Config{
.views_dir = "./src", // point to where your .html/.md files live
.layout = "layout",
.env = .development,
.port = 3000,
.host = "0.0.0.0",
};
```
Spider prints warnings to help diagnose issues:
```
[spider] WARNING: views_dir "./views" not found.
[spider] Templates will not load in runtime mode.
[spider] runtime templates: 5 loaded from "./src"
```
#### Template name normalization
| File path (relative to views_dir) | Normalized name | Call with |
|---|---|---|
| `views/bills/index.html` | `bills_index` | `c.view("bills/index", ...)` |
| `views/home/index.html` | `home_index` | `c.view("home/index", ...)` |
| `shared/templates/layout.html` | `layout` | layout (auto, via config) |
| `shared/templates/Card.html` | `Card` | `c.view("Card", ...)` |
| `shared/templates/site-nav.html` | `site_nav` | `` in templates |
Rules: strip extension โ use segment after `views/` or `templates/` โ replace `/` and `-` with `_`.
---
## Database
### PostgreSQL (Pure Zig)
Spider's PostgreSQL driver is **pure Zig** โ no libpq dependency required. It uses a connection pool with retry logic (5 attempts, exponential backoff) and supports parameterized queries (`$1`, `$2`, ...).
> Obrigado ao [karlseguin](https://github.com/karlseguin) pelo excelente [pg.zig](https://github.com/karlseguin/pg.zig) โ projeto que serviu de base para o driver PostgreSQL do Spider. Utilizamos um fork customizado para atender ร s necessidades do framework.
```zig
const std = @import("std");
const spider = @import("spider");
const db = spider.pg;
pub fn main() !void {
// Initialize โ reads env vars with fallback defaults
var threaded = std.Io.Threaded.init_single_threaded;
const io = threaded.io();
try db.init(io, .{});
defer db.deinit();
var server = spider.app(.{});
defer server.deinit();
server
.get("/users", listUsers)
.listen(.{ .port = 3000 }) catch {};
}
```
All `DbConfig` fields are optional โ they fall back to environment variables:
| Field | Env var | Default |
|-------|---------|---------|
| `.host` | `PG_HOST` | `"localhost"` |
| `.port` | `PG_PORT` | `5432` |
| `.user` | `PG_USER` | `"spider"` |
| `.password` | `PG_PASSWORD` | `"spider"` |
| `.database` | `PG_DB` | `"spider_db"` |
| `.pool_size` | โ | `10` |
So `try db.init(io, .{});` reads everything from your `.env` file.
#### Queries
`db.query(T, arena, sql, params)` returns `[]T` for structs, `i32` for counts, `void` for INSERT/UPDATE/DELETE.
```zig
const User = struct { id: i32, name: []const u8, email: []const u8 };
// SELECT โ returns []User allocated in c.arena
fn listUsers(c: *spider.Ctx) !spider.Response {
const users = try db.query(User, c.arena,
"SELECT id, name, email FROM users WHERE active = $1",
.{true},
);
return c.json(users, .{});
}
// SELECT one row โ returns ?User
fn getUser(c: *spider.Ctx) !spider.Response {
const id = try std.fmt.parseInt(i32, c.param("id") orelse "0", 10);
const user = try db.queryOne(User, c.arena,
"SELECT id, name, email FROM users WHERE id = $1",
.{id},
) orelse return c.json(.{ .error = "not found" }, .{ .status = .not_found });
return c.json(user, .{});
}
// COUNT โ returns i32
fn countUsers(c: *spider.Ctx) !spider.Response {
const count = try db.query(i32, c.arena, "SELECT COUNT(*) FROM users", .{});
return c.json(.{ .count = count }, .{});
}
// INSERT โ void
fn createUser(c: *spider.Ctx) !spider.Response {
const Input = struct { name: []const u8, email: []const u8 };
const body = try c.bodyJson(Input);
try db.query(void, c.arena,
"INSERT INTO users (name, email) VALUES ($1, $2)",
.{ body.name, body.email },
);
return c.json(.{ .created = true }, .{ .status = .created });
}
```
#### ANY() with array()
```zig
fn batchUsers(c: *spider.Ctx) !spider.Response {
const ids = [_]i32{ 1, 2, 3 };
const rows = try db.query(User, c.arena,
"SELECT id, name, email FROM users WHERE id = ANY($1)",
.{db.array(i32, &ids)},
);
return c.json(rows, .{});
}
```
#### Transactions
```zig
fn transferHandler(c: *spider.Ctx) !spider.Response {
var tx = try db.begin();
defer tx.rollback();
try tx.query(void, c.arena,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
.{ amount, from_id },
);
try tx.query(void, c.arena,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
.{ amount, to_id },
);
try tx.commit();
return c.json(.{ .ok = true }, .{});
}
```
#### Raw SQL (no params)
```zig
// Execute multiple statements separated by ';'
try db.queryExecute(void, c.arena,
"CREATE TEMP TABLE foo (id int); INSERT INTO foo VALUES (1)"
);
// Raw query returning rows
const rows = try db.queryExecute(User, c.arena, "SELECT * FROM users");
// Single row raw query
const user = try db.queryOneExecute(User, c.arena, "SELECT * FROM users LIMIT 1");
```
### SQLite (via libsqlite3)
Requires a C compiler (uses `@import("c_sqlite")`).
```zig
try spider.sqlite.init(arena, .{ .filename = "app.db" });
defer spider.sqlite.deinit();
const Row = struct { id: i32, title: []const u8 };
const rows = try spider.sqlite.query(Row, c.arena,
"SELECT * FROM todos WHERE done = ?", .{false},
);
```
### MySQL (Pure Zig)
Spider's MySQL driver is **pure Zig** โ no libmysqlclient required.
```zig
try spider.mysql.init(arena, io, .{
.host = "localhost",
.database = "myapp",
.user = "root",
.password = "",
});
defer spider.mysql.deinit();
const Row = struct { id: i32, name: []const u8 };
const rows = try spider.mysql.query(Row, c.arena,
"SELECT * FROM products WHERE price > ?", .{100},
);
```
### Database Driver Interface (ORM-friendly)
Spider provides a vtable-based database interface for driver-agnostic code:
```zig
// Register the database with the server
const driver = spider.pg.PgDriver{};
server.db(driver.database());
// Use it from any handler via c.db()
fn handler(c: *spider.Ctx) !spider.Response {
// Works with any registered driver (pg, mysql, etc.)
const users = try c.db().query(User, "SELECT * FROM users", .{});
return c.json(users, .{});
}
// Execute raw SQL on the registered driver
try c.db().exec("CREATE INDEX idx_users_email ON users(email)");
```
---
## Authentication
### JWT
```zig
const auth = spider.auth;
// Sign
const token = try auth.jwtSign(c.arena, .{
.sub = user.id,
.email = user.email,
.name = user.name,
.exp = 9999999999,
}, spider.env.getOr("JWT_SECRET", "changeme"));
// Verify (note: requires c._io)
const Claims = struct { sub: i32, email: []const u8, name: []const u8, exp: i64 };
const claims = try auth.jwtVerify(Claims, c.arena, c._io, token, secret);
// Set cookie
const cookie = try c.setCookie("token", token, .{});
return c.json(.{ .ok = true }, .{
.headers = &.{.{ "Set-Cookie", cookie }},
});
// Clear cookie (logout)
const cookie = try c.setCookie("token", "", .{ .max_age = 0 });
```
```zig
// Legacy cookie helpers (still available in auth module)
const cookie = try auth.cookieSet(c.arena, token);
const clear = try auth.cookieClear(c.arena);
```
### Auth Middleware
```zig
var gAuth = spider.auth.Auth.init(.{
.secret = spider.env.getOr("JWT_SECRET", "changeme"),
.public_paths = &.{ "/login", "/auth/*" },
.redirect_to = "/login",
.secure_cookie = false, // true in production
});
server
.get("/login", loginHandler)
.group("/dashboard", &.{gAuth.asFn()}, dashboardRoutes);
```
### Google OAuth
```zig
const google = spider.google;
const googleConfig = google.GoogleConfig{
.client_id = spider.env.getOr("GOOGLE_CLIENT_ID", ""),
.client_secret = spider.env.getOr("GOOGLE_CLIENT_SECRET", ""),
.redirect_uri = spider.env.getOr("GOOGLE_REDIRECT_URI", ""),
};
// Redirect to Google
fn loginHandler(c: *spider.Ctx) !spider.Response {
const url = try google.authUrl(c.arena, googleConfig);
return c.redirect(url);
}
// Handle callback
fn callbackHandler(c: *spider.Ctx) !spider.Response {
const code = c.query("code") orelse return c.redirect("/login");
const profile = try google.fetchProfile(c, code, googleConfig);
const token = try spider.auth.jwtSign(c.arena, .{
.sub = 0,
.email = profile.email,
.name = profile.name,
.exp = 9999999999,
}, spider.env.getOr("JWT_SECRET", "changeme"));
const cookie = try c.setCookie("token", token, .{});
return c.redirect("/");
}
```
### Clerk OAuth
```zig
const clerk = try spider.clerk.Clerk.init(c.arena, c._io, .{
.publishable_key = spider.env.getOr("CLERK_PUBLISHABLE_KEY", ""),
.secret_key = spider.env.getOr("CLERK_SECRET_KEY", ""),
.redirect_uri = "http://localhost:3000/auth/callback",
});
defer clerk.deinit();
server
.get("/login", userLoginHandler)
.get("/auth/callback", clerk.callbackHandler())
.group("/dashboard", &.{clerk.middleware()}, dashboardRoutes);
fn userLoginHandler(c: *spider.Ctx) !spider.Response {
const url = try clerk.authUrl(c.arena);
return c.redirect(url);
}
```
### Keycloak OAuth (with Refresh Token)
```zig
const kc = try spider.keycloak.Keycloak.init(c.arena, c._io, .{
.base_url = spider.env.getOr("KEYCLOAK_URL", "http://localhost:8080"),
.realm = spider.env.getOr("KEYCLOAK_REALM", "myapp"),
.client_id = spider.env.getOr("KEYCLOAK_CLIENT_ID", ""),
.client_secret = spider.env.getOr("KEYCLOAK_CLIENT_SECRET", ""),
.redirect_uri = "http://localhost:3000/auth/callback",
});
defer kc.deinit();
server
.get("/auth/login", kc.loginHandler())
.get("/auth/callback", kc.callbackHandler())
.get("/auth/refresh", kc.refreshHandler()) // auto-refresh expired tokens
.group("/dashboard", &.{kc.middleware()}, dashboardRoutes);
```
### JWKS-based Auth (Generic)
For any provider that exposes JWKS endpoints (Auth0, Firebase, etc.):
```zig
const jwks = try spider.jwks.JwksAuth.init(c.arena, c._io, .{
.jwks_url = "https://example.com/.well-known/jwks.json",
.issuer = "https://example.com/",
.cookie_name = "__session",
.login_path = "/login",
.refresh_path = "/auth/refresh",
});
defer jwks.deinit();
server
.group("/api", &.{jwks.middleware()}, apiRoutes);
```
---
## WebSocket
Spider's WebSocket support uses the `server.ws()` method for a clean handler interface:
```zig
fn chatHandler(w: *spider.Ws) !void {
// Join a channel
try w.join("room:general");
while (try w.next()) |msg| {
switch (msg.type) {
.text => {
// Send to specific user
try w.send("Message received");
// Broadcast to channel
w.broadcastTo("room:general", msg.data);
// Broadcast to all connected clients
w.broadcast(msg.data);
},
.binary => {},
}
}
}
server.ws("/ws/chat", chatHandler);
```
### WebSocket API
| Method | Description |
|--------|-------------|
| `w.next()` | Wait for next message (returns `?Message`) |
| `w.send(text)` | Send text message to this connection |
| `w.broadcast(text)` | Broadcast to all connections |
| `w.broadcastTo(channel, text)` | Broadcast to a channel |
| `w.broadcastFmt(fmt, args)` | Broadcast formatted text |
| `w.broadcastToFmt(channel, fmt, args)` | Broadcast formatted to channel |
| `w.join(channel)` | Join a channel |
| `w.joinUser(user_id)` | Join user-specific channel (`user:{id}`) |
### WebSocket with Interval (Heartbeat / Periodic Broadcast)
```zig
fn broadcastStats(hub: *spider.Hub) void {
hub.broadcast("heartbeat");
}
server.wsInterval("/ws/stats", 5000, broadcastStats);
```
This creates a WebSocket endpoint that automatically broadcasts the callback result every `N` milliseconds.
### Direct Hub Access
From any handler, access the WebSocket hub to broadcast externally:
```zig
fn someHandler(c: *spider.Ctx) !spider.Response {
const hub = c.wsHub();
hub.broadcast("Event from HTTP handler!");
hub.broadcastToChannel("room:admin", "Admin notification");
hub.notifyUser(42, "private_msg", .{ .text = "Secret!" });
return c.json(.{ .ok = true }, .{});
}
```
---
## Server-Sent Events (SSE)
```zig
fn sseHandler(sse: *spider.Sse) !void {
try sse.join("notifications");
while (true) {
try sse.send("ping", .{ .time = "2024-01-01T00:00:00Z" });
// Keep connection alive
sse.wait();
}
}
server.sse("/events", sseHandler);
```
### SSE API
| Method | Description |
|--------|-------------|
| `s.send(event, data)` | Send an event (data is JSON-serialized) |
| `s.join(channel)` | Join a channel |
| `s.joinUser(user_id)` | Join user-specific channel |
| `s.wait()` | Block until connection closes |
| `s.param(key)` | Access URL parameters |
### Hub Events (Structured Messages)
The Hub supports structured event/data messages for SSE:
```zig
const hub = c.sseHub();
// Emit to all SSE connections
hub.emit("notification", .{ .title = "New message", .body = "Hello!" });
// Emit to a channel
hub.emitTo("user:42", "private", .{ .msg = "Secret" });
// Notify a specific user
hub.notifyUser(42, "alert", .{ .type = "info" });
```
---
## Web Push Notifications
Spider includes a full Web Push implementation (RFC 8291, RFC 8292) with VAPID.
### Generate VAPID Keys
```zig
var threaded = std.Io.Threaded.init_single_threaded;
const io = threaded.io();
const keys = spider.push.WebPush.generateKeys(io);
// Store keys.private_key and keys.public_key
```
Or via CLI:
```bash
spider generate-vapid mailto:admin@example.com
```
### Send Push Notification
```zig
const wp = spider.push.WebPush.init(.{
.subject = "mailto:admin@example.com",
.private_key = spider.env.getOr("VAPID_PRIVATE_KEY", ""),
.public_key = spider.env.getOr("VAPID_PUBLIC_KEY", ""),
});
// Or load from env
const wp = spider.push.WebPush.initFromEnv();
// From a handler
try wp.send(c, .{
.endpoint = "https://fcm.googleapis.com/...",
.p256dh = "...",
.auth = "...",
}, "Hello Push!", 3600);
```
**Requirements:** Uses `spider.http_client` (pacman) under the hood โ no external dependencies.
---
## Cloudflare R2 Object Storage
Spider provides a full R2 client with AWS Signature V4.
```zig
const r2 = spider.r2.R2.init(.{
.account_id = spider.env.getOr("R2_ACCOUNT_ID", ""),
.access_key = spider.env.getOr("R2_ACCESS_KEY", ""),
.secret_key = spider.env.getOr("R2_SECRET_KEY", ""),
.bucket = spider.env.getOr("R2_BUCKET", ""),
.pub_url = spider.env.getOr("R2_PUBLIC_URL", ""),
});
// Or load from env
const r2 = spider.r2.R2.initFromEnv();
```
### Operations
```zig
// Upload
try r2.put(c, "folder/file.txt", file_content, "text/plain");
// Download
const data = try r2.get(c, "folder/file.txt");
// Delete
try r2.delete(c, "folder/file.txt");
// Check existence
const exists = try r2.head(c, "folder/file.txt");
// Presigned URL for direct browser upload
const url = try r2.presignedPut(c.arena, "uploads/file.pdf", "application/pdf", 3600);
// Public URL
const pub = try r2.publicUrl(c.arena, "folder/file.txt");
```
---
## Multipart Uploads
Spider supports `multipart/form-data` parsing for file uploads.
### Parsing Uploaded Files
```zig
fn uploadHandler(c: *spider.Ctx) !spider.Response {
const mp = try c.parseMultipart();
defer mp.deinit();
// Access text fields
const description = mp.getValue("description") orelse "";
// Access uploaded files
const files = mp.getFile("avatar") orelse &.{};
for (files) |file| {
std.log.info("upload: {s} ({d} bytes, {s})", .{
file.filename, file.size, file.content_type,
});
// file.data contains the raw bytes
}
return c.json(.{ .uploaded = files.len }, .{});
}
```
### Typed Form Parsing (auto-detects multipart vs url-encoded)
```zig
const FormInput = struct {
name: []const u8,
email: []const u8,
age: i32,
};
fn formHandler(c: *spider.Ctx) !spider.Response {
const input = try c.parseForm(FormInput);
return c.json(.{ .name = input.name, .email = input.email }, .{});
}
```
---
## Dependency Injection (Decorators)
Spider supports automatic dependency injection into handlers using `spider.app(decorations)`:
```zig
const AppDeps = struct {
pool: *PgPool,
email: *EmailService,
config: AppConfig,
};
fn main() !void {
const deps = AppDeps{
.pool = &pool,
.email = &email_service,
.config = app_config,
};
var server = spider.app(deps);
defer server.deinit();
server
.get("/", homeHandler)
.listen(.{ .port = 3000 }) catch {};
}
// Handler receives dependencies automatically โ no manual wiring needed
fn homeHandler(c: *spider.Ctx, pool: *PgPool, email: *EmailService) !spider.Response {
const users = try pool.query(...);
try email.sendWelcome(...);
return c.json(.{ .ok = true }, .{});
}
```
Up to **4 extra parameters** beyond `*spider.Ctx` are supported. The type of each parameter must match a field in the decorations struct โ otherwise you get a clear compile error.
---
## Static Files
Spider automatically serves `./public/` at `/` โ no configuration needed.
```
public/
โโโ css/
โ โโโ app.css โ GET /css/app.css
โโโ js/
โ โโโ app.js โ GET /js/app.js
โโโ logo.png โ GET /logo.png
```
Path traversal (`../../etc/passwd`) is blocked automatically.
### Custom Static Directory
```zig
// Serve from a different directory
server.staticDir("./assets");
// Serve with a different prefix
server.staticAt("./uploads", "/media");
// /media/images/logo.png โ ./uploads/images/logo.png
```
---
## Live Reload
Spider auto-injects WebSocket live reload in development mode:
```zig
// spider.config.zig
pub const config = spider.Config{
.env = .development, // enables live reload
};
```
When you save a template or static file, the browser refreshes automatically. No configuration needed โ just run `zig build run` in dev mode.
---
## Health Endpoints
When using `spider.app()` or `spider.appWithConfig()`, two health endpoints are registered automatically:
| Endpoint | Description |
|----------|-------------|
| `GET /up` | Simple health check โ returns `"OK"` |
| `GET /_spider/health` | JSON with status and uptime in seconds |
In development mode, a live-reload WebSocket is also registered at `/_spider/reload`.
---
## Metrics
Spider provides global request metrics:
```zig
const snapshot = spider.metrics.snapshot(io);
std.log.info("requests: {d}, errors: {d}", .{
snapshot.total_requests,
snapshot.errors,
});
```
Metrics tracked: total requests, errors, bytes in/out, slow requests, WebSocket clients.
---
## Environment Configuration
Spider automatically loads `.env` files on startup with priority order:
1. `.env` โ base configuration
2. `.env.development` or `.env.production` โ environment-specific
3. `.env.local` โ local overrides (highest priority)
```bash
# .env
DATABASE_URL=postgres://localhost/myapp
JWT_SECRET=my-secret-key
PORT=3000
DEBUG=true
GOOGLE_CLIENT_ID=your-client-id
```
```zig
// Access anywhere in your app
const host = spider.env.getOr("DB_HOST", "localhost");
const port = spider.env.getInt(u16, "PORT", 3000);
const debug = spider.env.getBool("DEBUG", false);
const secret = spider.env.get("JWT_SECRET"); // returns ?[]const u8
```
---
## Configuration
Create `spider.config.zig` in your project root:
```zig
// spider.config.zig
const spider = @import("spider");
pub const config = spider.Config{
.port = 3000,
.host = "127.0.0.1",
.views_dir = "./src",
.layout = "layout",
.static_dir = "./public",
.env = .development,
.workers = null, // defaults to CPU count
};
```
Or configure inline via `spider.appWithConfig()`:
```zig
var server = spider.appWithConfig(spider.Config{
.port = 8080,
.env = .production,
});
```
---
## CLI Tool
Spider ships with a `spider` CLI for project scaffolding:
```bash
# Create a new project
spider new myapp
spider new myapp --daisyui # With DaisyUI preset
spider new myapp --skip-downloads # Skip binary downloads (tailwindcss, alpine, htmx)
# Generate code
spider generate feature # Full CRUD feature
spider generate auth --provider=keycloak # Auth with Keycloak
spider generate auth --provider=google # Auth with Google
# Generate VAPID keys for Web Push
spider generate-vapid mailto:admin@example.com
# Run migrations
spider migrate
# Show version
spider version
# spider v0.6.2
```
---
## HTTP Client
Spider bundles a full HTTP client (`pacman`) accessible via:
```zig
const http = spider.http_client;
var res = try http.get(io, arena, "https://api.example.com/users", .{});
defer res.deinit();
// Parse JSON response
const data = try res.json(ResponseType);
defer data.deinit();
// POST with JSON body
var res = try http.post(io, arena, "https://api.example.com/users", .{
.body = .{ .json = .{ .name = "Alice" } },
});
// POST with form data
var res = try http.post(io, arena, "https://api.example.com/token", .{
.body = .{ .form = &.{
.{ "grant_type", "authorization_code" },
.{ "code", code },
} },
});
```
---
## Project Structure
```
src/
โโโ spider.zig โ Public API (all exports)
โโโ core/
โ โโโ app.zig โ Server, routing, workers, DI, WebSocket/SSE handlers
โ โโโ context.zig โ Ctx, Response, ResponseOptions, CookieOptions
โ โโโ database.zig โ Database vtable interface
โโโ routing/
โ โโโ router.zig โ Trie router (static + dynamic routes)
โ โโโ group.zig โ Route groups
โโโ modules/
โ โโโ auth/auth.zig โ JWT sign/verify, cookie helpers, Auth middleware
โ โโโ static.zig โ Static file serving
โ โโโ dashboard.zig โ Built-in metrics dashboard
โ โโโ livereload.zig โ Live reload (dev mode)
โ โโโ health.zig โ /up and /_spider/health endpoints
โ โโโ push.zig โ Web Push (RFC 8291/8292)
โ โโโ r2.zig โ Cloudflare R2 (AWS SigV4)
โ โโโ logger.zig โ Colorized request logger middleware
โโโ drivers/
โ โโโ pg/pg.zig โ PostgreSQL driver (pure Zig, pool-based)
โ โโโ sqlite/sqlite.zig โ SQLite driver (via libsqlite3 C binding)
โ โโโ mysql/ โ MySQL driver (pure Zig wire protocol)
โโโ render/
โ โโโ template.zig โ Template engine entry point
โ โโโ views.zig โ Template resolver (embed + runtime)
โ โโโ ast.zig โ AST node types
โ โโโ parser.zig โ Template parser
โ โโโ renderer.zig โ Template renderer
โ โโโ context.zig โ Template rendering context
โ โโโ zmd/ โ Markdown to HTML renderer
โโโ internal/
โ โโโ config.zig โ spider.Config
โ โโโ env.zig โ .env loader
โ โโโ logger.zig โ Structured logging
โ โโโ metrics.zig โ Request/error metrics
โ โโโ buffer_pool.zig โ Buffer pooling
โโโ ws/
โ โโโ websocket.zig โ WebSocket protocol (RFC 6455)
โ โโโ hub.zig โ Broadcast hub (WebSocket + SSE)
โ โโโ ws.zig โ Ws handler interface (next, send, broadcast, join)
โ โโโ sse.zig โ SSE handler interface (send, join, wait)
โโโ binding/
โ โโโ form.zig โ URL-encoded form parsing
โ โโโ form_parser.zig โ Typed form binding (struct mapping)
โ โโโ multipart.zig โ Multipart/form-data parsing
โโโ providers/
โ โโโ google.zig โ Google OAuth
โ โโโ clerk.zig โ Clerk OAuth + JWKS middleware
โ โโโ jwks.zig โ JWKS key fetching + JWT verification
โ โโโ keycloak.zig โ Keycloak OAuth + refresh token
โโโ cli/
โ โโโ main.zig โ CLI entry point
โ โโโ new.zig โ `spider new` project scaffolding
โ โโโ generate.zig โ `spider generate` code generation
โ โโโ migrate.zig โ `spider migrate` runner
โ โโโ generate_vapid.zig โ VAPID key generation
โ โโโ templates/ โ Scaffolding templates
โโโ features/ โ Built-in features (scaffolded code)
โโโ build_helpers.zig โ spider_build.setup() helper
โโโ generate_templates.zig โ embedded_templates.zig generator
```
---
## API Reference
### `spider.Ctx` Methods
| Method | Description |
|--------|-------------|
| `c.json(data, opts)` | JSON response |
| `c.text(content, opts)` | Plain text response |
| `c.html(content, opts)` | HTML response |
| `c.view(name, data, opts)` | Render template by name |
| `c.render(tmpl, data, opts)` | Render template string directly |
| `c.redirect(url)` | HTTP redirect (302) |
| `c.param(name)` | URL parameter |
| `c.query(name)` | Query string parameter |
| `c.header(name)` | Request header |
| `c.cookie(name)` | Request cookie |
| `c.getBody()` | Raw request body |
| `c.bodyJson(T)` | Parse JSON body into struct |
| `c.parseForm(T)` | Parse form body (auto-detects url-encoded + multipart) |
| `c.parseMultipart()` | Parse multipart/form-data (returns MultipartData) |
| `c.setCookie(name, value, opts)` | Build Set-Cookie string |
| `c.withCookie(name, value, opts)` | Build ResponseOptions with cookie |
| `c.isHtmx()` | True if HX-Request header present |
| `c.isBoosted()` | True if HX-Boosted header present |
| `c.db()` | DatabaseCtx for driver-agnostic queries |
| `c.wsHub()` | WebSocket Hub (must be in ws route) |
| `c.sseHub()` | SSE Hub (must be in sse route) |
| `c.getPath()` | Request path |
| `c.getMethod()` | Request method string |
| `c.arena` | Per-request arena allocator |
### `spider.ResponseOptions`
```zig
pub const ResponseOptions = struct {
status: std.http.Status = .ok,
headers: []const [2][]const u8 = &.{},
cookies: []const [2][]const u8 = &.{},
};
```
### `spider.CookieOptions`
```zig
pub const CookieOptions = struct {
value: []const u8 = "",
http_only: bool = true,
secure: bool = true,
same_site: []const u8 = "Lax",
path: []const u8 = "/",
max_age: ?u32 = null,
};
```
### `spider.Server` Methods
| Method | Description |
|--------|-------------|
| `server.get(path, handler)` | Register GET route |
| `server.post(path, handler)` | Register POST route |
| `server.put(path, handler)` | Register PUT route |
| `server.delete(path, handler)` | Register DELETE route |
| `server.patch(path, handler)` | Register PATCH route |
| `server.head(path, handler)` | Register HEAD route |
| `server.ws(path, handler)` | Register WebSocket route |
| `server.wsInterval(path, ms, callback)` | WebSocket with periodic broadcast |
| `server.sse(path, handler)` | Register SSE route |
| `server.use(middleware)` | Global middleware |
| `server.useAt(path, middleware)` | Path-scoped middleware |
| `server.group(prefix, mws, fn)` | Route group with middleware |
| `server.onError(handler)` | Global error handler |
| `server.addRoute(method, path, mws, handler)` | Route with middleware |
| `server.db(database)` | Register database driver |
| `server.staticDir(dir)` | Set static files directory |
| `server.staticAt(dir, prefix)` | Static dir with custom prefix |
| `server.health(path, handler)` | Alias for server.get |
| `server.listen(options)` | Start server |
### `spider.pg` Methods (aliased as `const db = spider.pg`)
| Method | Description |
|--------|-------------|
| `db.init(io, config)` | Initialize pool (DbConfig with optional overrides) |
| `db.deinit()` | Shutdown pool |
| `db.query(T, arena, sql, params)` | Parameterized query โ `[]T`, `i32`, or `void` |
| `db.queryOne(T, arena, sql, params)` | Parameterized query โ `?T` (single row) |
| `db.queryExecute(T, arena, sql)` | Raw SQL without params |
| `db.queryOneExecute(T, arena, sql)` | Raw SQL single row |
| `db.array(T, values)` | Create array param for `ANY($1)` |
| `db.begin()` | Start transaction โ `Transaction` |
| `tx.query(T, arena, sql, params)` | Query inside transaction |
| `tx.queryOne(T, arena, sql, params)` | Single row inside transaction |
| `tx.commit()` | Commit transaction |
| `tx.rollback()` | Rollback transaction |
### `spider.Ws` Methods
| Method | Description |
|--------|-------------|
| `w.next()` | Wait for next message (`?Message`) |
| `w.send(text)` | Send text to this connection |
| `w.broadcast(text)` | Broadcast to all connections |
| `w.broadcastTo(channel, text)` | Broadcast to channel |
| `w.broadcastFmt(fmt, args)` | Broadcast formatted text |
| `w.broadcastToFmt(channel, fmt, args)` | Broadcast formatted to channel |
| `w.join(channel)` | Join a channel |
| `w.joinUser(user_id)` | Join user channel (`user:{id}`) |
### `spider.Sse` Methods
| Method | Description |
|--------|-------------|
| `s.send(event, data)` | Send an event (JSON data) |
| `s.join(channel)` | Join a channel |
| `s.joinUser(user_id)` | Join user channel |
| `s.wait()` | Block until connection closes |
### `spider.Hub` Methods
| Method | Description |
|--------|-------------|
| `hub.broadcast(msg)` | Broadcast to all WS + SSE connections |
| `hub.broadcastToChannel(channel, msg)` | Broadcast to channel |
| `hub.broadcastFmt(fmt, args)` | Broadcast formatted |
| `hub.emit(event, data)` | Emit JSON event (SSE) |
| `hub.emitTo(channel, event, data)` | Emit JSON event to channel |
| `hub.notifyUser(user_id, event, data)` | Notify user `user:{id}` |
---
## Examples
- ๐ **[SpiderStack](examples/spiderstack/)** โ ~~Full-featured starter kit with Google OAuth, PostgreSQL, HTMX, Tailwind, and DaisyUI~~ **Desatualizado โ nรฃo recomendado no momento**
- ๐ฆ **[local_first](examples/local_first/)** โ Local-first architecture example
- ๐๏ธ **[embed_templates](examples/embed_templates/)** โ Template embed mode example
- ๐ง **[c_import_zig_017](examples/c_import_zig_017/)** โ C imports with Zig 0.17
- ๐ **[hot_relead](examples/hot_relead/)** โ Hot reload example
---
## Zig Version Policy
Spider tracks Zig `master` โ always.
We follow Zig's development branch closely, migrating ahead of each stable release. This means Spider is ready for the new version before it ships, and breaking changes are handled as they happen โ not after.
| Version | Status |
|---------|--------|
| `0.17.0-dev` | โ
current |
| `0.16.0` | โ
migrated before release |
| `0.15.0` | โ
migrated before release |
If you're on a stable Zig release and Spider doesn't compile, check the git history โ the migration is usually already done.
---
## Author
Built by **Seven** (erivan cerqueira) โ follow the journey on
[YouTube](https://www.youtube.com/@llllOllOOl) where Seven posts
videos about Zig and Spider development.
๐ฌ Discord: `llll0ll00ll`
---
## License
MIT