···99- **UI**: hono/jsx (SSR + client hydration)
1010- **Styling**: @vanilla-extract/css
1111- **Database**: SQLite via bun:sqlite + Drizzle ORM
1212-- **Toolchain**: Vite+ (`vp`) — wraps Vite 8, Vitest, Oxlint, Oxfmt
1212+- **Toolchain**: Vite+ (`vp`) wraps Vite 8, Vitest, Oxlint, Oxfmt
1313- **Lexicons**: managed with `goat lex`, stored in `lexicons/`
14141515## Commands
16161717-- `vp dev` — start dev server
1818-- `vp build --mode client` — build client assets
1919-- `vp check` — lint, format, type-check
2020-- `vp test` — run tests
2121-- `bun run start` — run production server
2222-- `bun run db:generate` — generate Drizzle migrations
2323-- `bun run db:migrate` — run migrations
2424-- `goat lex lint lexicons/` — lint lexicon schemas
1717+- `vp dev`: start dev server
1818+- `vp build --mode client`: build client assets
1919+- `vp check`: lint, format, type-check
2020+- `vp test`: run tests
2121+- `bun run start`: run production server
2222+- `bun run db:generate`: generate Drizzle migrations
2323+- `bun run db:migrate`: run migrations
2424+- `goat lex lint lexicons/`: lint lexicon schemas
25252626## Conventions
27272828- Use `bun` instead of `node`, `npm`, etc.
2929- Bun auto-loads .env — no dotenv needed
3030-- Use `bun:sqlite` for SQLite — not better-sqlite3
3030+- Use `bun:sqlite` for SQLite, not better-sqlite3
3131- Routes go in `app/routes/`, islands in `app/islands/`
3232- Shared non-interactive components go in `app/components/`
3333- Backend logic goes in `lib/`
···11+import { createRoute } from "honox/factory";
22+import { AppShell } from "../../../../components/Layout/AppShell/index.js";
33+import { Header } from "../../../../components/Layout/Header/index.js";
44+import { Container } from "../../../../components/Layout/Container/index.js";
55+import { PageHeader } from "../../../../components/Layout/PageHeader/index.js";
66+import { Card } from "../../../../components/Card/index.js";
77+import { Button } from "../../../../components/Button/index.js";
88+import ThemeToggle from "../../../../islands/ThemeToggle.js";
99+import SubscriptionForm from "../../../../islands/SubscriptionForm.js";
1010+1111+export default createRoute((c) => {
1212+ const user = c.get("user");
1313+1414+ return c.render(
1515+ <AppShell header={<Header user={user} actions={<ThemeToggle />} />}>
1616+ <Container>
1717+ <PageHeader
1818+ title="New Webhook Subscription"
1919+ actions={
2020+ <Button href="/dashboard/subscriptions/new" variant="ghost" size="sm">
2121+ ← Back
2222+ </Button>
2323+ }
2424+ />
2525+ <Card variant="flat">
2626+ <SubscriptionForm type="webhook" />
2727+ </Card>
2828+ </Container>
2929+ </AppShell>,
3030+ { title: "New Webhook — Airglow" },
3131+ );
3232+});
+12-16
app/routes/index.tsx
···1616 <section class={s.hero}>
1717 <h1 class={s.heroTitle}>Webhooks for the AT Protocol</h1>
1818 <p class={s.heroSubtitle}>
1919- Subscribe to events across the AT Protocol network and receive
2020- real-time webhook deliveries. Filter by lexicon, match conditions,
2121- and track every delivery.
1919+ Subscribe to events across the AT Protocol network and receive real-time webhook
2020+ deliveries. Filter by lexicon, match conditions, and track every delivery.
2221 </p>
2322 {user ? (
2423 <Button href="/dashboard" size="lg">
···3534 <div class={s.featureCard}>
3635 <h3 class={s.featureTitle}>Real-time Webhooks</h3>
3736 <p class={s.featureDesc}>
3838- Receive HTTP POST callbacks instantly when matching events occur on
3939- the AT Protocol network via Jetstream.
3737+ Receive HTTP POST callbacks instantly when matching events occur on the AT Protocol
3838+ network via Jetstream.
4039 </p>
4140 </div>
4241 <div class={s.featureCard}>
4342 <h3 class={s.featureTitle}>Lexicon Filtering</h3>
4443 <p class={s.featureDesc}>
4545- Subscribe to specific record types by NSID. Add field-level
4646- conditions to match exactly the events you need.
4444+ Subscribe to specific record types by NSID. Add field-level conditions to match
4545+ exactly the events you need.
4746 </p>
4847 </div>
4948 <div class={s.featureCard}>
5049 <h3 class={s.featureTitle}>Delivery Tracking</h3>
5150 <p class={s.featureDesc}>
5252- Full delivery log with status codes, retry attempts, and error
5353- details. Know exactly what happened with every event.
5151+ Full delivery log with status codes, retry attempts, and error details. Know exactly
5252+ what happened with every event.
5453 </p>
5554 </div>
5655 <div class={s.featureCard}>
5756 <h3 class={s.featureTitle}>HMAC Signing</h3>
5857 <p class={s.featureDesc}>
5959- Every webhook is signed with a per-subscription HMAC secret so
6060- your callback can verify authenticity.
5858+ Every webhook is signed with a per-subscription HMAC secret so your callback can
5959+ verify authenticity.
6160 </p>
6261 </div>
6362 </section>
···6867 <li class={s.step}>
6968 <div class={s.stepNumber}>1</div>
7069 <h3 class={s.stepTitle}>Sign in</h3>
7171- <p class={s.stepDesc}>
7272- Authenticate with your AT Protocol identity via OAuth.
7373- </p>
7070+ <p class={s.stepDesc}>Authenticate with your AT Protocol identity via OAuth.</p>
7471 </li>
7572 <li class={s.step}>
7673 <div class={s.stepNumber}>2</div>
···8380 <div class={s.stepNumber}>3</div>
8481 <h3 class={s.stepTitle}>Receive</h3>
8582 <p class={s.stepDesc}>
8686- Get signed webhook deliveries in real time with automatic
8787- retries.
8383+ Get signed webhook deliveries in real time with automatic retries.
8884 </p>
8985 </li>
9086 </ol>
···11-import {
22- createGlobalThemeContract,
33- createGlobalTheme,
44- globalStyle,
55-} from "@vanilla-extract/css";
11+import { createGlobalThemeContract, createGlobalTheme, globalStyle } from "@vanilla-extract/css";
62import { darkColors, lightColors } from "./tokens/colors.ts";
73import { darkShadows, lightShadows } from "./tokens/shadows.ts";
84
+1-7
app/styles/tokens/index.ts
···11export { darkColors, lightColors } from "./colors.ts";
22export { space } from "./spacing.ts";
33-export {
44- fontFamily,
55- fontSize,
66- fontWeight,
77- lineHeight,
88- letterSpacing,
99-} from "./typography.ts";
33+export { fontFamily, fontSize, fontWeight, lineHeight, letterSpacing } from "./typography.ts";
104export { lightShadows, darkShadows } from "./shadows.ts";
115export { radii } from "./radii.ts";
126export { breakpoints } from "./breakpoints.ts";
+17-7
docs/dev-server.md
···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.
20202121The key constraint: **all server-side code executes in Node**, not Bun. This means:
2222+2223- `bun:sqlite` is not available (see [sqlite-bun-compat.md](./sqlite-bun-compat.md))
2324- CJS packages must be loaded via `createRequire()` (see [oauth.md](./oauth.md))
2425- `.env` must be loaded manually for Node
···2829HonoX's Vite plugin unconditionally sets:
29303031```ts
3131-{ ssr: { noExternal: true } }
3232+{
3333+ ssr: {
3434+ noExternal: true;
3535+ }
3636+}
3237```
33383439This 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()`.
35403641Workarounds:
4242+3743- **Vite aliases** redirect `bun:sqlite` and `better-sqlite3` to a `createRequire()` shim
3844- **`createRequire()`** loads `@atproto/oauth-client-node` bypassing Vite's transform
3945···6268### Why `http.request()` and not `fetch()`
63696470Node's `fetch()` follows the Fetch spec and silently strips `sec-fetch-*` headers. The PDS validates these headers:
7171+6572- `sec-fetch-site: same-origin` — required for the PDS to accept the request
6673- `sec-fetch-mode: navigate` — required for page loads (the browser sends this naturally)
6774···7077### Response rewriting
71787279Text 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:
8080+7381- The PDS OAuth SPA's internal API calls go through the proxy
7482- Redirect URLs in JSON responses point to the proxy
7583- Location headers in 3xx responses are rewritten
76847785Additionally:
8686+7887- `Strict-Transport-Security` headers are stripped (we're on HTTP)
7988- `upgrade-insecure-requests` is removed from CSP
8089- `Secure` flag is stripped from cookies
···8291## Production
83928493In production, none of these workarounds apply:
9494+8595- `bun run start` runs the Hono app directly under Bun
8696- `bun:sqlite` resolves natively
8797- CJS packages are loaded by Bun's module system
···9010091101## Key configuration
921029393-| 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 |
103103+| Setting | Dev | Production |
104104+| ----------------- | --------------------------------- | --------------------------------- |
105105+| Runtime | Node (via Vite) | Bun |
106106+| SQLite driver | `better-sqlite3` (via alias) | `bun:sqlite` |
107107+| PDS access | Vite proxy + URL rewriting | Direct HTTPS |
108108+| `.env` loading | Manual loader in `lib/config.ts` | Bun auto-loads |
99109| OAuth client type | Loopback (`http://localhost?...`) | Confidential (HTTPS metadata URL) |
+13-13
docs/oauth.md
···4444```ts
4545import { createRequire } from "node:module";
4646const require = createRequire(import.meta.url);
4747-const { NodeOAuthClient, JoseKey, requestLocalLock } = require(
4848- "@atproto/oauth-client-node",
4949-) as typeof import("@atproto/oauth-client-node");
4747+const { NodeOAuthClient, JoseKey, requestLocalLock } =
4848+ require("@atproto/oauth-client-node") as typeof import("@atproto/oauth-client-node");
5049```
51505251`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.
···81806. Strips `Secure` flag from cookies
82818382The login handler rewrites the authorize URL for the browser:
8383+8484```ts
8585const redirectUrl = rewritePdsUrl(url.toString(), "browser");
8686// https://pds.dev/oauth/authorize?... → http://127.0.0.1:5175/oauth/authorize?...
···107107108108## Key files
109109110110-| 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 |
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 |
+9-7
docs/sqlite-bun-compat.md
···6767The obvious fix would be to tell Vite to externalize `bun:sqlite`:
68686969```ts
7070-ssr: { external: ["bun:sqlite"] }
7070+ssr: {
7171+ external: ["bun:sqlite"];
7272+}
7173```
72747375This 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.
···89919092## Key files
91939292-| 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 |
9494+| File | Role |
9595+| ------------------------- | ---------------------------------------------------------- |
9696+| `lib/db/sqlite-compat.ts` | `createRequire()` shim for `better-sqlite3` |
9797+| `lib/db/index.ts` | Database connection (imports `bun:sqlite`, aliased in dev) |
9898+| `vite.config.ts` | Aliases and PDS proxy plugin |
9999+| `lib/config.ts` | `.env` loader for Node SSR context |
+19-2
lexicons/app/rglw/subscription.json
···88 "key": "tid",
99 "record": {
1010 "type": "object",
1111- "required": ["lexicon", "callbackUrl", "createdAt"],
1111+ "required": ["lexicon", "createdAt"],
1212 "properties": {
1313 "lexicon": {
1414 "type": "string",
1515 "description": "NSID of the collection to subscribe to.",
1616 "maxLength": 256
1717 },
1818+ "type": {
1919+ "type": "string",
2020+ "description": "Subscription type: 'webhook' delivers via HTTP POST, 'record' creates a record on the subscriber's PDS.",
2121+ "knownValues": ["webhook", "record"],
2222+ "default": "webhook",
2323+ "maxLength": 32
2424+ },
1825 "callbackUrl": {
1926 "type": "string",
2027 "format": "uri",
2121- "description": "URL to receive webhook POST requests.",
2828+ "description": "For webhook subscriptions: URL to receive webhook POST requests.",
2229 "maxLength": 2048
3030+ },
3131+ "targetCollection": {
3232+ "type": "string",
3333+ "description": "For record subscriptions: NSID of the collection to create the record in.",
3434+ "maxLength": 256
3535+ },
3636+ "recordTemplate": {
3737+ "type": "string",
3838+ "description": "For record subscriptions: JSON template with {{placeholder}} expressions resolved from event data.",
3939+ "maxLength": 10240
2340 },
2441 "conditions": {
2542 "type": "array",
···11+import type { JetstreamEvent } from "../jetstream/matcher.js";
22+33+const PLACEHOLDER_RE = /\{\{([^}]+)\}\}/g;
44+55+/**
66+ * Resolve a placeholder path against a Jetstream event.
77+ * Supports:
88+ * - "now" → current ISO datetime
99+ * - "event.did", "event.time_us", "event.kind"
1010+ * - "event.commit.operation", "event.commit.collection", "event.commit.rkey", "event.commit.cid"
1111+ * - "event.commit.record.<dotted.path>" → nested record field
1212+ */
1313+function resolvePlaceholder(path: string, event: JetstreamEvent): unknown {
1414+ if (path === "now") return new Date().toISOString();
1515+1616+ if (!path.startsWith("event.")) return undefined;
1717+ const rest = path.slice("event.".length);
1818+1919+ // Walk dot path into event
2020+ let value: unknown = event;
2121+ for (const key of rest.split(".")) {
2222+ if (value == null || typeof value !== "object") return undefined;
2323+ value = (value as Record<string, unknown>)[key];
2424+ }
2525+ return value;
2626+}
2727+2828+/** Validate template syntax at creation time. */
2929+export function validateTemplate(
3030+ template: string,
3131+): { valid: true; placeholders: string[] } | { valid: false; error: string } {
3232+ // Check that the template is valid JSON (ignoring placeholders)
3333+ const placeholders: string[] = [];
3434+ const stripped = template.replace(PLACEHOLDER_RE, (_, path: string) => {
3535+ placeholders.push(path.trim());
3636+ return '"__placeholder__"';
3737+ });
3838+3939+ try {
4040+ const parsed = JSON.parse(stripped);
4141+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
4242+ return { valid: false, error: "Template must be a JSON object" };
4343+ }
4444+ } catch {
4545+ return { valid: false, error: "Template is not valid JSON" };
4646+ }
4747+4848+ if (placeholders.length === 0) {
4949+ return { valid: false, error: "Template must contain at least one {{placeholder}}" };
5050+ }
5151+5252+ for (const p of placeholders) {
5353+ if (p !== "now" && !p.startsWith("event.")) {
5454+ return { valid: false, error: `Invalid placeholder: {{${p}}}` };
5555+ }
5656+ }
5757+5858+ return { valid: true, placeholders };
5959+}
6060+6161+/** Render a template by resolving all {{placeholder}} expressions against event data. */
6262+export function renderTemplate(template: string, event: JetstreamEvent): Record<string, unknown> {
6363+ const rendered = template.replace(PLACEHOLDER_RE, (match, path: string) => {
6464+ const value = resolvePlaceholder(path.trim(), event);
6565+ if (value === undefined) return "";
6666+6767+ // If the placeholder is the entire JSON value (between quotes), return raw
6868+ // Otherwise return as string for interpolation within a larger string
6969+ if (typeof value === "string") {
7070+ // Escape for JSON string context
7171+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
7272+ }
7373+ if (typeof value === "number" || typeof value === "boolean") {
7474+ return String(value);
7575+ }
7676+ // For objects/arrays, stringify (without outer quotes)
7777+ return JSON.stringify(value);
7878+ });
7979+8080+ try {
8181+ const parsed = JSON.parse(rendered);
8282+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
8383+ throw new Error("Rendered template is not a JSON object");
8484+ }
8585+ return parsed as Record<string, unknown>;
8686+ } catch (err) {
8787+ throw new Error(
8888+ `Failed to parse rendered template: ${err instanceof Error ? err.message : String(err)}`,
8989+ );
9090+ }
9191+}
+2-3
lib/auth/client.ts
···66// Load via require() — HonoX forces Vite to ESM-transform all node_modules
77// during SSR, which breaks CJS packages. require() bypasses Vite's transform.
88const require = createRequire(import.meta.url);
99-const { JoseKey, NodeOAuthClient, requestLocalLock } = require(
1010- "@atproto/oauth-client-node",
1111-) as typeof import("@atproto/oauth-client-node");
99+const { JoseKey, NodeOAuthClient, requestLocalLock } =
1010+ require("@atproto/oauth-client-node") as typeof import("@atproto/oauth-client-node");
1211import { config } from "../config.js";
1312import { sessionStore, stateStore } from "./storage.js";
1413
+1-1
lib/config.ts
···2233// Bun auto-loads .env, but Vite SSR runs on Node which doesn't.
44// Load missing vars so config works in both contexts.
55-if (typeof globalThis.Bun === "undefined" && existsSync(".env")) {
55+if (!("Bun" in globalThis) && existsSync(".env")) {
66 for (const line of readFileSync(".env", "utf-8").split("\n")) {
77 const m = line.match(/^([^#=\s]+)=(.*)$/);
88 if (m?.[1] && !(m[1] in process.env)) process.env[m[1]] = m[2];
+20
lib/db/migrations/0001_early_kronos.sql
···11+PRAGMA foreign_keys=OFF;--> statement-breakpoint
22+CREATE TABLE `__new_subscriptions` (
33+ `uri` text PRIMARY KEY NOT NULL,
44+ `did` text NOT NULL,
55+ `rkey` text NOT NULL,
66+ `type` text DEFAULT 'webhook' NOT NULL,
77+ `lexicon` text NOT NULL,
88+ `callback_url` text,
99+ `conditions` text DEFAULT '[]' NOT NULL,
1010+ `secret` text,
1111+ `target_collection` text,
1212+ `record_template` text,
1313+ `active` integer DEFAULT false NOT NULL,
1414+ `indexed_at` integer NOT NULL
1515+);
1616+--> statement-breakpoint
1717+INSERT INTO `__new_subscriptions`("uri", "did", "rkey", "type", "lexicon", "callback_url", "conditions", "secret", "target_collection", "record_template", "active", "indexed_at") SELECT "uri", "did", "rkey", "type", "lexicon", "callback_url", "conditions", "secret", "target_collection", "record_template", "active", "indexed_at" FROM `subscriptions`;--> statement-breakpoint
1818+DROP TABLE `subscriptions`;--> statement-breakpoint
1919+ALTER TABLE `__new_subscriptions` RENAME TO `subscriptions`;--> statement-breakpoint
2020+PRAGMA foreign_keys=ON;
···22import { request as httpRequest } from "node:http";
33import honox from "honox/vite";
44import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
55-import { defineConfig, type Plugin, type ViteDevServer } from "vite";
55+import { defineConfig } from "vite-plus";
66+import type { Plugin, ViteDevServer } from "vite";
6778// Collect all CSS from the Vite module graph and serve at /__dev.css
89// This allows a blocking <link> tag in dev mode to prevent FOUC
···2425 if (!result?.code) continue;
2526 // Vite wraps CSS as: const __vite__css = "...css..."
2627 // or similar patterns. Extract everything between the first ` = "` and the closing `"`
2727- const match = result.code.match(
2828- /(?:__vite__css|css)\s*=\s*"((?:[^"\\]|\\.)*)"/s,
2929- );
2828+ const match = result.code.match(/(?:__vite__css|css)\s*=\s*"((?:[^"\\]|\\.)*)"/s);
3029 if (match?.[1]) {
3130 // Unescape the JS string
3231 const css = match[1]