# Local development

> The two-terminal workflow: `pnpm dev-server` (router plus workers on wrangler) and `pnpm dev-client` (Vite on port 3000). Covers `.dev.vars` loading, generated dev wrangler files, dynamic gatekeeper service-binding discovery, the `--use-workers-ai-binding` and `--serve-frontend-assets` flags, and `VITE_BACKEND_HOST` port selection.

- Repository: cloudflare/cloudflare-os
- GitHub: https://github.com/cloudflare/cloudflare-os
- Human docs: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd
- Complete Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/llms-full.txt

## Source Files

- `run-dev-server.js`
- `scripts/dev-server-config.js`
- `scripts/dev-server-config.test.js`
- `wrangler.jsonc`
- `packages/router/src/index.ts`
- `docs/public-server.md`

---

---
title: "Local development"
description: "The two-terminal workflow: `pnpm dev-server` (router plus workers on wrangler) and `pnpm dev-client` (Vite on port 3000). Covers `.dev.vars` loading, generated dev wrangler files, dynamic gatekeeper service-binding discovery, the `--use-workers-ai-binding` and `--serve-frontend-assets` flags, and `VITE_BACKEND_HOST` port selection."
---

Local development runs two processes. `pnpm dev-server` executes `run-dev-server.js`, which loads a root `.dev.vars`, generates the format-blueprint module, discovers gatekeeper packages under `packages/`, writes dev-only `wrangler.dev.jsonc` files with the resulting service bindings, and launches `wrangler dev` with all discovered workers. `pnpm dev-client` runs the Vite dev server on port 3000, which is where you open the frontend — the dev router deliberately does not proxy to Vite, because Vite's HMR socket disconnects every time wrangler restarts workerd.

## The two terminals

<Steps>
<Step title="Terminal 1 — router plus workers on wrangler">

```bash
pnpm run dev-server
```

Generates the dev wrangler configs, then starts a single multi-config `wrangler dev` process from the repo root covering the `dev-router` (`wrangler.jsonc`, `main: packages/router/src/index.ts`), `workshop-backend`, and every discovered `gatekeeper-*` package.

</Step>
<Step title="Terminal 2 — Vite frontend">

```bash
pnpm dev-client
```

Serves the frontend on `localhost:3000`. Open that port directly; do not expect the wrangler origin to serve frontend routes in normal dev mode.

</Step>
</Steps>

<Warning>
In normal dev mode the backend has no assets binding, and the dev router has no `ASSETS` binding, so any non-API request that reaches wrangler falls through to `WORKSHOP_BACKEND` rather than to a frontend build. That fallback is only useful in `run-local` mode.
</Warning>

## Dev routing model

The router package doubles as the dev router. Routing config is the binding set: gatekeepers are discovered at request time by scanning `GATEKEEPER_*` keys on `env`, lowercasing the suffix and replacing `_` with `-`, so `GATEKEEPER_GITHUB` serves `/gatekeeper/github` and everything below it.

```mermaid
flowchart TB
  subgraph browser["Browser"]
    vite["localhost:3000 (Vite dev server)"]
    wrang["wrangler dev origin"]
  end

  subgraph devwrangler["wrangler dev (multi-config, repo root)"]
    router["dev-router<br/>packages/router/src/index.ts"]
    backend["workshop-backend"]
    gk["gatekeeper-* workers<br/>(GATEKEEPER_GITHUB, GATEKEEPER_GOOGLE,<br/>GATEKEEPER_CONTEXT, GATEKEEPER_EMAIL, …)"]
  end

  subgraph generated["Generated dev config (gitignored outputs)"]
    rootcfg["wrangler.dev.jsonc (root)"]
    gkcfg["packages/gatekeeper-*/wrangler.dev.jsonc"]
    fmt["workshop-backend format-blueprints module"]
  end

  vite -->|"VITE_BACKEND_HOST"| router
  wrang --> router
  router -->|"/gatekeeper/&lt;name&gt;/*"| gk
  router -->|"/api, /api/*, /blueprint-screenshot*"| backend
  router -->|"no ASSETS binding in dev: fallback"| backend
  rootcfg -.-> router
  gkcfg -.-> gk
  fmt -.-> backend
```

