{"id":50198448,"url":"https://github.com/llllOllOOll/spider","last_synced_at":"2026-06-11T11:00:49.422Z","repository":{"id":355465977,"uuid":"1225395820","full_name":"llllOllOOll/spider","owner":"llllOllOOll","description":null,"archived":false,"fork":false,"pushed_at":"2026-06-06T00:54:35.000Z","size":4691,"stargazers_count":32,"open_issues_count":1,"forks_count":3,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-06T02:13:10.839Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"C","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/llllOllOOll.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-30T08:31:10.000Z","updated_at":"2026-06-06T00:54:40.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/llllOllOOll/spider","commit_stats":null,"previous_names":["llllollooll/spider"],"tags_count":14,"template":false,"template_full_name":null,"purl":"pkg:github/llllOllOOll/spider","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/llllOllOOll%2Fspider","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/llllOllOOll%2Fspider/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/llllOllOOll%2Fspider/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/llllOllOOll%2Fspider/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/llllOllOOll","download_url":"https://codeload.github.com/llllOllOOll/spider/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/llllOllOOll%2Fspider/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34195117,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-11T02:00:06.485Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-05-25T20:00:20.125Z","updated_at":"2026-06-11T11:00:49.410Z","avatar_url":"https://github.com/llllOllOOll.png","language":"C","funding_links":[],"categories":["Network \u0026 Web"],"sub_categories":["Web Framework"],"readme":"# \u003cimg src=\"assets/spider_logo.png\" width=\"32\" height=\"32\" alt=\"Spider Logo\"\u003e Spider v0.6.2\n\nBuild web servers in Zig — performant, productive, and batteries-included.\n\n**Batteries included:** PostgreSQL, SQLite, MySQL, JWT auth, Google OAuth,\nClerk, Keycloak, WebSockets, SSE, Web Push, Cloudflare R2, multipart upload,\nHTMX support, CLI tool, and a powerful template engine.\n\n📖 **Documentation:** this README  \n🔧 **CLI:** `spider new myapp`\n\n---\n\n## Installation\n\n### Quick Install (Recommended)\n\n```bash\ncurl -fsSL https://spiderme.org/install.sh | bash\n```\n\nOr specify a version:\n\n```bash\ncurl -fsSL https://spiderme.org/install.sh | bash -s -- --version v0.6.2\n```\n\n### Manual Install\n\nAdd Spider as a dependency in your `build.zig.zon`:\n\n```bash\nzig fetch --save git+https://github.com/llllOllOOll/spider#main\n```\n\nThen in your `build.zig`:\n\n```zig\nconst spider_dep = b.dependency(\"spider\", .{ .target = target });\nconst spider_mod = spider_dep.module(\"spider\");\n\nconst exe = b.addExecutable(.{\n    .name = \"myapp\",\n    .root_module = b.createModule(.{\n        .root_source_file = b.path(\"src/main.zig\"),\n        .target = target,\n        .optimize = optimize,\n        .imports = \u0026.{\n            .{ .name = \"spider\", .module = spider_mod },\n        },\n    }),\n});\n```\n\nAlternatively, use the **build helper** for one-line setup:\n\n```zig\nconst spider_build = @import(\"spider_build\");\nspider_build.setup(b, exe, spider_dep);\n```\n\nThis automatically detects `spider.config.zig` and runs the template generator.\n\n---\n\n## Requirements\n\n- Zig `0.17.0-dev` or compatible\n\n```bash\nzig version\n# 0.17.0-dev.93+76174e1bc\n```\n\n---\n\n## Quick Start\n\n```zig\nconst std = @import(\"std\");\nconst spider = @import(\"spider\");\n\n// Embed templates (optional — one line enables embed mode)\npub const spider_templates = @import(\"embedded_templates.zig\").EmbeddedTemplates;\n\npub fn main() void {\n    var server = spider.app(.{});\n    defer server.deinit();\n\n    server\n        .get(\"/\", homeHandler)\n        .get(\"/users/:id\", userHandler)\n        .post(\"/users\", createUserHandler)\n        .listen(.{ .port = 3000 }) catch {};\n}\n\nfn homeHandler(c: *spider.Ctx) !spider.Response {\n    return c.json(.{ .message = \"Hello from Spider!\" }, .{});\n}\n\nfn userHandler(c: *spider.Ctx) !spider.Response {\n    const id = c.param(\"id\") orelse \"unknown\";\n    return c.json(.{ .user_id = id }, .{});\n}\n\nfn createUserHandler(c: *spider.Ctx) !spider.Response {\n    const User = struct { name: []const u8, email: []const u8 };\n    const body = try c.bodyJson(User);\n    return c.json(.{ .created = true, .name = body.name }, .{ .status = .created });\n}\n```\n\n```bash\nzig build run\n# Server listening on http://127.0.0.1:3000\n# Starting 12 worker threads\n```\n\n`listen` accepts both `port` and `host` — any field not set falls back to the values in `spider.config.zig`:\n\n```zig\n.listen(.{ .port = 8080 })                          // override port only\n.listen(.{ .host = \"0.0.0.0\" })                     // override host only\n.listen(.{ .port = 8080, .host = \"0.0.0.0\" })       // override both\n.listen(.{})                                         // use config values\n```\n\n---\n\n## Context — `c: *spider.Ctx`\n\nEvery handler receives a `*spider.Ctx`. It provides everything you need — no allocators, no I/O wiring required.\n\n### Responses\n\n```zig\n// JSON\nreturn c.json(.{ .id = 1, .name = \"Alice\" }, .{});\n\n// JSON with custom status\nreturn c.json(.{ .error = \"not found\" }, .{ .status = .not_found });\n\n// JSON with custom headers\nreturn c.json(.{ .ok = true }, .{\n    .headers = \u0026.{.{ \"X-Powered-By\", \"Spider\" }},\n});\n\n// Plain text\nreturn c.text(\"Hello!\", .{});\n\n// HTML\nreturn c.html(\"\u003ch1\u003eHello\u003c/h1\u003e\", .{});\n\n// Redirect\nreturn c.redirect(\"/dashboard\");\n\n// Render template by name (auto-detects .html/.md extension)\nreturn c.view(\"users/index\", .{ .users = users }, .{});\n\n// Render template string directly\nreturn c.render(\"Hello { name }!\", .{ .name = \"World\" }, .{});\n```\n\n### Reading Requests\n\n```zig\n// URL parameter: /users/:id\nconst id = c.param(\"id\") orelse \"unknown\";\n\n// Query string: /search?q=zig\nconst q = c.query(\"q\") orelse \"\";\n\n// Request header\nconst ua = c.header(\"User-Agent\") orelse \"\";\n\n// Cookie\nconst session = c.cookie(\"token\") orelse \"\";\n\n// Raw body\nconst raw = c.getBody() orelse \"\";\n\n// Parse JSON body\nconst User = struct { name: []const u8, email: []const u8 };\nconst user = try c.bodyJson(User);\n\n// Parse form body (auto-detects url-encoded and multipart)\nconst input = try c.parseForm(FormInput);\n\n// Parse multipart form (when you need file uploads)\nconst mp = try c.parseMultipart();\nconst title = mp.getValue(\"title\") orelse \"\";\nconst files = mp.getFile(\"avatar\") orelse \u0026.{};\n```\n\n### Arena Allocator\n\n```zig\n// Allocate freely — Spider cleans up after each request\nconst msg = try std.fmt.allocPrint(c.arena, \"Hello, {s}!\", .{name});\nreturn c.json(.{ .message = msg }, .{});\n```\n\n### HTMX Detection\n\n```zig\nfn handler(c: *spider.Ctx) !spider.Response {\n    if (c.isHtmx()) {\n        // return partial HTML fragment\n        return c.view(\"users/_list\", data, .{});\n    }\n    // return full page\n    return c.view(\"users/index\", data, .{});\n}\n```\n\n### Cookies\n\n```zig\n// Read a cookie\nconst token = c.cookie(\"session\") orelse \"\";\n\n// Set a cookie (returns the Set-Cookie string)\nconst cookie = try c.setCookie(\"session\", jwt, .{\n    .http_only = true,\n    .secure = true,\n    .same_site = \"Lax\",\n    .path = \"/\",\n    .max_age = 86400 * 7,\n});\n\n// Set a cookie via ResponseOptions helper\nconst opts = try c.withCookie(\"session\", jwt, .{\n    .max_age = 86400,\n});\n\n// Include cookie in response\nreturn c.json(.{ .ok = true }, .{\n    .headers = \u0026.{.{ \"Set-Cookie\", cookie }},\n});\n```\n\n### Database inside Context\n\n```zig\n// If you've registered a database via server.db(), use c.db()\npub fn handler(c: *spider.Ctx) !spider.Response {\n    const users = try c.db().query(User, \"SELECT * FROM users WHERE active = $1\", .{true});\n    return c.json(users, .{});\n}\n```\n\n---\n\n## Routing\n\n```zig\nserver\n    .get(\"/\", homeHandler)\n    .post(\"/users\", createUser)\n    .get(\"/users/:id\", getUser)\n    .put(\"/users/:id\", updateUser)\n    .delete(\"/users/:id\", deleteUser)\n    .patch(\"/users/:id\", patchUser)\n    .head(\"/users/:id\", headUser);\n```\n\n### Route Groups\n\nGroups allow sharing middleware across a set of routes.\n\n```zig\nfn dashboardRoutes(s: *spider.Server, prefix: []const u8, mws: []const spider.MiddlewareFn) void {\n    s.addRoute(.GET, \"/dashboard\", mws, dashHandler);\n    s.addRoute(.GET, \"/dashboard/users\", mws, usersHandler);\n}\n\nserver\n    .group(\"/dashboard\", \u0026.{authMiddleware}, dashboardRoutes)\n    .get(\"/login\", loginHandler);\n```\n\n### Route-specific Middleware\n\n```zig\n// Register a route with specific middlewares\nserver.addRoute(.POST, \"/admin/users\", \u0026.{authMiddleware, adminMiddleware}, createUser);\n```\n\n---\n\n## Middleware\n\n```zig\n// Global — applies to all routes\nserver.use(loggerMiddleware);\n\n// By path prefix\nserver.useAt(\"/api/*\", apiMiddleware);\n\n// Per group\nserver.group(\"/admin\", \u0026.{authMiddleware, adminMiddleware}, adminRoutes);\n\n// Global error handler\nserver.onError(errorHandler);\n```\n\n### Writing Middleware\n\n```zig\nfn loggerMiddleware(c: *spider.Ctx, next: spider.NextFn) !spider.Response {\n    std.log.info(\"{s} {s}\", .{ c.getMethod(), c.getPath() });\n    const res = try next(c);\n    std.log.info(\"  → {d}\", .{@intFromEnum(res.status)});\n    return res;\n}\n\nfn authMiddleware(c: *spider.Ctx, next: spider.NextFn) !spider.Response {\n    const token = c.cookie(\"token\") orelse\n        return c.redirect(\"/login\");\n    _ = try spider.auth.jwtVerify(spider.auth.Claims, c.arena, c._io, token, secret);\n    return next(c);\n}\n```\n\n### Built-in Logger Middleware\n\nSpider includes a colorized request logger:\n\n```zig\nserver.use(spider.logger);\n// GET  /users  200  12ms\n// POST /api    401  3µs\n```\n\n---\n\n## Templates\n\nSpider's template engine uses an **AST parser** with support for variables, loops, conditions, includes, layout inheritance, **components** (PascalCase), **named slots**, and **Markdown**.\n\n### Template Syntax\n\n```html\n\u003c!-- views/layout.html --\u003e\n\u003c!DOCTYPE html\u003e\n\u003chtml\u003e\n\u003cbody\u003e\n\u003cnav\u003eMy App\u003c/nav\u003e\n\u003cmain\u003e{ slot }\u003c/main\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n```\n\n```html\n\u003c!-- views/users/index.html --\u003e\nextends \"layout\"\n\u003ch1\u003eUsers\u003c/h1\u003e\nfor (users) |user| {\n  \u003cli\u003e{ user.name } — { user.email }\u003c/li\u003e\n}\n```\n\n```zig\n// Handler — just the name, Spider handles the rest\nfn usersHandler(c: *spider.Ctx) !spider.Response {\n    const users = try db.query(User, \"SELECT * FROM users\", .{});\n    return c.view(\"users/index\", .{ .users = users }, .{});\n}\n```\n\n### Conditionals\n\n```html\nif (user.active) {\n  \u003cspan class=\"badge\"\u003eActive\u003c/span\u003e\n} else {\n  \u003cspan class=\"badge muted\"\u003eInactive\u003c/span\u003e\n}\n// else if chains\nif (role == \"admin\") {\n  \u003cli\u003eAdmin Panel\u003c/li\u003e\n} else if (role == \"moderator\") {\n  \u003cli\u003eModerator Tools\u003c/li\u003e\n} else {\n  \u003cli\u003eStandard User\u003c/li\u003e\n}\n```\n\n### Coalescing (defaults)\n\n```html\n\u003cp\u003eHello, { name ?? \"Guest\" }\u003c/p\u003e\n```\n\n### List length\n\n```html\nif (users.len \u003e 0) {\n  \u003cp\u003e{ users.len } users found\u003c/p\u003e\n}\n```\n\n### Components (PascalCase)\n\nCreate reusable components with PascalCase naming:\n\n```html\n\u003c!-- views/components/UserInfo.html --\u003e\n\u003cdiv class=\"user-card\"\u003e\n\u003ch3\u003e{ name }\u003c/h3\u003e\n\u003cp\u003e{ email }\u003c/p\u003e\n{ slot }\n\u003c/div\u003e\n```\n\n```html\n\u003c!-- Usage in another template --\u003e\n\u003cUserInfo name=\"Alice\" email=\"alice@spider.dev\"\u003e\n  \u003cp\u003eExtra content here\u003c/p\u003e\n\u003c/UserInfo\u003e\n\u003c!-- Self-closing (no slot content) --\u003e\n\u003cUserInfo name=\"Bob\" email=\"bob@spider.dev\" /\u003e\n```\n\n### Named Slots\n\n```html\n\u003c!-- views/components/PageLayout.html --\u003e\n\u003cheader\u003e{ slot_header }\u003c/header\u003e\n\u003cmain\u003e{ slot }\u003c/main\u003e\n\u003caside\u003e{ slot_sidebar }\u003c/aside\u003e\n\u003c!-- Usage --\u003e\n\u003cPageLayout\u003e\n  \u003ch1 slot=\"header\"\u003eDashboard\u003c/h1\u003e\n  \u003cp\u003eWelcome back!\u003c/p\u003e\n  \u003cnav slot=\"sidebar\"\u003e...\u003c/nav\u003e\n\u003c/PageLayout\u003e\n```\n\n### Markdown Support\n\nSpider auto-detects Markdown files via `--doc` signature in frontmatter:\n\n```markdown\n\u003c!-- views/docs/api.md --\u003e\n--doc\ntitle: API Documentation\nlayout: docs_layout\n--\n# API Reference\nWelcome to the API docs...\n```\n\n```zig\n// Handler — auto-detects .md extension\nreturn c.view(\"docs/api\", .{}, .{});\n```\n\n### Template Tags\n\n| Tag | Description |\n|-----|-------------|\n| `{ variable }` | Variable interpolation |\n| `{ variable ?? \"default\" }` | Coalescing operator (default value) |\n| `if (condition) { ... }` | Conditional |\n| `if (a) { ... } else if (b) { ... } else { ... }` | If / else if / else |\n| `for (items) \\|item\\| { ... }` | Loop with capture |\n| `extends \"layout\"` | Layout inheritance (top of file) |\n| `\u003cComponentName prop=\"value\"\u003e` | PascalCase component (with slot) |\n| `\u003cComponentName prop=\"value\" /\u003e` | Self-closing component |\n| `{ slot }` | Default slot content |\n| `{ slot_name }` | Named slot content |\n\n### Template Modes\n\nSpider has two template modes. Both produce **byte-identical output** — the only difference is when templates are loaded.\n\n**Embed mode** — templates compiled into the binary (recommended for production):\n\n```zig\n// root.zig or main.zig — one line enables embed mode\npub const spider_templates = @import(\"embedded_templates.zig\").EmbeddedTemplates;\n```\n\nSpider 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.\n\n**Runtime mode** — reads from disk at request time (useful in development):\n\n```zig\n// main.zig — nothing needed, just don't declare spider_templates\n// Spider scans views_dir and serves templates from disk\n```\n\nDetection uses `@hasDecl(@import(\"root\"), \"spider_templates\")` — same pattern as `std_options` in the Zig stdlib.\n\n#### spider.config.zig\n\nWhen using runtime mode, create `spider.config.zig` in your project root to configure the template directory:\n\n```zig\n// spider.config.zig\nconst spider = @import(\"spider\");\n\npub const config = spider.Config{\n    .views_dir = \"./src\",   // point to where your .html/.md files live\n    .layout = \"layout\",\n    .env = .development,\n    .port = 3000,\n    .host = \"0.0.0.0\",\n};\n```\n\nSpider prints warnings to help diagnose issues:\n\n```\n[spider] WARNING: views_dir \"./views\" not found.\n[spider]          Templates will not load in runtime mode.\n\n[spider] runtime templates: 5 loaded from \"./src\"\n```\n\n#### Template name normalization\n\n| File path (relative to views_dir) | Normalized name | Call with |\n|---|---|---|\n| `views/bills/index.html` | `bills_index` | `c.view(\"bills/index\", ...)` |\n| `views/home/index.html` | `home_index` | `c.view(\"home/index\", ...)` |\n| `shared/templates/layout.html` | `layout` | layout (auto, via config) |\n| `shared/templates/Card.html` | `Card` | `c.view(\"Card\", ...)` |\n| `shared/templates/site-nav.html` | `site_nav` | `\u003cSiteNav /\u003e` in templates |\n\nRules: strip extension → use segment after `views/` or `templates/` → replace `/` and `-` with `_`.\n\n---\n\n## Database\n\n### PostgreSQL (Pure Zig)\n\nSpider'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`, ...).\n\n\u003e 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.\n\n\n```zig\nconst std = @import(\"std\");\nconst spider = @import(\"spider\");\nconst db = spider.pg;\n\npub fn main() !void {\n    // Initialize — reads env vars with fallback defaults\n    var threaded = std.Io.Threaded.init_single_threaded;\n    const io = threaded.io();\n    try db.init(io, .{});\n    defer db.deinit();\n\n    var server = spider.app(.{});\n    defer server.deinit();\n\n    server\n        .get(\"/users\", listUsers)\n        .listen(.{ .port = 3000 }) catch {};\n}\n```\n\nAll `DbConfig` fields are optional — they fall back to environment variables:\n\n| Field | Env var | Default |\n|-------|---------|---------|\n| `.host` | `PG_HOST` | `\"localhost\"` |\n| `.port` | `PG_PORT` | `5432` |\n| `.user` | `PG_USER` | `\"spider\"` |\n| `.password` | `PG_PASSWORD` | `\"spider\"` |\n| `.database` | `PG_DB` | `\"spider_db\"` |\n| `.pool_size` | — | `10` |\n\nSo `try db.init(io, .{});` reads everything from your `.env` file.\n\n#### Queries\n\n`db.query(T, arena, sql, params)` returns `[]T` for structs, `i32` for counts, `void` for INSERT/UPDATE/DELETE.\n\n```zig\nconst User = struct { id: i32, name: []const u8, email: []const u8 };\n\n// SELECT — returns []User allocated in c.arena\nfn listUsers(c: *spider.Ctx) !spider.Response {\n    const users = try db.query(User, c.arena,\n        \"SELECT id, name, email FROM users WHERE active = $1\",\n        .{true},\n    );\n    return c.json(users, .{});\n}\n\n// SELECT one row — returns ?User\nfn getUser(c: *spider.Ctx) !spider.Response {\n    const id = try std.fmt.parseInt(i32, c.param(\"id\") orelse \"0\", 10);\n    const user = try db.queryOne(User, c.arena,\n        \"SELECT id, name, email FROM users WHERE id = $1\",\n        .{id},\n    ) orelse return c.json(.{ .error = \"not found\" }, .{ .status = .not_found });\n    return c.json(user, .{});\n}\n\n// COUNT — returns i32\nfn countUsers(c: *spider.Ctx) !spider.Response {\n    const count = try db.query(i32, c.arena, \"SELECT COUNT(*) FROM users\", .{});\n    return c.json(.{ .count = count }, .{});\n}\n\n// INSERT — void\nfn createUser(c: *spider.Ctx) !spider.Response {\n    const Input = struct { name: []const u8, email: []const u8 };\n    const body = try c.bodyJson(Input);\n    try db.query(void, c.arena,\n        \"INSERT INTO users (name, email) VALUES ($1, $2)\",\n        .{ body.name, body.email },\n    );\n    return c.json(.{ .created = true }, .{ .status = .created });\n}\n```\n\n#### ANY() with array()\n\n```zig\nfn batchUsers(c: *spider.Ctx) !spider.Response {\n    const ids = [_]i32{ 1, 2, 3 };\n    const rows = try db.query(User, c.arena,\n        \"SELECT id, name, email FROM users WHERE id = ANY($1)\",\n        .{db.array(i32, \u0026ids)},\n    );\n    return c.json(rows, .{});\n}\n```\n\n#### Transactions\n\n```zig\nfn transferHandler(c: *spider.Ctx) !spider.Response {\n    var tx = try db.begin();\n    defer tx.rollback();\n\n    try tx.query(void, c.arena,\n        \"UPDATE accounts SET balance = balance - $1 WHERE id = $2\",\n        .{ amount, from_id },\n    );\n    try tx.query(void, c.arena,\n        \"UPDATE accounts SET balance = balance + $1 WHERE id = $2\",\n        .{ amount, to_id },\n    );\n    try tx.commit();\n\n    return c.json(.{ .ok = true }, .{});\n}\n```\n\n#### Raw SQL (no params)\n\n```zig\n// Execute multiple statements separated by ';'\ntry db.queryExecute(void, c.arena,\n    \"CREATE TEMP TABLE foo (id int); INSERT INTO foo VALUES (1)\"\n);\n\n// Raw query returning rows\nconst rows = try db.queryExecute(User, c.arena, \"SELECT * FROM users\");\n\n// Single row raw query\nconst user = try db.queryOneExecute(User, c.arena, \"SELECT * FROM users LIMIT 1\");\n```\n\n### SQLite (via libsqlite3)\n\nRequires a C compiler (uses `@import(\"c_sqlite\")`).\n\n```zig\ntry spider.sqlite.init(arena, .{ .filename = \"app.db\" });\ndefer spider.sqlite.deinit();\n\nconst Row = struct { id: i32, title: []const u8 };\nconst rows = try spider.sqlite.query(Row, c.arena,\n    \"SELECT * FROM todos WHERE done = ?\", .{false},\n);\n```\n\n### MySQL (Pure Zig)\n\nSpider's MySQL driver is **pure Zig** — no libmysqlclient required.\n\n```zig\ntry spider.mysql.init(arena, io, .{\n    .host = \"localhost\",\n    .database = \"myapp\",\n    .user = \"root\",\n    .password = \"\",\n});\ndefer spider.mysql.deinit();\n\nconst Row = struct { id: i32, name: []const u8 };\nconst rows = try spider.mysql.query(Row, c.arena,\n    \"SELECT * FROM products WHERE price \u003e ?\", .{100},\n);\n```\n\n### Database Driver Interface (ORM-friendly)\n\nSpider provides a vtable-based database interface for driver-agnostic code:\n\n```zig\n// Register the database with the server\nconst driver = spider.pg.PgDriver{};\nserver.db(driver.database());\n\n// Use it from any handler via c.db()\nfn handler(c: *spider.Ctx) !spider.Response {\n    // Works with any registered driver (pg, mysql, etc.)\n    const users = try c.db().query(User, \"SELECT * FROM users\", .{});\n    return c.json(users, .{});\n}\n\n// Execute raw SQL on the registered driver\ntry c.db().exec(\"CREATE INDEX idx_users_email ON users(email)\");\n```\n\n---\n\n## Authentication\n\n### JWT\n\n```zig\nconst auth = spider.auth;\n\n// Sign\nconst token = try auth.jwtSign(c.arena, .{\n    .sub = user.id,\n    .email = user.email,\n    .name = user.name,\n    .exp = 9999999999,\n}, spider.env.getOr(\"JWT_SECRET\", \"changeme\"));\n\n// Verify (note: requires c._io)\nconst Claims = struct { sub: i32, email: []const u8, name: []const u8, exp: i64 };\nconst claims = try auth.jwtVerify(Claims, c.arena, c._io, token, secret);\n\n// Set cookie\nconst cookie = try c.setCookie(\"token\", token, .{});\nreturn c.json(.{ .ok = true }, .{\n    .headers = \u0026.{.{ \"Set-Cookie\", cookie }},\n});\n\n// Clear cookie (logout)\nconst cookie = try c.setCookie(\"token\", \"\", .{ .max_age = 0 });\n```\n\n```zig\n// Legacy cookie helpers (still available in auth module)\nconst cookie = try auth.cookieSet(c.arena, token);\nconst clear = try auth.cookieClear(c.arena);\n```\n\n### Auth Middleware\n\n```zig\nvar gAuth = spider.auth.Auth.init(.{\n    .secret = spider.env.getOr(\"JWT_SECRET\", \"changeme\"),\n    .public_paths = \u0026.{ \"/login\", \"/auth/*\" },\n    .redirect_to = \"/login\",\n    .secure_cookie = false, // true in production\n});\n\nserver\n    .get(\"/login\", loginHandler)\n    .group(\"/dashboard\", \u0026.{gAuth.asFn()}, dashboardRoutes);\n```\n\n### Google OAuth\n\n```zig\nconst google = spider.google;\n\nconst googleConfig = google.GoogleConfig{\n    .client_id     = spider.env.getOr(\"GOOGLE_CLIENT_ID\", \"\"),\n    .client_secret = spider.env.getOr(\"GOOGLE_CLIENT_SECRET\", \"\"),\n    .redirect_uri  = spider.env.getOr(\"GOOGLE_REDIRECT_URI\", \"\"),\n};\n\n// Redirect to Google\nfn loginHandler(c: *spider.Ctx) !spider.Response {\n    const url = try google.authUrl(c.arena, googleConfig);\n    return c.redirect(url);\n}\n\n// Handle callback\nfn callbackHandler(c: *spider.Ctx) !spider.Response {\n    const code = c.query(\"code\") orelse return c.redirect(\"/login\");\n    const profile = try google.fetchProfile(c, code, googleConfig);\n\n    const token = try spider.auth.jwtSign(c.arena, .{\n        .sub = 0,\n        .email = profile.email,\n        .name = profile.name,\n        .exp = 9999999999,\n    }, spider.env.getOr(\"JWT_SECRET\", \"changeme\"));\n\n    const cookie = try c.setCookie(\"token\", token, .{});\n    return c.redirect(\"/\");\n}\n```\n\n### Clerk OAuth\n\n```zig\nconst clerk = try spider.clerk.Clerk.init(c.arena, c._io, .{\n    .publishable_key = spider.env.getOr(\"CLERK_PUBLISHABLE_KEY\", \"\"),\n    .secret_key = spider.env.getOr(\"CLERK_SECRET_KEY\", \"\"),\n    .redirect_uri = \"http://localhost:3000/auth/callback\",\n});\ndefer clerk.deinit();\n\nserver\n    .get(\"/login\", userLoginHandler)\n    .get(\"/auth/callback\", clerk.callbackHandler())\n    .group(\"/dashboard\", \u0026.{clerk.middleware()}, dashboardRoutes);\n\nfn userLoginHandler(c: *spider.Ctx) !spider.Response {\n    const url = try clerk.authUrl(c.arena);\n    return c.redirect(url);\n}\n```\n\n### Keycloak OAuth (with Refresh Token)\n\n```zig\nconst kc = try spider.keycloak.Keycloak.init(c.arena, c._io, .{\n    .base_url = spider.env.getOr(\"KEYCLOAK_URL\", \"http://localhost:8080\"),\n    .realm = spider.env.getOr(\"KEYCLOAK_REALM\", \"myapp\"),\n    .client_id = spider.env.getOr(\"KEYCLOAK_CLIENT_ID\", \"\"),\n    .client_secret = spider.env.getOr(\"KEYCLOAK_CLIENT_SECRET\", \"\"),\n    .redirect_uri = \"http://localhost:3000/auth/callback\",\n});\ndefer kc.deinit();\n\nserver\n    .get(\"/auth/login\", kc.loginHandler())\n    .get(\"/auth/callback\", kc.callbackHandler())\n    .get(\"/auth/refresh\", kc.refreshHandler()) // auto-refresh expired tokens\n    .group(\"/dashboard\", \u0026.{kc.middleware()}, dashboardRoutes);\n```\n\n### JWKS-based Auth (Generic)\n\nFor any provider that exposes JWKS endpoints (Auth0, Firebase, etc.):\n\n```zig\nconst jwks = try spider.jwks.JwksAuth.init(c.arena, c._io, .{\n    .jwks_url = \"https://example.com/.well-known/jwks.json\",\n    .issuer = \"https://example.com/\",\n    .cookie_name = \"__session\",\n    .login_path = \"/login\",\n    .refresh_path = \"/auth/refresh\",\n});\ndefer jwks.deinit();\n\nserver\n    .group(\"/api\", \u0026.{jwks.middleware()}, apiRoutes);\n```\n\n---\n\n## WebSocket\n\nSpider's WebSocket support uses the `server.ws()` method for a clean handler interface:\n\n```zig\nfn chatHandler(w: *spider.Ws) !void {\n    // Join a channel\n    try w.join(\"room:general\");\n\n    while (try w.next()) |msg| {\n        switch (msg.type) {\n            .text =\u003e {\n                // Send to specific user\n                try w.send(\"Message received\");\n\n                // Broadcast to channel\n                w.broadcastTo(\"room:general\", msg.data);\n\n                // Broadcast to all connected clients\n                w.broadcast(msg.data);\n            },\n            .binary =\u003e {},\n        }\n    }\n}\n\nserver.ws(\"/ws/chat\", chatHandler);\n```\n\n### WebSocket API\n\n| Method | Description |\n|--------|-------------|\n| `w.next()` | Wait for next message (returns `?Message`) |\n| `w.send(text)` | Send text message to this connection |\n| `w.broadcast(text)` | Broadcast to all connections |\n| `w.broadcastTo(channel, text)` | Broadcast to a channel |\n| `w.broadcastFmt(fmt, args)` | Broadcast formatted text |\n| `w.broadcastToFmt(channel, fmt, args)` | Broadcast formatted to channel |\n| `w.join(channel)` | Join a channel |\n| `w.joinUser(user_id)` | Join user-specific channel (`user:{id}`) |\n\n### WebSocket with Interval (Heartbeat / Periodic Broadcast)\n\n```zig\nfn broadcastStats(hub: *spider.Hub) void {\n    hub.broadcast(\"heartbeat\");\n}\n\nserver.wsInterval(\"/ws/stats\", 5000, broadcastStats);\n```\n\nThis creates a WebSocket endpoint that automatically broadcasts the callback result every `N` milliseconds.\n\n### Direct Hub Access\n\nFrom any handler, access the WebSocket hub to broadcast externally:\n\n```zig\nfn someHandler(c: *spider.Ctx) !spider.Response {\n    const hub = c.wsHub();\n    hub.broadcast(\"Event from HTTP handler!\");\n    hub.broadcastToChannel(\"room:admin\", \"Admin notification\");\n    hub.notifyUser(42, \"private_msg\", .{ .text = \"Secret!\" });\n    return c.json(.{ .ok = true }, .{});\n}\n```\n\n---\n\n## Server-Sent Events (SSE)\n\n```zig\nfn sseHandler(sse: *spider.Sse) !void {\n    try sse.join(\"notifications\");\n\n    while (true) {\n        try sse.send(\"ping\", .{ .time = \"2024-01-01T00:00:00Z\" });\n        // Keep connection alive\n        sse.wait();\n    }\n}\n\nserver.sse(\"/events\", sseHandler);\n```\n\n### SSE API\n\n| Method | Description |\n|--------|-------------|\n| `s.send(event, data)` | Send an event (data is JSON-serialized) |\n| `s.join(channel)` | Join a channel |\n| `s.joinUser(user_id)` | Join user-specific channel |\n| `s.wait()` | Block until connection closes |\n| `s.param(key)` | Access URL parameters |\n\n### Hub Events (Structured Messages)\n\nThe Hub supports structured event/data messages for SSE:\n\n```zig\nconst hub = c.sseHub();\n\n// Emit to all SSE connections\nhub.emit(\"notification\", .{ .title = \"New message\", .body = \"Hello!\" });\n\n// Emit to a channel\nhub.emitTo(\"user:42\", \"private\", .{ .msg = \"Secret\" });\n\n// Notify a specific user\nhub.notifyUser(42, \"alert\", .{ .type = \"info\" });\n```\n\n---\n\n## Web Push Notifications\n\nSpider includes a full Web Push implementation (RFC 8291, RFC 8292) with VAPID.\n\n### Generate VAPID Keys\n\n```zig\nvar threaded = std.Io.Threaded.init_single_threaded;\nconst io = threaded.io();\nconst keys = spider.push.WebPush.generateKeys(io);\n// Store keys.private_key and keys.public_key\n```\n\nOr via CLI:\n\n```bash\nspider generate-vapid mailto:admin@example.com\n```\n\n### Send Push Notification\n\n```zig\nconst wp = spider.push.WebPush.init(.{\n    .subject = \"mailto:admin@example.com\",\n    .private_key = spider.env.getOr(\"VAPID_PRIVATE_KEY\", \"\"),\n    .public_key = spider.env.getOr(\"VAPID_PUBLIC_KEY\", \"\"),\n});\n\n// Or load from env\nconst wp = spider.push.WebPush.initFromEnv();\n\n// From a handler\ntry wp.send(c, .{\n    .endpoint = \"https://fcm.googleapis.com/...\",\n    .p256dh = \"...\",\n    .auth = \"...\",\n}, \"Hello Push!\", 3600);\n```\n\n**Requirements:** Uses `spider.http_client` (pacman) under the hood — no external dependencies.\n\n---\n\n## Cloudflare R2 Object Storage\n\nSpider provides a full R2 client with AWS Signature V4.\n\n```zig\nconst r2 = spider.r2.R2.init(.{\n    .account_id = spider.env.getOr(\"R2_ACCOUNT_ID\", \"\"),\n    .access_key = spider.env.getOr(\"R2_ACCESS_KEY\", \"\"),\n    .secret_key = spider.env.getOr(\"R2_SECRET_KEY\", \"\"),\n    .bucket = spider.env.getOr(\"R2_BUCKET\", \"\"),\n    .pub_url = spider.env.getOr(\"R2_PUBLIC_URL\", \"\"),\n});\n\n// Or load from env\nconst r2 = spider.r2.R2.initFromEnv();\n```\n\n### Operations\n\n```zig\n// Upload\ntry r2.put(c, \"folder/file.txt\", file_content, \"text/plain\");\n\n// Download\nconst data = try r2.get(c, \"folder/file.txt\");\n\n// Delete\ntry r2.delete(c, \"folder/file.txt\");\n\n// Check existence\nconst exists = try r2.head(c, \"folder/file.txt\");\n\n// Presigned URL for direct browser upload\nconst url = try r2.presignedPut(c.arena, \"uploads/file.pdf\", \"application/pdf\", 3600);\n\n// Public URL\nconst pub = try r2.publicUrl(c.arena, \"folder/file.txt\");\n```\n\n---\n\n## Multipart Uploads\n\nSpider supports `multipart/form-data` parsing for file uploads.\n\n### Parsing Uploaded Files\n\n```zig\nfn uploadHandler(c: *spider.Ctx) !spider.Response {\n    const mp = try c.parseMultipart();\n    defer mp.deinit();\n\n    // Access text fields\n    const description = mp.getValue(\"description\") orelse \"\";\n\n    // Access uploaded files\n    const files = mp.getFile(\"avatar\") orelse \u0026.{};\n    for (files) |file| {\n        std.log.info(\"upload: {s} ({d} bytes, {s})\", .{\n            file.filename, file.size, file.content_type,\n        });\n        // file.data contains the raw bytes\n    }\n\n    return c.json(.{ .uploaded = files.len }, .{});\n}\n```\n\n### Typed Form Parsing (auto-detects multipart vs url-encoded)\n\n```zig\nconst FormInput = struct {\n    name: []const u8,\n    email: []const u8,\n    age: i32,\n};\n\nfn formHandler(c: *spider.Ctx) !spider.Response {\n    const input = try c.parseForm(FormInput);\n    return c.json(.{ .name = input.name, .email = input.email }, .{});\n}\n```\n\n---\n\n## Dependency Injection (Decorators)\n\nSpider supports automatic dependency injection into handlers using `spider.app(decorations)`:\n\n```zig\nconst AppDeps = struct {\n    pool: *PgPool,\n    email: *EmailService,\n    config: AppConfig,\n};\n\nfn main() !void {\n    const deps = AppDeps{\n        .pool = \u0026pool,\n        .email = \u0026email_service,\n        .config = app_config,\n    };\n\n    var server = spider.app(deps);\n    defer server.deinit();\n\n    server\n        .get(\"/\", homeHandler)\n        .listen(.{ .port = 3000 }) catch {};\n}\n\n// Handler receives dependencies automatically — no manual wiring needed\nfn homeHandler(c: *spider.Ctx, pool: *PgPool, email: *EmailService) !spider.Response {\n    const users = try pool.query(...);\n    try email.sendWelcome(...);\n    return c.json(.{ .ok = true }, .{});\n}\n```\n\nUp 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.\n\n---\n\n## Static Files\n\nSpider automatically serves `./public/` at `/` — no configuration needed.\n\n```\npublic/\n├── css/\n│   └── app.css       → GET /css/app.css\n├── js/\n│   └── app.js        → GET /js/app.js\n└── logo.png          → GET /logo.png\n```\n\nPath traversal (`../../etc/passwd`) is blocked automatically.\n\n### Custom Static Directory\n\n```zig\n// Serve from a different directory\nserver.staticDir(\"./assets\");\n\n// Serve with a different prefix\nserver.staticAt(\"./uploads\", \"/media\");\n// /media/images/logo.png → ./uploads/images/logo.png\n```\n\n---\n\n## Live Reload\n\nSpider auto-injects WebSocket live reload in development mode:\n\n```zig\n// spider.config.zig\npub const config = spider.Config{\n    .env = .development, // enables live reload\n};\n```\n\nWhen you save a template or static file, the browser refreshes automatically. No configuration needed — just run `zig build run` in dev mode.\n\n---\n\n## Health Endpoints\n\nWhen using `spider.app()` or `spider.appWithConfig()`, two health endpoints are registered automatically:\n\n| Endpoint | Description |\n|----------|-------------|\n| `GET /up` | Simple health check — returns `\"OK\"` |\n| `GET /_spider/health` | JSON with status and uptime in seconds |\n\nIn development mode, a live-reload WebSocket is also registered at `/_spider/reload`.\n\n---\n\n## Metrics\n\nSpider provides global request metrics:\n\n```zig\nconst snapshot = spider.metrics.snapshot(io);\nstd.log.info(\"requests: {d}, errors: {d}\", .{\n    snapshot.total_requests,\n    snapshot.errors,\n});\n```\n\nMetrics tracked: total requests, errors, bytes in/out, slow requests, WebSocket clients.\n\n---\n\n## Environment Configuration\n\nSpider automatically loads `.env` files on startup with priority order:\n\n1. `.env` — base configuration\n2. `.env.development` or `.env.production` — environment-specific\n3. `.env.local` — local overrides (highest priority)\n\n```bash\n# .env\nDATABASE_URL=postgres://localhost/myapp\nJWT_SECRET=my-secret-key\nPORT=3000\nDEBUG=true\nGOOGLE_CLIENT_ID=your-client-id\n```\n\n```zig\n// Access anywhere in your app\nconst host = spider.env.getOr(\"DB_HOST\", \"localhost\");\nconst port = spider.env.getInt(u16, \"PORT\", 3000);\nconst debug = spider.env.getBool(\"DEBUG\", false);\nconst secret = spider.env.get(\"JWT_SECRET\"); // returns ?[]const u8\n```\n\n---\n\n## Configuration\n\nCreate `spider.config.zig` in your project root:\n\n```zig\n// spider.config.zig\nconst spider = @import(\"spider\");\n\npub const config = spider.Config{\n    .port = 3000,\n    .host = \"127.0.0.1\",\n    .views_dir = \"./src\",\n    .layout = \"layout\",\n    .static_dir = \"./public\",\n    .env = .development,\n    .workers = null, // defaults to CPU count\n};\n```\n\nOr configure inline via `spider.appWithConfig()`:\n\n```zig\nvar server = spider.appWithConfig(spider.Config{\n    .port = 8080,\n    .env = .production,\n});\n```\n\n---\n\n## CLI Tool\n\nSpider ships with a `spider` CLI for project scaffolding:\n\n```bash\n# Create a new project\nspider new myapp\nspider new myapp --daisyui                       # With DaisyUI preset\nspider new myapp --skip-downloads                # Skip binary downloads (tailwindcss, alpine, htmx)\n\n# Generate code\nspider generate feature \u003cname\u003e                   # Full CRUD feature\nspider generate auth --provider=keycloak         # Auth with Keycloak\nspider generate auth --provider=google           # Auth with Google\n\n# Generate VAPID keys for Web Push\nspider generate-vapid mailto:admin@example.com\n\n# Run migrations\nspider migrate\n\n# Show version\nspider version\n# spider v0.6.2\n```\n\n---\n\n## HTTP Client\n\nSpider bundles a full HTTP client (`pacman`) accessible via:\n\n```zig\nconst http = spider.http_client;\n\nvar res = try http.get(io, arena, \"https://api.example.com/users\", .{});\ndefer res.deinit();\n\n// Parse JSON response\nconst data = try res.json(ResponseType);\ndefer data.deinit();\n\n// POST with JSON body\nvar res = try http.post(io, arena, \"https://api.example.com/users\", .{\n    .body = .{ .json = .{ .name = \"Alice\" } },\n});\n\n// POST with form data\nvar res = try http.post(io, arena, \"https://api.example.com/token\", .{\n    .body = .{ .form = \u0026.{\n        .{ \"grant_type\", \"authorization_code\" },\n        .{ \"code\", code },\n    } },\n});\n```\n\n---\n\n## Project Structure\n\n```\nsrc/\n├── spider.zig              — Public API (all exports)\n├── core/\n│   ├── app.zig             — Server, routing, workers, DI, WebSocket/SSE handlers\n│   ├── context.zig         — Ctx, Response, ResponseOptions, CookieOptions\n│   └── database.zig        — Database vtable interface\n├── routing/\n│   ├── router.zig          — Trie router (static + dynamic routes)\n│   └── group.zig           — Route groups\n├── modules/\n│   ├── auth/auth.zig       — JWT sign/verify, cookie helpers, Auth middleware\n│   ├── static.zig          — Static file serving\n│   ├── dashboard.zig       — Built-in metrics dashboard\n│   ├── livereload.zig      — Live reload (dev mode)\n│   ├── health.zig          — /up and /_spider/health endpoints\n│   ├── push.zig            — Web Push (RFC 8291/8292)\n│   ├── r2.zig              — Cloudflare R2 (AWS SigV4)\n│   └── logger.zig          — Colorized request logger middleware\n├── drivers/\n│   ├── pg/pg.zig           — PostgreSQL driver (pure Zig, pool-based)\n│   ├── sqlite/sqlite.zig   — SQLite driver (via libsqlite3 C binding)\n│   └── mysql/              — MySQL driver (pure Zig wire protocol)\n├── render/\n│   ├── template.zig        — Template engine entry point\n│   ├── views.zig           — Template resolver (embed + runtime)\n│   ├── ast.zig             — AST node types\n│   ├── parser.zig          — Template parser\n│   ├── renderer.zig        — Template renderer\n│   ├── context.zig         — Template rendering context\n│   └── zmd/                — Markdown to HTML renderer\n├── internal/\n│   ├── config.zig          — spider.Config\n│   ├── env.zig             — .env loader\n│   ├── logger.zig          — Structured logging\n│   ├── metrics.zig         — Request/error metrics\n│   └── buffer_pool.zig     — Buffer pooling\n├── ws/\n│   ├── websocket.zig       — WebSocket protocol (RFC 6455)\n│   ├── hub.zig             — Broadcast hub (WebSocket + SSE)\n│   ├── ws.zig              — Ws handler interface (next, send, broadcast, join)\n│   └── sse.zig             — SSE handler interface (send, join, wait)\n├── binding/\n│   ├── form.zig            — URL-encoded form parsing\n│   ├── form_parser.zig     — Typed form binding (struct mapping)\n│   └── multipart.zig       — Multipart/form-data parsing\n├── providers/\n│   ├── google.zig          — Google OAuth\n│   ├── clerk.zig           — Clerk OAuth + JWKS middleware\n│   ├── jwks.zig            — JWKS key fetching + JWT verification\n│   └── keycloak.zig        — Keycloak OAuth + refresh token\n├── cli/\n│   ├── main.zig            — CLI entry point\n│   ├── new.zig             — `spider new` project scaffolding\n│   ├── generate.zig        — `spider generate` code generation\n│   ├── migrate.zig         — `spider migrate` runner\n│   ├── generate_vapid.zig  — VAPID key generation\n│   └── templates/          — Scaffolding templates\n├── features/               — Built-in features (scaffolded code)\n├── build_helpers.zig       — spider_build.setup() helper\n└── generate_templates.zig  — embedded_templates.zig generator\n```\n\n---\n\n## API Reference\n\n### `spider.Ctx` Methods\n\n| Method | Description |\n|--------|-------------|\n| `c.json(data, opts)` | JSON response |\n| `c.text(content, opts)` | Plain text response |\n| `c.html(content, opts)` | HTML response |\n| `c.view(name, data, opts)` | Render template by name |\n| `c.render(tmpl, data, opts)` | Render template string directly |\n| `c.redirect(url)` | HTTP redirect (302) |\n| `c.param(name)` | URL parameter |\n| `c.query(name)` | Query string parameter |\n| `c.header(name)` | Request header |\n| `c.cookie(name)` | Request cookie |\n| `c.getBody()` | Raw request body |\n| `c.bodyJson(T)` | Parse JSON body into struct |\n| `c.parseForm(T)` | Parse form body (auto-detects url-encoded + multipart) |\n| `c.parseMultipart()` | Parse multipart/form-data (returns MultipartData) |\n| `c.setCookie(name, value, opts)` | Build Set-Cookie string |\n| `c.withCookie(name, value, opts)` | Build ResponseOptions with cookie |\n| `c.isHtmx()` | True if HX-Request header present |\n| `c.isBoosted()` | True if HX-Boosted header present |\n| `c.db()` | DatabaseCtx for driver-agnostic queries |\n| `c.wsHub()` | WebSocket Hub (must be in ws route) |\n| `c.sseHub()` | SSE Hub (must be in sse route) |\n| `c.getPath()` | Request path |\n| `c.getMethod()` | Request method string |\n| `c.arena` | Per-request arena allocator |\n\n### `spider.ResponseOptions`\n\n```zig\npub const ResponseOptions = struct {\n    status: std.http.Status = .ok,\n    headers: []const [2][]const u8 = \u0026.{},\n    cookies: []const [2][]const u8 = \u0026.{},\n};\n```\n\n### `spider.CookieOptions`\n\n```zig\npub const CookieOptions = struct {\n    value: []const u8 = \"\",\n    http_only: bool = true,\n    secure: bool = true,\n    same_site: []const u8 = \"Lax\",\n    path: []const u8 = \"/\",\n    max_age: ?u32 = null,\n};\n```\n\n### `spider.Server` Methods\n\n| Method | Description |\n|--------|-------------|\n| `server.get(path, handler)` | Register GET route |\n| `server.post(path, handler)` | Register POST route |\n| `server.put(path, handler)` | Register PUT route |\n| `server.delete(path, handler)` | Register DELETE route |\n| `server.patch(path, handler)` | Register PATCH route |\n| `server.head(path, handler)` | Register HEAD route |\n| `server.ws(path, handler)` | Register WebSocket route |\n| `server.wsInterval(path, ms, callback)` | WebSocket with periodic broadcast |\n| `server.sse(path, handler)` | Register SSE route |\n| `server.use(middleware)` | Global middleware |\n| `server.useAt(path, middleware)` | Path-scoped middleware |\n| `server.group(prefix, mws, fn)` | Route group with middleware |\n| `server.onError(handler)` | Global error handler |\n| `server.addRoute(method, path, mws, handler)` | Route with middleware |\n| `server.db(database)` | Register database driver |\n| `server.staticDir(dir)` | Set static files directory |\n| `server.staticAt(dir, prefix)` | Static dir with custom prefix |\n| `server.health(path, handler)` | Alias for server.get |\n| `server.listen(options)` | Start server |\n\n### `spider.pg` Methods (aliased as `const db = spider.pg`)\n\n| Method | Description |\n|--------|-------------|\n| `db.init(io, config)` | Initialize pool (DbConfig with optional overrides) |\n| `db.deinit()` | Shutdown pool |\n| `db.query(T, arena, sql, params)` | Parameterized query → `[]T`, `i32`, or `void` |\n| `db.queryOne(T, arena, sql, params)` | Parameterized query → `?T` (single row) |\n| `db.queryExecute(T, arena, sql)` | Raw SQL without params |\n| `db.queryOneExecute(T, arena, sql)` | Raw SQL single row |\n| `db.array(T, values)` | Create array param for `ANY($1)` |\n| `db.begin()` | Start transaction → `Transaction` |\n| `tx.query(T, arena, sql, params)` | Query inside transaction |\n| `tx.queryOne(T, arena, sql, params)` | Single row inside transaction |\n| `tx.commit()` | Commit transaction |\n| `tx.rollback()` | Rollback transaction |\n\n### `spider.Ws` Methods\n\n| Method | Description |\n|--------|-------------|\n| `w.next()` | Wait for next message (`?Message`) |\n| `w.send(text)` | Send text to this connection |\n| `w.broadcast(text)` | Broadcast to all connections |\n| `w.broadcastTo(channel, text)` | Broadcast to channel |\n| `w.broadcastFmt(fmt, args)` | Broadcast formatted text |\n| `w.broadcastToFmt(channel, fmt, args)` | Broadcast formatted to channel |\n| `w.join(channel)` | Join a channel |\n| `w.joinUser(user_id)` | Join user channel (`user:{id}`) |\n\n### `spider.Sse` Methods\n\n| Method | Description |\n|--------|-------------|\n| `s.send(event, data)` | Send an event (JSON data) |\n| `s.join(channel)` | Join a channel |\n| `s.joinUser(user_id)` | Join user channel |\n| `s.wait()` | Block until connection closes |\n\n### `spider.Hub` Methods\n\n| Method | Description |\n|--------|-------------|\n| `hub.broadcast(msg)` | Broadcast to all WS + SSE connections |\n| `hub.broadcastToChannel(channel, msg)` | Broadcast to channel |\n| `hub.broadcastFmt(fmt, args)` | Broadcast formatted |\n| `hub.emit(event, data)` | Emit JSON event (SSE) |\n| `hub.emitTo(channel, event, data)` | Emit JSON event to channel |\n| `hub.notifyUser(user_id, event, data)` | Notify user `user:{id}` |\n\n---\n\n## Examples\n\n- 🚀 **[SpiderStack](examples/spiderstack/)** — ~~Full-featured starter kit with Google OAuth, PostgreSQL, HTMX, Tailwind, and DaisyUI~~ **Desatualizado — não recomendado no momento**\n- 📦 **[local_first](examples/local_first/)** — Local-first architecture example\n- 🏗️ **[embed_templates](examples/embed_templates/)** — Template embed mode example\n- 🔧 **[c_import_zig_017](examples/c_import_zig_017/)** — C imports with Zig 0.17\n- 🔄 **[hot_relead](examples/hot_relead/)** — Hot reload example\n\n---\n\n## Zig Version Policy\n\nSpider tracks Zig `master` — always.\n\nWe 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.\n\n| Version | Status |\n|---------|--------|\n| `0.17.0-dev` | ✅ current |\n| `0.16.0` | ✅ migrated before release |\n| `0.15.0` | ✅ migrated before release |\n\nIf you're on a stable Zig release and Spider doesn't compile, check the git history — the migration is usually already done.\n\n---\n\n## Author\n\nBuilt by **Seven** (erivan cerqueira) — follow the journey on\n[YouTube](https://www.youtube.com/@llllOllOOl) where Seven posts\nvideos about Zig and Spider development.\n\n💬 Discord: `llll0ll00ll`\n\n---\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2FllllOllOOll%2Fspider","html_url":"https://awesome.ecosyste.ms/projects/github.com%2FllllOllOOll%2Fspider","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2FllllOllOOll%2Fspider/lists"}