# Goribu — AGENTS.md

This project uses **Goribu**, a full-stack React framework for Cloudflare Workers. It is not Next.js, Remix, or Express. Conventions below are framework-specific. Read this file before writing or editing route, handler, middleware, document, or config code.

## Mental model

A Goribu app is one Worker that serves SSR React pages and the JSON the SPA client needs to update them. Routing is file-based under `src/routes/`. Each route file co-locates its page component (default export) with its handlers (named exports `GET`, `POST`, `PUT`, `PATCH`, `DELETE`). Handlers receive `(req, res)` and must return a `Response`. There is no `pages/`/`api/` split.

`res.render(Component, props)` is the workhorse. It streams HTML server-side on first visit and returns JSON for in-app SPA navigations and `<Form>` submissions, transparently. Pages are pure functions of their props. POST handlers call `res.render` when the outcome should navigate or re-render the page (e.g. validation errors via a full re-render); for in-place updates that keep you on the page, return `res.json` and react with `<Form>`'s `onSuccess`/`onError`. `res.render` is method-agnostic.

Routes whose only handler is a GET that doesn't touch dynamic primitives (`req.d1`, `req.postgres`, `req.cache`, `req.env`) are prerendered to static HTML at build time and served directly from the CDN. The Worker never runs for those.

## Project structure

```
src/
  routes/                      All routes. Path on disk = URL.
    index.jsx                  /
    polls/
      _middleware.js           runs for /polls/* (optional)
      index.jsx                /polls
      create.jsx               /polls/create
      [id].jsx                 /polls/:id           dynamic segment
    _middleware.js             runs for every route (optional)
    _404.jsx                   route 404 (optional, has minimal default)
    _500.jsx                   uncaught error page (optional, has minimal default)
  data/                        Plain JS data layer called from handlers.
  document.jsx                 HTML shell. Receives { children }.
  server.jsx                   Worker entry. Calls createApp({ routes, ... }).
migrations/                    Database migrations (D1 or Postgres).
goribu.config.js               Route + database (single source of truth).
wrangler.jsonc                 Cloudflare runtime config (non-database bindings, compat).
vite.config.js                 Build pipeline.
.env / .env.production         Env vars and secrets.
```

`@/` is aliased to `src/`. The alias must be declared in BOTH `vite.config.js` and `jsconfig.json`/`tsconfig.json`. With only one, build works but autocomplete breaks (or vice versa).

## Route file shape

Default export is the page. Named exports are handlers. Method names must be uppercase.

```jsx
import { Form, Link } from "goribu";
import { getPoll, recordVote } from "@/data/polls.js";

export default function PollPage({ poll, error }) {
  return (
    <>
      <title>{poll.title}</title>
      <main>
        <h1>{poll.title}</h1>
        {error && <p className="text-red-700">{error}</p>}
        <Form method="post">
          {poll.options.map((o) => (
            <button key={o.id} name="optionId" value={o.id}>{o.text}</button>
          ))}
        </Form>
      </main>
    </>
  );
}

export async function GET(req, res) {
  const poll = await getPoll(req.d1, req.params.id);
  if (!poll) return res.notFound();
  return res.render(PollPage, { poll });
}

export async function POST(req, res) {
  const { optionId } = await req.body();
  if (!optionId) {
    const poll = await getPoll(req.d1, req.params.id);
    return res.status(400).render(PollPage, { poll, error: "Pick an option." });
  }
  await recordVote(req.d1, req.params.id, optionId);
  return res.redirect(`/polls/${req.params.id}`);
}
```

Dynamic segments use `[name].jsx` (file or folder). Values appear on `req.params.name` as URL-decoded strings.

A route file without a default export is just an HTTP endpoint. Same handler shape, no page rendered. Useful for JSON endpoints under `src/routes/api/` if you like that grouping.

`<title>`, `<meta>`, and `<link>` placed anywhere in JSX hoist to `<head>` automatically (React 19). Goribu deduplicates after SSR so a route's tags override the Document's. There is no `<Head>` or `<Helmet>`.

## req

