An open API service indexing awesome lists of open source software.

https://github.com/llllOllOOll/spider


https://github.com/llllOllOOll/spider

Last synced: 7 days ago
JSON representation

Awesome Lists containing this project

README

          

# Spider Logo 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| {
  • { user.name } โ€” { user.email }

  • }
    ```

    ```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") {

  • Admin Panel

  • } else if (role == "moderator") {
  • Moderator Tools

  • } else {
  • Standard User

  • }
    ```

    ### 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