···11+# Dev server and SSR architecture
22+33+Airglow uses HonoX for file-based routing with islands architecture. This creates a split-runtime situation during development: the server-side code runs on Node (via Vite), while production runs on Bun.
44+55+## How HonoX dev mode works
66+77+```
88+Browser request
99+ ↓
1010+Vite dev server (Node)
1111+ ↓
1212+HonoX vite plugin (SSR middleware)
1313+ ↓
1414+app/server.ts → routes → lib/*
1515+ ↓
1616+HTML response (SSR) + client JS (HMR)
1717+```
1818+1919+`vp dev` (Vite+) starts a Vite dev server. HonoX registers a middleware via `configureServer` that intercepts all requests and runs them through the Hono app using Vite's SSR module runner. Route files in `app/routes/` are discovered and registered automatically.
2020+2121+The key constraint: **all server-side code executes in Node**, not Bun. This means:
2222+- `bun:sqlite` is not available (see [sqlite-bun-compat.md](./sqlite-bun-compat.md))
2323+- CJS packages must be loaded via `createRequire()` (see [oauth.md](./oauth.md))
2424+- `.env` must be loaded manually for Node
2525+2626+## HonoX's `noExternal: true`
2727+2828+HonoX's Vite plugin unconditionally sets:
2929+3030+```ts
3131+{ ssr: { noExternal: true } }
3232+```
3333+3434+This tells Vite to ESM-transform **all** `node_modules` during SSR instead of letting Node load them natively. This is required for HonoX's file-based routing to work (it needs to process route imports through Vite's module graph). But it breaks CJS packages that use `module.exports` or `require()`.
3535+3636+Workarounds:
3737+- **Vite aliases** redirect `bun:sqlite` and `better-sqlite3` to a `createRequire()` shim
3838+- **`createRequire()`** loads `@atproto/oauth-client-node` bypassing Vite's transform
3939+4040+## PDS proxy
4141+4242+The local PDS runs in Docker on `localhost:3000` but is configured with hostname `pds.dev`. The browser needs to interact with the PDS (OAuth authorize page, asset loading), but `pds.dev` is unreachable.
4343+4444+A custom Vite plugin (`pdsProxy` in `vite.config.ts`) solves this:
4545+4646+```
4747+Browser → http://127.0.0.1:5175/oauth/authorize
4848+ ↓ (Vite middleware, before HonoX)
4949+pdsProxy plugin
5050+ ↓ (http.request to localhost:3000)
5151+Local PDS
5252+ ↓ (response with pds.dev URLs rewritten to 127.0.0.1:5175)
5353+Browser
5454+```
5555+5656+### Plugin lifecycle
5757+5858+1. At plugin creation time, `getPdsIssuer()` synchronously fetches the PDS issuer URL via `execSync` + `curl`. This must be synchronous because Vite's `defineConfig` doesn't support async in all contexts.
5959+2. In `configureServer`, the middleware is registered **before** HonoX's middleware (plugin order matters — `pdsProxy()` is listed first in the `plugins` array).
6060+3. Requests matching `/oauth/`, `/.well-known/`, `/xrpc/`, `/@atproto/` are proxied.
6161+6262+### Why `http.request()` and not `fetch()`
6363+6464+Node's `fetch()` follows the Fetch spec and silently strips `sec-fetch-*` headers. The PDS validates these headers:
6565+- `sec-fetch-site: same-origin` — required for the PDS to accept the request
6666+- `sec-fetch-mode: navigate` — required for page loads (the browser sends this naturally)
6767+6868+`http.request()` from `node:http` forwards headers as-is.
6969+7070+### Response rewriting
7171+7272+Text responses (HTML, JSON, JavaScript) have all occurrences of the PDS issuer URL (e.g. `https://pds.dev`) replaced with the app origin (`http://127.0.0.1:5175`). This ensures:
7373+- The PDS OAuth SPA's internal API calls go through the proxy
7474+- Redirect URLs in JSON responses point to the proxy
7575+- Location headers in 3xx responses are rewritten
7676+7777+Additionally:
7878+- `Strict-Transport-Security` headers are stripped (we're on HTTP)
7979+- `upgrade-insecure-requests` is removed from CSP
8080+- `Secure` flag is stripped from cookies
8181+8282+## Production
8383+8484+In production, none of these workarounds apply:
8585+- `bun run start` runs the Hono app directly under Bun
8686+- `bun:sqlite` resolves natively
8787+- CJS packages are loaded by Bun's module system
8888+- No PDS proxy (the app and PDS are on reachable domains)
8989+- No `.env` loader needed (Bun auto-loads it)
9090+9191+## Key configuration
9292+9393+| Setting | Dev | Production |
9494+|---|---|---|
9595+| Runtime | Node (via Vite) | Bun |
9696+| SQLite driver | `better-sqlite3` (via alias) | `bun:sqlite` |
9797+| PDS access | Vite proxy + URL rewriting | Direct HTTPS |
9898+| `.env` loading | Manual loader in `lib/config.ts` | Bun auto-loads |
9999+| OAuth client type | Loopback (`http://localhost?...`) | Confidential (HTTPS metadata URL) |
+119
docs/oauth.md
···11+# AT Protocol OAuth
22+33+Airglow authenticates users via AT Protocol OAuth, which requires PAR (Pushed Authorization Requests), PKCE, and DPoP. The implementation lives in `lib/auth/` and uses `@atproto/oauth-client-node`.
44+55+## Flow
66+77+1. User submits their handle on `/auth/login`
88+2. Server resolves the handle to a DID (via local PDS in dev, DNS in prod)
99+3. `NodeOAuthClient.authorize()` performs a server-side PAR request to the user's PDS, which returns a `request_uri`
1010+4. Server redirects the browser to the PDS's `/oauth/authorize` page
1111+5. User authorizes the app on the PDS
1212+6. PDS redirects back to `/auth/callback` with an authorization code
1313+7. `NodeOAuthClient.callback()` exchanges the code for tokens (DPoP-bound)
1414+8. Server upserts the user in the local DB and sets a signed session cookie
1515+9. User is redirected to `/dashboard`
1616+1717+## Local dev vs production
1818+1919+The AT Protocol OAuth spec defines two client types. Airglow uses both depending on the environment.
2020+2121+### Local dev (loopback client)
2222+2323+When `PDS_URL` is set or `PUBLIC_URL` is a loopback address, the OAuth client uses the **loopback** format:
2424+2525+- `client_id` is `http://localhost?redirect_uri=...&scope=...` (metadata encoded in the URL itself)
2626+- `application_type: "native"`, `token_endpoint_auth_method: "none"`
2727+- No signing key, no JWKS endpoint
2828+- `allowHttp: true` on the OAuth client
2929+3030+### Production (confidential client)
3131+3232+- `client_id` is `https://<domain>/oauth/client-metadata.json` (served by the app)
3333+- `application_type: "web"`, `token_endpoint_auth_method: "private_key_jwt"`
3434+- ES256 signing key auto-generated at `data/oauth-key.json`
3535+- JWKS served at `/oauth/jwks.json`
3636+- Both endpoints are registered in `app/server.ts`
3737+3838+## CJS compatibility workaround
3939+4040+`@atproto/oauth-client-node` ships as CommonJS. HonoX forces `ssr: { noExternal: true }` in its Vite plugin, which makes Vite ESM-transform all `node_modules` during SSR. CJS modules break because `exports` and `module` are not defined in ESM context.
4141+4242+The workaround in `lib/auth/client.ts`:
4343+4444+```ts
4545+import { createRequire } from "node:module";
4646+const require = createRequire(import.meta.url);
4747+const { NodeOAuthClient, JoseKey, requestLocalLock } = require(
4848+ "@atproto/oauth-client-node",
4949+) as typeof import("@atproto/oauth-client-node");
5050+```
5151+5252+`createRequire()` gives us a CJS-compatible `require()` in an ESM module. This bypasses Vite's transform pipeline entirely. Type safety is preserved via `import type` and the `as typeof import(...)` cast.
5353+5454+In production (running directly under Bun), this still works — Bun supports `createRequire()`.
5555+5656+## Local PDS: URL rewriting and proxy
5757+5858+A local PDS (e.g. running in Docker) registers DIDs with its configured public hostname (e.g. `pds.dev`). This hostname is not reachable from the dev machine — `pds.dev` is a real domain that redirects to GitHub. Two layers of rewriting handle this.
5959+6060+### Server-side: custom fetch
6161+6262+`lib/auth/client.ts` discovers the PDS hostname by querying `/xrpc/com.atproto.server.describeServer` and passes a custom `fetch` to `NodeOAuthClient` that rewrites URLs:
6363+6464+```
6565+https://pds.dev/xrpc/... → http://localhost:3000/xrpc/...
6666+```
6767+6868+This handles all server-side HTTP calls the OAuth client makes (PAR, token exchange, DID resolution).
6969+7070+### Browser-side: Vite proxy
7171+7272+The OAuth authorize page must load in the browser. The browser can't reach `pds.dev`, and the PDS rejects requests where the `Host` header doesn't match its configured hostname.
7373+7474+`vite.config.ts` registers a Vite middleware plugin (`pdsProxy`) that:
7575+7676+1. Intercepts requests to `/oauth/`, `/.well-known/`, `/xrpc/`, `/@atproto/`
7777+2. Forwards them to the local PDS via `http.request()` (not `fetch()` — see below)
7878+3. Rewrites request headers: sets `sec-fetch-site: same-origin`, `Host: <pds-hostname>`
7979+4. Rewrites response bodies: replaces `https://pds.dev` with `http://127.0.0.1:5175`
8080+5. Strips HTTPS-only headers (HSTS, CSP `upgrade-insecure-requests`)
8181+6. Strips `Secure` flag from cookies
8282+8383+The login handler rewrites the authorize URL for the browser:
8484+```ts
8585+const redirectUrl = rewritePdsUrl(url.toString(), "browser");
8686+// https://pds.dev/oauth/authorize?... → http://127.0.0.1:5175/oauth/authorize?...
8787+```
8888+8989+### Why `http.request()` instead of `fetch()`
9090+9191+Node's `fetch()` (based on undici) enforces the Fetch spec and silently strips `sec-fetch-*` headers. The PDS checks these headers to verify same-origin requests. `http.request()` from `node:http` has no such restrictions — headers are forwarded as-is.
9292+9393+### Why `127.0.0.1` instead of `localhost`
9494+9595+AT Protocol OAuth requires `PUBLIC_URL` to use `127.0.0.1` for loopback clients. The PDS also rejects requests where `sec-fetch-site` indicates cross-site — using the IP address instead of `localhost` avoids same-site/cross-site mismatches between the app and the PDS proxy.
9696+9797+## Session management
9898+9999+Sessions use Hono's signed cookies (`hono/cookie`):
100100+101101+- Cookie name: `airglow_session`
102102+- Value: user's DID, HMAC-signed with `COOKIE_SECRET`
103103+- `httpOnly`, `sameSite: Lax`, 30-day expiry
104104+- `secure` only when `PUBLIC_URL` is HTTPS
105105+106106+OAuth token state (access/refresh tokens, DPoP keys) is stored separately in the `oauth_sessions` SQLite table, managed by `NodeOAuthClient`.
107107+108108+## Key files
109109+110110+| File | Role |
111111+|---|---|
112112+| `lib/auth/client.ts` | OAuth client singleton, key management, handle resolution, URL rewriting |
113113+| `lib/auth/storage.ts` | SQLite-backed stores for OAuth state and sessions |
114114+| `lib/auth/middleware.ts` | Cookie-based session middleware (`getSessionUser`, `requireAuth`) |
115115+| `app/routes/auth/login.tsx` | Login form + POST handler (initiates OAuth) |
116116+| `app/routes/auth/callback.tsx` | OAuth callback (token exchange, user upsert, cookie) |
117117+| `app/routes/auth/signout.ts` | Clears session cookie |
118118+| `app/routes/dashboard/_middleware.ts` | Auth guard for dashboard routes |
119119+| `vite.config.ts` | PDS proxy plugin for local dev |
+97
docs/sqlite-bun-compat.md
···11+# SQLite and Bun compatibility
22+33+Airglow uses `bun:sqlite` as its SQLite driver with Drizzle ORM. This works perfectly when running under Bun (production, migrations, tests). But during development, HonoX runs server-side rendering through Vite, which executes on Node — and Node cannot resolve `bun:` module schemes.
44+55+## The problem
66+77+```
88+Only URLs with a scheme in: file, data, and node are supported by the default ESM loader.
99+Received protocol 'bun:'
1010+```
1111+1212+The import chain that triggers this:
1313+1414+```
1515+app/server.ts → lib/auth/client.ts → lib/auth/storage.ts → lib/db/index.ts
1616+ ↓
1717+ import { Database } from "bun:sqlite"
1818+ import { drizzle } from "drizzle-orm/bun-sqlite"
1919+```
2020+2121+HonoX's Vite plugin sets `ssr: { noExternal: true }`, which forces Vite to ESM-transform every dependency during SSR. This means Vite follows all imports and tries to resolve them through Node's module system.
2222+2323+## The workaround
2424+2525+Three Vite `resolve.alias` entries in `vite.config.ts` swap the Bun-specific modules for Node-compatible equivalents during dev:
2626+2727+```ts
2828+resolve: {
2929+ alias: {
3030+ "bun:sqlite": "/lib/db/sqlite-compat.ts",
3131+ "better-sqlite3": "/lib/db/sqlite-compat.ts",
3232+ "drizzle-orm/bun-sqlite": "drizzle-orm/better-sqlite3",
3333+ },
3434+}
3535+```
3636+3737+### What each alias does
3838+3939+**`"bun:sqlite"` -> `lib/db/sqlite-compat.ts`**
4040+4141+Replaces the Bun-native SQLite driver with `better-sqlite3`. The shim uses `createRequire()` to load the CJS package:
4242+4343+```ts
4444+import { createRequire } from "node:module";
4545+const require = createRequire(import.meta.url);
4646+const BetterSqlite3 = require("better-sqlite3");
4747+export default BetterSqlite3;
4848+export const Database = BetterSqlite3;
4949+```
5050+5151+`createRequire()` is necessary because `better-sqlite3` is a CJS module (uses `module.exports`). HonoX's `noExternal: true` forces Vite to ESM-transform it, which fails with `module is not defined`. `require()` bypasses Vite's transform entirely.
5252+5353+**`"better-sqlite3"` -> `lib/db/sqlite-compat.ts`**
5454+5555+The Drizzle adapter (`drizzle-orm/better-sqlite3`) internally imports `better-sqlite3`. Without this alias, Vite would try to ESM-transform the CJS module directly. Pointing it to the same shim ensures all access goes through `createRequire()`.
5656+5757+**`"drizzle-orm/bun-sqlite"` -> `"drizzle-orm/better-sqlite3"`**
5858+5959+The Drizzle ORM adapter for `bun:sqlite` calls bun-specific APIs on prepared statements (e.g. `stmt.values()`) that don't exist on `better-sqlite3`. Swapping the adapter ensures Drizzle uses `better-sqlite3`-compatible query methods.
6060+6161+## Why not just use `better-sqlite3` everywhere?
6262+6363+`bun:sqlite` is Bun's built-in SQLite binding — zero native compilation, faster than `better-sqlite3` under Bun, and no external dependency. The aliases only apply during Vite dev (where Node runs SSR). In production (`bun run start`), the real `bun:sqlite` is used since Bun resolves it natively without Vite.
6464+6565+## Why not `ssr.external`?
6666+6767+The obvious fix would be to tell Vite to externalize `bun:sqlite`:
6868+6969+```ts
7070+ssr: { external: ["bun:sqlite"] }
7171+```
7272+7373+This doesn't work because HonoX's Vite plugin unconditionally sets `ssr: { noExternal: true }`, which overrides any `external` setting. Attempts to override this via `configResolved` also failed — the config is effectively locked by HonoX.
7474+7575+## Environment variables
7676+7777+The `.env` file is auto-loaded by Bun but not by Node (Vite SSR). `lib/config.ts` includes a minimal `.env` loader for the Node context:
7878+7979+```ts
8080+if (typeof globalThis.Bun === "undefined" && existsSync(".env")) {
8181+ for (const line of readFileSync(".env", "utf-8").split("\n")) {
8282+ const m = line.match(/^([^#=\s]+)=(.*)$/);
8383+ if (m?.[1] && !(m[1] in process.env)) process.env[m[1]] = m[2];
8484+ }
8585+}
8686+```
8787+8888+This only fills in variables that aren't already in `process.env`, so it doesn't override shell environment variables.
8989+9090+## Key files
9191+9292+| File | Role |
9393+|---|---|
9494+| `lib/db/sqlite-compat.ts` | `createRequire()` shim for `better-sqlite3` |
9595+| `lib/db/index.ts` | Database connection (imports `bun:sqlite`, aliased in dev) |
9696+| `vite.config.ts` | Aliases and PDS proxy plugin |
9797+| `lib/config.ts` | `.env` loader for Node SSR context |