| Request | Dev destination |
| --- | --- |
| `/gatekeeper/<suffix>` and `/gatekeeper/<suffix>/*` | The matching `GATEKEEPER_*` service binding |
| `/api`, `/api/*` | `WORKSHOP_BACKEND` |
| `/blueprint-screenshot`, `/blueprint-screenshot/*` | `WORKSHOP_BACKEND` |
| Everything else | `env.ASSETS` when present (production); otherwise `WORKSHOP_BACKEND` |
| Inbound email | `GATEKEEPER_EMAIL.email(message)`, or `message.setReject(...)` when unbound |

<Note>
Gatekeeper OAuth redirects land on the gatekeeper Workers themselves at `/gatekeeper/<name>/oauth`, handled by the discovery loop above. There are no backend `/auth` callbacks.
</Note>

## `.dev.vars` loading

`run-dev-server.js` calls `loadDevVars()` before anything else. It reads a root `.dev.vars` file — gitignored, `KEY=VALUE` per line — and copies entries into `process.env`.

Parsing rules, as implemented:

- Lines are trimmed; empty lines and lines starting with `#` are skipped.
- The first `=` splits key from value; lines with no `=` are skipped.
- Key and value are trimmed, and a single matching pair of surrounding `"` or `'` is stripped from the value.
- An existing shell environment value wins: assignment happens only when `process.env[key] === undefined`.
- A missing `.dev.vars` is not an error — the loader returns immediately.

A minimal public-service example:

```ini title=".dev.vars"
ENABLE_CLOUDFLARE_LIMITS=true
PUBLIC_BASE_URL=http://localhost:8787
AUTH_GATEKEEPERS=cloudflare,google,github

# Each gatekeeper's OAuth app (client id/secret). In dev these seed the gatekeeper Workers:
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
CLOUDFLARE_OAUTH_CLIENT_ID=...
CLOUDFLARE_OAUTH_CLIENT_SECRET=...

# Platform AI Gateway used for the free tier:
CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google

# Required whenever CF_AI_GATEWAY is set (all inference goes over HTTPS with tokens):
CF_AI_GATEWAY_ACCOUNT_ID=...
CF_AI_GATEWAY_API_TOKEN=...

# To send Workers AI straight to its REST endpoint (no gateway, no cost logs):
CF_AI_GATEWAY_WAI_DIRECT=true
```

## Generated dev files and prebuild steps

Before wrangler starts, `run-dev-server.js` performs generation work whose outputs are gitignored and therefore absent on a clean checkout.

:::files
```
<repo root>
├── wrangler.jsonc                     # checked in: dev-router, WORKSHOP_BACKEND service binding
├── wrangler.dev.jsonc                 # generated: wrangler.jsonc + one service per gatekeeper
├── .dev.vars                          # gitignored, KEY=VALUE per line
├── run-dev-server.js
├── scripts/
│   ├── dev-server-config.js           # getWranglerPortFromBackendHost()
│   ├── dev-server-config.test.js
│   └── build-gatekeeper-configurator.mjs
└── packages/
    ├── router/src/index.ts            # dev router + production router
    ├── workshop-backend/
    │   └── scripts/build-format-blueprints.mjs
    └── gatekeeper-*/
        ├── wrangler.jsonc             # checked in
        ├── wrangler.dev.jsonc         # generated
        ├── build-app.mjs              # optional single-file app UI build
        └── src/
            ├── configurator/          # optional configurator UI source
            └── generated/             # configurator output, app.txt
```
:::

| Generation step | Trigger | Purpose |
| --- | --- | --- |
| `packages/workshop-backend/scripts/build-format-blueprints.mjs` | Always, before wrangler bundles the backend | Produces the format-blueprint module; gitignored, so it does not exist on a clean checkout |
| `scripts/build-gatekeeper-configurator.mjs <gkDir> --quiet` | When `<gkDir>/src/configurator` exists | Compiles the configurator UI into `src/generated` |
| `<gkDir>/build-app.mjs` | When that file exists | Writes the single-file Vite app bundle to `src/generated/app.txt` |
| Root `wrangler.dev.jsonc` | Always | Parses `wrangler.jsonc` with `jsonc-parser`, appends one `services` entry per gatekeeper, writes JSON and logs `generated: <path>` |
| Per-gatekeeper `wrangler.dev.jsonc` | Per discovered gatekeeper | Adds the explicit `cwd` a root-launched multi-config wrangler process needs, and injects shared OAuth credentials |