- `req.params`: dynamic-segment values as `{ [key: string]: string }`. URL-decoded.
- `req.query()`: friendly query parser. Returns a plain, destructurable object — single params become strings, repeated params become arrays, missing params are `undefined`. `?tag=a&tag=b&page=2` → `{ tag: ['a','b'], page: '2' }`. Never throws. Mirrors `req.body()`. Pass a type: `req.query<T>()`.
- `req.searchParams`: query string as a native `URLSearchParams` — the exact escape hatch. `req.searchParams.get('name')` for a single value, `req.searchParams.getAll('tag')` for repeated keys, `req.searchParams.has('draft')` for presence.
- `req.body()`: friendly body parser — `await` it. Always resolves to a safe, destructurable value:
  - `application/json`: the parsed object (top-level arrays/primitives/`null` → `{}`; read those with `req.json()`).
  - `application/x-www-form-urlencoded`: a plain object; repeated keys become arrays (`option=a&option=b` → `{ option: ['a','b'] }`).
  - empty, GET/HEAD/OPTIONS, multipart, or unsupported Content-Type: `{}`.
  - Never throws on body shape — malformed JSON resolves to `{}`. A `Content-Length` over 10 MiB → 413 before the body is buffered.
- `req.json()`: strict JSON parser. Throws `415` for a non-JSON Content-Type, `400` for malformed JSON. Empty body → `{}`.
- `req.form()`: precise form parser. `URLSearchParams` for urlencoded, `FormData` for multipart (files + repeated values). Throws `415` for non-form content types. Use when repeated keys, ordering, or uploads matter.
- `req.env`: the raw Cloudflare `env` object. All bindings (D1, KV, R2, queues, vars, secrets) live here.
- `req.d1`: D1 helper (when `DB` binding is configured). See Database.
- `req.postgres`: `postgres` package's tagged-template `sql` (when Hyperdrive `POSTGRES` binding is configured). See Database.
- `req.cache`: Cache API helper (NOT KV). See Cache.
- `req.url`, `req.method`, `req.headers`: standard Fetch values.
- `req.text()`, `req.arrayBuffer()`: raw body as text / bytes. For webhooks, signature verification, binary payloads. All body reads are lazy and cached, so mixing helpers is safe.
- `req.request`: the raw `Request` when a third-party library needs it.

## res

- `res.render(Component, props?)`: render a React page. Streams HTML on full-page requests, returns JSON on SPA navigations. Pass the component reference, not JSX. Method-agnostic.
- `res.notFound(props?)`: render the registered `_404` page with status 404. Use for resource 404s (hydrates as the real `_404` route).
- `res.vary(token)`: declare a request header the response varies on (e.g. `Cookie`). Chainable; accumulates across middleware.
- `res.json(data, status?)`: 200 by default, `application/json`.
- `res.text(body, status?)`: 200, `text/plain`.
- `res.html(body, status?)`: 200, `text/html`. For ad-hoc HTML strings. Use `res.render` for components.
- `res.redirect(url, status?)`: 302 by default.
- `res.status(code)`: chainable. Sets pending status for the next call.

```js
return res.notFound();
return res.status(201).render(PollPage, { poll });
return res.status(400).json({ error: "invalid" });
```

Handlers must return a `Response` (or `Promise<Response>`). Forgetting throws a clear framework error.

`HEAD` is auto-derived from `GET` (the GET handler runs, body stripped); `OPTIONS` is auto-answered with an `Allow` header. An unhandled method returns 405 with `Allow`. Export `HEAD`/`OPTIONS` only to override.

## Components from `goribu`

- `<Link href>`: SPA navigation. Renders as `<a>`. Intercepts same-tab same-origin clicks; native browser navigation for external, modified (Cmd/Ctrl/Shift), middle-click, or `target="_blank"`. Prefetches on hover/pointer-down by default. Pass `prefetch={false}` to disable. Pass `replace` to swap history instead of push.
- `<Form action? method? onSuccess? onError?>`: SPA-style form submissions. `action` defaults to the current path. If the handler calls `res.render`/`res.redirect` the page navigates or re-renders; if it returns `res.json`, the page stays put and `onSuccess`/`onError` fire by HTTP status. Progressive-enhancement-safe (works without JS).
- `<Stylesheet href>`: stylesheet link. Resolves to a hashed asset URL in production. Use in `document.jsx`.
- `<ClientEntry src>`: bootstraps React on the client. Goes in `document.jsx` inside `<body>`. Without it, pages render but stay static (no `<Link>` SPA, no `<Form>` enhancement).

