zebflow 0.10.3

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
# Webhook → TSX Page

## What this builds

A server-rendered HTML page triggered by HTTP GET. The query result flows directly into the TSX template as `input`. Standard pattern for every data-driven page in Zebflow.

---

## Core Pattern

```
trigger.webhook → (optional query node) → web.response --template pages/foo.tsx
```

The upstream node's entire output becomes `input` inside the TSX template. `input.rows` for pg.query results, `input.data` or whatever shape the script returns.

---

## Pipelines

### Simple page — static data via script

```
| trigger.webhook --path /hello --method GET
| script -- "return { message: 'Hello World', ts: Date.now() }"
| web.response --template pages/hello.tsx
```

### Page with PostgreSQL list

```
| trigger.webhook --path /programmes --method GET
| pg.query --credential my-pg \
    -- "SELECT unit_id::text, code, title->>'id' as title, slug FROM academic.academic_unit WHERE unit_type = 'programme' AND is_active = true ORDER BY code"
| web.response --template pages/programmes.tsx
```

In `pages/programmes.tsx` — `input.rows` is the array of DB rows:

```tsx
const rows = input?.rows ?? [];
return (
  <ul>
    {rows.map(p => <li key={p.unit_id}>{p.title} ({p.code})</li>)}
  </ul>
);
```

### Detail page with path param — `:unit_id` → `$1`, 404 when missing

A script cannot set the response status, so the not-found branch is a real
graph branch, not a flag on the same `web.response` that serves the found
case:

```
[find]  trigger.webhook --path /programmes/:unit_id --method GET
[query] pg.query --credential my-pg --params "{{ input.params.unit_id }}" -- "SELECT unit_id::text, code, title, description FROM academic.academic_unit WHERE unit_id = $1::uuid AND is_active = true"
[found] logic.if --expr "input.rows && input.rows.length > 0"
[ok]    web.response --template pages/programme-detail.tsx
[gone]  web.response --status 404 --template pages/not-found.tsx
[find] -> [query]
[query] -> [found]
[found]:true -> [ok]
[found]:false -> [gone]
```

`[ok]` receives `{ rows }` as `input` in `pages/programme-detail.tsx`:

```tsx
export default function Page(input) {
  const row = input?.rows?.[0];
  return (
    <Page>
      <main>
        <h1>{row?.title?.id}</h1>
        <p>{row?.code}</p>
      </main>
    </Page>
  );
}
```

### Page with query string filter — `?faculty_id=uuid`

```
| trigger.webhook --path /programmes --method GET
| pg.query --credential my-pg --params "{{ [input.query.faculty_id ?? null] }}" \
    -- "SELECT unit_id::text, code, title->>'id' as title FROM academic.academic_unit WHERE unit_type = 'programme' AND ($1::uuid IS NULL OR parent_unit_id = $1::uuid) ORDER BY code"
| web.response --template pages/programmes.tsx
```

---

## Nodes Used

- `trigger.webhook` — GET endpoint; path params in `input.params.<name>`, query string in `input.query.<name>`
- `pg.query --credential <id>` — fetch data; `--params "{{ input.params.unit_id }}"` binds `:unit_id` as `$1`
- `logic.if --expr "input.rows.length > 0"` — branch on `true`/`false` pins; the only way to answer 404 conditionally, since a script cannot set the status
- `script` — static payloads, data transform
- `web.response` — renders TSX template; upstream output = `input` in template; supports `--status`, `--set-cookie`, `--header`

---

## Templates Needed

Use `file_create kind=page name=<page-name>` then `file_write` to fill content.
Access upstream data via `input` (function parameter) — it's whatever the previous node returned.
`ctx` is the same object available as `globalThis.ctx` in both SSR and browser.