### Watchers

For each gatekeeper with a configurator or `build-app.mjs`, the script first runs a one-shot build, then spawns a persistent `--watch` child so UI edits show up on reload; `wrangler dev`'s `watch_dir: src` then re-bundles the worker. Watchers are killed on `exit`, and on `SIGINT` (exit code `130`) and `SIGTERM` (exit code `143`). An unexpected watcher exit logs `<label> exited unexpectedly (code=…, signal=…)`.

## Gatekeeper discovery and binding names

`findGatekeepers(packages/)` selects entries whose name starts with `gatekeeper-` **and** that contain a `wrangler.jsonc` file. Directory-read failures and per-entry `statSync` failures are swallowed, yielding an empty list or skipping the entry rather than throwing.

Binding names come from `bindingName(gk)`: uppercase the package name and replace `-` with `_`.

```text
packages/gatekeeper-github   ->  binding GATEKEEPER_GITHUB   service gatekeeper-github
packages/gatekeeper-context  ->  binding GATEKEEPER_CONTEXT  service gatekeeper-context

router request-time reverse mapping:
GATEKEEPER_GITHUB  ->  suffix "github"   ->  /gatekeeper/github/*
```

The Context Library (`packages/gatekeeper-context`) is discovered by `findGatekeepers` and bound like any other gatekeeper. Its `describe()` reports `autoProvisionsAccount`, so core auto-provisions one Context account per user; the only extra wiring it needs is a `sharingDomain` in its binding props.

### Shared OAuth credential seeding

`SHARED_GATEKEEPER_CREDS` maps a gatekeeper package name to the shell/`.dev.vars` variables that seed its `CLIENT_ID` / `CLIENT_SECRET`, so one OAuth app can drive both sign-in and capability connection. For example, `gatekeeper-github` reads `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`. Gatekeepers without shared creds keep their raw config, and credentials already defined in a gatekeeper's own config still win.

Register each OAuth app's redirect URI against `PUBLIC_BASE_URL`:

- GitHub: `${PUBLIC_BASE_URL}/gatekeeper/github/oauth`
- Google: `${PUBLIC_BASE_URL}/gatekeeper/google/oauth`
- Cloudflare: `${PUBLIC_BASE_URL}/gatekeeper/cloudflare/oauth`

## Flags

<ParamField body="--use-workers-ai-binding" type="flag">
Include the Workers AI binding in `workshop-backend`. Requires a Cloudflare login. Use it when running with `CF_AI_GATEWAY*` configured, so the `webFetch` tool's document-to-Markdown conversion still has a `WORKERS_AI` binding — inference itself no longer uses the binding and goes over HTTPS with the gateway tokens.
</ParamField>

<ParamField body="--serve-frontend-assets" type="flag">
Configure the backend to serve the pre-built frontend bundle as static assets, as used by `run-local` mode. Omitted in normal dev mode so the frontend is served by Vite on `:3000` and no `vite build` is required to start the dev server.
</ParamField>

Both flags are detected with `process.argv.includes(...)`, so pass them through `pnpm` with `--`:

```bash
pnpm run dev-server -- --use-workers-ai-binding
pnpm run dev-server -- --serve-frontend-assets
```

<Info>
In `run-local` mode the backend's static `assets` binding uses `run_worker_first` for the API routes, so the router's dev fallback to `WORKSHOP_BACKEND` returns the pre-built single-page app for frontend requests.
</Info>

## `VITE_BACKEND_HOST` and wrangler port selection

`VITE_BACKEND_HOST` names the host (and optional port) the frontend talks to. `run-dev-server.js` feeds it through `getWranglerPortFromBackendHost()` from `scripts/dev-server-config.js`; when a port is present, that port is also passed to `wrangler dev` as `--port`, keeping both sides on the same number.

```bash
VITE_BACKEND_HOST=localhost:9000 pnpm run dev-server   # also runs wrangler dev --port 9000
```

Behavior of `getWranglerPortFromBackendHost(backendHost)`:

<ResponseField name="return" type="string | null">
The port as a string when the trimmed host contains one; `null` for an empty string or a host with no port. Throws on invalid input.
</ResponseField>