All four pass standard DOM props through (`className`, `target`, `rel`, `aria-*`, `onClick`, `onSubmit`, etc.).

## Forms

```jsx
<Form method="post" action="/polls">
  {({ submitting }) => (
    <>
      <input name="title" />
      <button disabled={submitting}>{submitting ? "Saving…" : "Save"}</button>
    </>
  )}
</Form>
```

Children can be JSX or a function `({ submitting }) => JSX` for loading state.

`(await req.body()).fieldName` reads form values. Multi-value inputs come back as arrays; single inputs as strings. Normalize when a field can be either:

```js
const { option: raw } = await req.body();
const options = Array.isArray(raw) ? raw : raw ? [raw] : [];
```

For exact form semantics (`getAll`, ordering, or file uploads), reach for `req.form()`.

**Validation pattern**: render the same component with errors and submitted values:

```js
return res.status(400).render(SettingsPage, {
  user: await getUser(req),
  values: { name },
  errors: { name: "Name required" },
});
```

**Critical rule: every render call must pass the full set of props the component needs.** The page is a pure function of its props. Forgetting to pass `user` on the error path is the single most common mistake. The page throws on render. Use a small loader shared between handlers:

```js
async function load(req) { return { user: await getUser(req) }; }

export async function POST(req, res) {
  if (invalid) {
    return res.status(400).render(Page, { ...(await load(req)), values, errors });
  }
  // ...
}
```

**Multi-button forms**: the clicked button's `name`/`value` arrives in `await req.body()`. Useful for vote buttons, action menus, etc.

**GET forms** (`<Form method="get">`) encode fields into the URL and SPA-navigate. Read on the server with `req.searchParams.get(name)` or destructure `req.query()`.

**In-place forms** (handler returns `res.json`, not a render): the page stays put and the response is dispatched on HTTP status — `onSuccess(data, response)` on 2xx, `onError(data, response)` on 4xx/5xx (or a transport failure, where `response` is `undefined`). `data` is the parsed JSON body. The component holds the data in React state and applies the change itself. Navigation payloads and non-JSON responses fire neither callback.

**File uploads, external URLs, full submission control**: use a native `<form>`. `<Form>` rejects file inputs with an error. `<Form>` also bows out if your `onSubmit` calls `event.preventDefault()`.

## Database

D1 and Postgres (via Hyperdrive) are both first-class. Pick one per app, or use both.

### D1

Declared in `goribu.config.js` only. Do NOT add `d1_databases` to `wrangler.jsonc` — Goribu synthesizes the binding.

```js
export default {
  database: { type: "d1", name: "my-app-db" },
};
```

Goribu auto-creates the database on first deploy if it doesn't exist. The binding is always `DB`. Use `req.d1`. Never call `req.env.DB.prepare(...)` directly.

```js
const poll = await req.d1.get("SELECT * FROM polls WHERE id = ?", [id]);
const polls = await req.d1.all("SELECT * FROM polls ORDER BY id DESC");
const { lastRowId, changes } = await req.d1.run(
  "INSERT INTO polls (title) VALUES (?)", [title]
);
await req.d1.batch([
  { sql: "INSERT INTO polls (title) VALUES (?)", params: ["Q1"] },
  { sql: "INSERT INTO options (poll_id, text) VALUES (?, ?)", params: [1, "Yes"] },
]);
```

`req.d1.raw` is the underlying binding for query builders (e.g. Drizzle):

```js
import { drizzle } from "drizzle-orm/d1";
const db = drizzle(req.d1.raw);
```

Always parameterize. Never interpolate user input into SQL strings.

### Postgres

Declared in `goribu.config.js` only. Do NOT add `hyperdrive` to `wrangler.jsonc` — Goribu synthesizes the binding.

```js
export default {
  database: {
    type: "postgres",
    url: process.env.POSTGRES_URL,
    hyperdriveId: process.env.HYPERDRIVE_ID,
  },
};
```

`.env` (dev):
```
POSTGRES_URL=postgres://user:pass@localhost:5432/myapp
```

`.env.production` (prod):
```
POSTGRES_URL=postgres://migrator:pass@host:5432/db
HYPERDRIVE_ID=abc123...
```

Two values, two jobs. `HYPERDRIVE_ID` is the runtime binding the Worker uses through Hyperdrive's pool. `POSTGRES_URL` is the direct connection `goribu deploy` uses to run migrations before the Worker ships (DDL doesn't belong in a connection pool). They can point at the same database with different credentials — the migration user needs DDL permission, the runtime user usually doesn't. For a one-off deploy against a different database, override with `--postgres-url=...`.

The user creates the Hyperdrive config themselves in the Cloudflare dashboard and pastes its ID into `.env.production`. Goribu does NOT auto-provision Hyperdrive — don't suggest a command that does. The binding is always `POSTGRES`.

`req.postgres` is the `postgres` package's tagged-template `sql`. Always tagged templates, never plain strings:

```js
const polls = await req.postgres`SELECT * FROM polls WHERE id = ${id}`;

const [{ id }] = await req.postgres`
  INSERT INTO polls (title) VALUES (${title}) RETURNING id
`;

await req.postgres.begin(async (sql) => {
  const [{ id }] = await sql`INSERT INTO polls (title) VALUES (${title}) RETURNING id`;
  await sql`INSERT INTO options (poll_id, text) VALUES (${id}, 'Yes')`;
  await sql`INSERT INTO options (poll_id, text) VALUES (${id}, 'No')`;
});
```

Connection lifecycle is automatic. Never call `.end()` from a handler.

### Migrations

```bash
npx goribu migration:create create_polls_table
npx goribu migrate                   # local
npx goribu migrate:rollback          # local, reverses last migration
```

Files live in `migrations/` with timestamped names. Each exports `up(db)` and optionally `down(db)`. The argument shape matches `req.d1` for D1 apps and `req.postgres` for Postgres apps. Production migrations run automatically before each `goribu deploy`. If a migration fails, the Worker doesn't deploy.

## Cache

`req.cache` wraps the Cloudflare Cache API (NOT KV). Per-POP storage, URL-keyed, fast within a data center.

```js
let polls = await req.cache.get("/polls");
if (!polls) {
  polls = await getAllPolls(req.d1);
  await req.cache.set("/polls", polls, { ttl: 300 });
}
// invalidate after writes
await req.cache.delete("/polls");
await req.cache.delete("/polls", "/polls/featured", "/admin/polls");
```

Keys must start with `/`. Anything else logs a warning and is skipped.

User-specific data must include the user in the key:

```js
// wrong: leaks across users
await req.cache.get("/dashboard");
// right: per-user
await req.cache.get(`/dashboard/${userId}`);
```

`ttl` defaults to 60 seconds. Treat it as a safety net for missed invalidations, not the primary mechanism. Explicit `set`/`delete` in handlers does the real work.

This is per-POP, not globally replicated. For globally consistent caching, use KV via `req.env.MY_KV`.

`req.cache.raw` is direct access to `caches.default` for custom `Vary` or `Cache-Control` work outside the helper's surface.

## Middleware

Create `_middleware.{js,jsx,ts,tsx}` in any folder under `src/routes/`. It applies to that folder and below. Outer wraps inner. Two `_middleware.*` files in the same folder is a build error.

A middleware is `(req, res, next) => Promise<Response>`. `await next()` runs the rest of the chain (inner middleware, then the handler) and returns the resulting `Response`. Modify or replace it, then return it.

```js
// Pass through, attach state
export default async function attachUser(req, res, next) {
  req.user = await loadUserFromCookie(req);
  return next();
}

// Short-circuit
export default async function requireAuth(req, res, next) {
  if (!req.user) return res.redirect("/login");
  return next();
}

// Modify response on the way out
export default async function csp(req, res, next) {
  const response = await next();
  response.headers.set("Content-Security-Policy", "default-src 'self'");
  return response;
}
```