| Input | Result |
| --- | --- |
| `"localhost:9000"` | `"9000"` |
| `"[::1]:9001"` | `"9001"` |
| `"localhost"` | `null` |
| `""` (or whitespace only) | `null` |
| `"localhost:0"` | Throws `VITE_BACKEND_HOST must include a valid port between 1 and 65535.` |
| `"localhost:99999"` | Throws `VITE_BACKEND_HOST must include a valid port …` |
| `"[::1]:99999"` | Throws `VITE_BACKEND_HOST must include a valid port …` |
| `"http://localhost:9000"` | Throws `VITE_BACKEND_HOST must include a valid host with an optional port.` |

Implementation notes: a value containing `://` is rejected before parsing; otherwise the host is parsed as `new URL("http://" + trimmed)`. A parse failure that matches a `host:port`-shaped pattern reports the port error, everything else reports the host error. A successfully parsed port below `1` also throws the port error.

## Troubleshooting

<AccordionGroup>
<Accordion title="Frontend requests hit the backend instead of the app">
In normal dev mode neither the dev router nor the backend has assets configured, so the router's final fallback sends the request to `WORKSHOP_BACKEND`. Run `pnpm dev-client` and open `localhost:3000` directly, or start the server with `--serve-frontend-assets` for the `run-local` posture.
</Accordion>

<Accordion title="A module generated at build time is missing on a clean checkout">
The format-blueprint module and each gatekeeper's `src/generated` output are gitignored. `pnpm run dev-server` regenerates them on startup — run it (or the individual generator scripts) before bundling.
</Accordion>

<Accordion title="A gatekeeper never gets a service binding">
`findGatekeepers` requires both the `gatekeeper-` name prefix and a `wrangler.jsonc` file in the package directory. Without `wrangler.jsonc` the package is silently skipped, so no `GATEKEEPER_*` binding is generated and `/gatekeeper/<name>/*` falls through to the backend.
</Accordion>

<Accordion title="Values in .dev.vars appear to be ignored">
Shell environment values take precedence: `loadDevVars()` only sets a key when `process.env[key]` is `undefined`. Unset the shell variable, or change it there instead.
</Accordion>

<Accordion title="Gateway mode fails on document conversion">
When `CF_AI_GATEWAY*` is set locally, start with `pnpm run dev-server -- --use-workers-ai-binding` so the `webFetch` tool still has a `WORKERS_AI` binding. Gateway mode itself always requires `CF_AI_GATEWAY_ACCOUNT_ID` and `CF_AI_GATEWAY_API_TOKEN`, with AI Gateway Run and Read permissions.
</Accordion>

<Accordion title="Vite HMR keeps disconnecting">
Expected if you try to reach the frontend through the wrangler origin — the dev router intentionally does not forward to `localhost:3000`, because HMR's socket drops on every wrangler restart of workerd. Use port 3000 directly.
</Accordion>
</AccordionGroup>

## Verification

```bash
# Port-selection helper unit tests
node --test scripts/dev-server-config.test.js
```

Startup signals to look for in terminal 1: the format-blueprint generator output, one `generated: <path>` line for the root `wrangler.dev.jsonc`, and per-gatekeeper watcher output for packages that build a configurator or app UI.

## Related pages

<CardGroup>
<Card title="Installation" href="/installation">Prerequisites, the pinned Node version, and what `pnpm run-local` builds.</Card>
<Card title="Quickstart" href="/quickstart">Run the whole stack on workerd and reach http://localhost:8787.</Card>
<Card title="Routing and worker bindings" href="/routing-and-bindings">How the router resolves `/api/*`, `/gatekeeper/<name>/*`, assets, and inbound email.</Card>
<Card title="Environment variables" href="/environment-variables">Every backend variable and its default, including the `CF_AI_GATEWAY*` family.</Card>
<Card title="Configure gatekeeper credentials" href="/configure-gatekeeper-credentials">OAuth redirect-URI contract and dev seeding from shell variables.</Card>
<Card title="Configure sign-in and AI Gateway billing" href="/configure-signin-and-billing">`AUTH_GATEKEEPERS`, `DISABLE_PASSWORD_AUTH`, and `ENABLE_CLOUDFLARE_LIMITS`.</Card>
<Card title="Build, lint, and test" href="/build-lint-test">Generator prerequisites and the commands CI enforces.</Card>
<Card title="Troubleshooting" href="/troubleshooting">Missing generated modules, gateway-mode binding errors, and other failure modes.</Card>
</CardGroup>