Multiple middleware in one file: default-export an array. Runs left-to-right.

```js
export default [logger, csp];
```

`res` is a builder. Setting headers on `res` and calling `next()` does NOT work. You must work with the `Response` returned by `await next()`.

Middleware does NOT run on `_404.jsx` or `_500.jsx`. Errors bubble through middleware as normal JS exceptions; handle with `try/catch` around `await next()` if needed.

Middleware runs at build time on prerendered routes with a synthetic empty `req`. Don't put auth, cookie, or geo branching in middleware on prerendered routes. The result is baked into the snapshot. Also keep prerendered route `GET` handlers independent of request bindings such as `req.d1`, `req.postgres`, `req.cache`, and `req.env`; if the build-time render touches those, Goribu warns and leaves that URL dynamic.

## Layouts

Goribu has no layout primitives. Shared navigation, footers, sidebars are plain React components imported into the routes that use them:

```jsx
import { AppLayout } from "@/components/AppLayout";

export default function Dashboard({ user }) {
  return <AppLayout><h1>Welcome, {user.name}</h1></AppLayout>;
}
```

Don't introduce framework-level layouts. React composition is the answer.

## Error pages

`src/routes/_404.jsx` for unmatched URLs. `src/routes/_500.jsx` for uncaught production errors. Both optional, both have minimal built-in fallbacks.

```jsx
// _404.jsx — receives { url }
export default function NotFound({ url }) {
  return <main><h1>Not found</h1><p>{url}</p></main>;
}

// _500.jsx — receives { url, errorId }
export default function ServerError({ url, errorId }) {
  return <main><h1>Server error</h1><p>Reference: {errorId}</p></main>;
}
```

`_500.jsx` does NOT receive the error object. Stack traces, SQL fragments, and secrets must not flow into rendered HTML. The error is logged server-side under `errorId`; correlate via `wrangler tail`.

**Route 404** (no matching file): the framework auto-renders `_404.jsx`.

**Resource 404** (route matched, record missing): call `res.notFound(props?)`. It renders the registered `_404.jsx` with a 404 status and hydrates as the real `_404` route. Setting `status(404)` on a normal `render` does NOT trigger `_404.jsx`:

```js
if (!poll) return res.notFound();
```

Both error pages must default-export a component. Don't export `GET`/`POST` handlers from them. Middleware does not run on either.

## Env vars and bindings

Server values live in `.env` (dev) and `.env.production` (prod). Access via `req.env.NAME`. Never `process.env`.

Client-exposed values must use the `PUBLIC_` prefix and are read in components via `import.meta.env.PUBLIC_NAME`. Unprefixed values stay server-only.

Database bindings (D1, Postgres) come from `goribu.config.js`. Other Cloudflare bindings (KV, R2, queues, service bindings, Durable Objects) come from `wrangler.jsonc`. Both appear on `req.env` automatically under their binding name.

`goribu deploy` uploads `.env.production` as Cloudflare secrets atomically with the new Worker.

## Imports

```js
import { Link, Form, Stylesheet, ClientEntry } from "goribu";
import type { Req, Res, Middleware, FormState } from "goribu";
```

`goribu/server` exposes `createApp`. ONLY `src/server.jsx` imports from it. Components, handlers, and middleware never import from `goribu/server`.

## TypeScript

Framework source is JavaScript by choice; the public API is fully typed via shipped `.d.ts`. Rename `.jsx` → `.tsx`, add a `tsconfig.json`, and augment `Env` to type bindings:

```ts
declare module "goribu" {
  interface Env {
    DB: D1Database;
    MY_KV: KVNamespace;
  }
}
```

`req.params` is `{ [key: string]: string }`; `req.query()` returns `Record<string, string | string[]>` (pass a type: `req.query<T>()`); `req.searchParams` is the native `URLSearchParams`. `req.body()` resolves to `Record<string, unknown>` (`req.body<T>()` to type it); `req.json<T>()` is the strict variant. Cast or validate at the handler boundary.

## Commands

- `npm run dev` — local dev (Vite + Wrangler, HMR).
- `npx goribu build` — produce `dist/`.
- `npx goribu deploy` — build, run pending migrations, ship.
- `npx goribu migration:create <name>` — scaffold a migration.
- `npx goribu migrate` — apply pending migrations locally.
- `npx goribu migrate:rollback` — reverse the last migration locally.

`vite.config.js` plugin order is fixed: `goribu()`, `react()`, `tailwindcss()`, `cloudflare()`. Don't reorder.

## Common mistakes to avoid

- Do not create `src/pages/` or `src/api/`. There is one `src/routes/` directory. (`src/routes/api/` as a subfolder is fine if you like the visual grouping.)
- Do not export handlers under names other than `GET`, `POST`, `PUT`, `PATCH`, `DELETE`. Method names must be uppercase.
- Do not use `next/link`, `react-router`, or `react-router-dom`. Use `Link` from `goribu`.
- Do not use raw `<form>` for in-app POST flows. Use `<Form>`. Native `<form>` is correct only for file uploads, external URLs, or full submission control.
- `req.body`/`req.json`/`req.form` are methods — call and await them: `await req.body()`, not `await req.body`. `req.body()` returns a plain object (repeated keys → arrays); for `getAll`/ordering/multipart use `req.form()`.
- Do not call `process.env`. Use `req.env` server-side and `import.meta.env.PUBLIC_*` client-side.
- Do not call `req.env.DB.prepare(...)`. Use `req.d1`. Same for `req.postgres` over `req.env.POSTGRES`.
- Do not interpolate user input into SQL. Always parameterize (D1: `?` + params array; Postgres: tagged templates).
- Do not call `res.render(<Component />)`. Pass the component reference: `res.render(Component, props)`.
- Do not forget props on the error path of a POST handler. Every render receives a complete fresh prop set; passing only `errors` and `values` and forgetting `user` will throw.
- Do not render a one-off component for resource 404s. Use `res.notFound(props?)` so the registered `_404.jsx` renders and hydrates. Setting `status(404)` on a normal `render` does not trigger `_404.jsx`.
- Do not introduce layout primitives. Use React composition.
- Do not put auth, cookie, geo branching, or request-binding reads in prerendered route render paths. Middleware runs once at build time with empty `req`; `GET` handlers that touch `req.d1`, `req.postgres`, `req.cache`, or `req.env` are left dynamic.
- Do not mutate `res` to set headers in middleware. Modify the `Response` returned by `await next()`.
- Do not change the order of plugins in `vite.config.js`.
- Do not import from `goribu/server` outside `src/server.jsx`.
- Do not invoke `wrangler` directly from app code. Use `goribu deploy`.
- Do not add `<Helmet>` or `<Head>`. Place `<title>`, `<meta>`, `<link>` directly in JSX; React 19 hoists them.
- Do not put file inputs in `<Form>`. It rejects them. Use a native `<form>`.
- Do not assume `req.cache` is globally replicated. It's per-POP. Use KV for global consistency.
- Do not add `d1_databases` or `hyperdrive` entries to `wrangler.jsonc`. Databases are declared in `goribu.config.js` (`database: { type, ... }`) and Goribu synthesizes the bindings. Declaring in both files is an error.
- Do not suggest auto-provisioning Hyperdrive. The user creates it in the Cloudflare dashboard and pastes its ID into `.env.production` as `HYPERDRIVE_ID`. D1 IS auto-created on first deploy; Hyperdrive is not.

## When you're stuck

Goribu is new. Fetch the docs at https://goribu.dev/docs rather than guessing from related frameworks. Key pages:

- `/docs/routing`, `/docs/request-and-response`, `/docs/rendering`
- `/docs/link`, `/docs/form`
- `/docs/d1`, `/docs/postgres`, `/docs/cache`
- `/docs/middleware`, `/docs/error-pages`
- `/docs/configuration`, `/docs/deployment`, `/docs/typescript`
