~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/hatk

AT Protocol app framework

1
0
main
900 lines · 27.3 KB Raw History
# Vite SSR & Environment API Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Replace tsx watch with Vite 8 Environment API for in-process HMR, rewrite server to Web Standard Request/Response, add framework-agnostic SSR, and produce a single deployable artifact.

**Architecture:** The server becomes a pure `(Request) → Response` function. A ~50 line Node.js adapter bridges it to `createServer` for production. In dev, Vite's `RunnableDevEnvironment` runs the handler with HMR. `defineRenderer` provides framework-agnostic SSR. `vite build` produces client assets + server entry in two stages.

**Tech Stack:** Vite 8 Environment API, Web Standard Request/Response, TypeScript

**Working directory:** `/Users/chadmiller/code/hatk/.worktrees/server-directory`

---

### Task 1: Node.js Request/Response adapter

Create the ~50 line bridge between Node.js HTTP and Web Standard APIs. This is the foundation everything else builds on.

**Files:**
- Create: `packages/hatk/src/adapter.ts`

**Step 1: Write adapter.ts**

```typescript
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http'

/**
 * Convert a Node.js IncomingMessage to a Web Standard Request.
 */
export function toRequest(req: IncomingMessage, base: string): Request {
  const url = new URL(req.url!, base)
  const headers = new Headers()
  for (const [key, value] of Object.entries(req.headers)) {
    if (value) {
      if (Array.isArray(value)) {
        for (const v of value) headers.append(key, v)
      } else {
        headers.set(key, value)
      }
    }
  }

  const init: RequestInit = {
    method: req.method,
    headers,
  }

  // GET and HEAD requests cannot have a body
  if (req.method !== 'GET' && req.method !== 'HEAD') {
    // @ts-expect-error — Node.js streams are valid body sources
    init.body = req
    init.duplex = 'half'
  }

  return new Request(url.href, init)
}

/**
 * Pipe a Web Standard Response back to a Node.js ServerResponse.
 */
export async function sendResponse(res: ServerResponse, response: Response): Promise<void> {
  res.writeHead(response.status, Object.fromEntries(response.headers.entries()))

  if (!response.body) {
    res.end()
    return
  }

  const reader = response.body.getReader()
  try {
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      res.write(value)
    }
  } finally {
    reader.releaseLock()
    res.end()
  }
}

/**
 * Create a Node.js HTTP server from a Web Standard fetch handler.
 */
export function serve(
  handler: (request: Request) => Promise<Response>,
  port: number,
  base?: string,
) {
  const origin = base || `http://localhost:${port}`
  const server = createServer(async (req, res) => {
    try {
      const request = toRequest(req, origin)
      const response = await handler(request)
      await sendResponse(res, response)
    } catch (err: any) {
      if (!res.headersSent) {
        res.writeHead(500, { 'Content-Type': 'application/json' })
      }
      res.end(JSON.stringify({ error: err.message }))
    }
  })
  server.listen(port)
  return server
}
```

**Step 2: Verify build**

Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit`
Expected: No errors

**Step 3: Commit**

```bash
git add packages/hatk/src/adapter.ts
git commit -m "feat: add Node.js Request/Response adapter"
```

---

### Task 2: Response helper functions

Create the utility functions that the rewritten server will use. These replace the old `jsonResponse`/`jsonError`/`sendJson` functions.

**Files:**
- Create: `packages/hatk/src/response.ts`

**Step 1: Write response.ts**

```typescript
import { gzipSync } from 'node:zlib'
import { normalizeValue } from './database/db.ts'

/**
 * Create a JSON Response with optional gzip compression.
 * Mirrors the old jsonResponse/sendJson behavior.
 */
export function json(data: unknown, status = 200, acceptEncoding?: string | null): Response {
  const body = Buffer.from(JSON.stringify(data, (_, v) => normalizeValue(v)))

  if (body.length > 1024 && acceptEncoding && /\bgzip\b/.test(acceptEncoding)) {
    const compressed = gzipSync(body)
    return new Response(compressed, {
      status,
      headers: {
        'Content-Type': 'application/json',
        'Content-Encoding': 'gzip',
        'Vary': 'Accept-Encoding',
        ...(status === 200 ? { 'Cache-Control': 'no-store' } : {}),
      },
    })
  }

  return new Response(body, {
    status,
    headers: {
      'Content-Type': 'application/json',
      ...(status === 200 ? { 'Cache-Control': 'no-store' } : {}),
    },
  })
}

/** Create a JSON error Response. */
export function jsonError(status: number, message: string, acceptEncoding?: string | null): Response {
  return json({ error: message }, status, acceptEncoding)
}

/** CORS preflight Response. */
export function cors(): Response {
  return new Response(null, {
    status: 200,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Headers': '*',
      'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
    },
  })
}

/** Add CORS headers to an existing Response. */
export function withCors(response: Response): Response {
  const headers = new Headers(response.headers)
  headers.set('Access-Control-Allow-Origin', '*')
  headers.set('Access-Control-Allow-Headers', '*')
  headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  })
}

/** Create a static file Response with correct MIME type. */
export function file(content: Buffer | Uint8Array, contentType: string, cacheControl?: string): Response {
  return new Response(content, {
    status: 200,
    headers: {
      'Content-Type': contentType,
      ...(cacheControl ? { 'Cache-Control': cacheControl } : {}),
    },
  })
}

/** 404 Not Found. */
export function notFound(): Response {
  return new Response('Not Found', { status: 404 })
}
```

**Step 2: Verify build**

Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit`
Expected: No errors

**Step 3: Commit**

```bash
git add packages/hatk/src/response.ts
git commit -m "feat: add Web Standard Response helper functions"
```

---

### Task 3: Rewrite server.ts to Request → Response

This is the biggest task. Rewrite the 1200-line `startServer` function as a pure `createHandler` function that returns `(Request) → Promise<Response>`. Every `res.writeHead()`/`res.end()` becomes a `return new Response(...)`. Every `readBody(req)` becomes `await request.text()`.

**Files:**
- Modify: `packages/hatk/src/server.ts` (complete rewrite)

**Step 1: Understand the mapping**

| Old pattern | New pattern |
|---|---|
| `readBody(req)` | `await request.text()` |
| `readBodyRaw(req)` | `Buffer.from(await request.arrayBuffer())` |
| `req.url` | `request.url` (already a full URL string) |
| `req.method` | `request.method` |
| `req.headers['authorization']` | `request.headers.get('authorization')` |
| `jsonResponse(res, data)` | `return json(data, 200, acceptEncoding)` |
| `jsonError(res, 400, 'msg')` | `return jsonError(400, 'msg', acceptEncoding)` |
| `res.writeHead(200, {...}); res.end(buf)` | `return file(buf, 'image/png', 'public, max-age=300')` |
| `url.searchParams.get(...)` | Same (URL is constructed from request.url) |

**Step 2: Rewrite the file**

The new structure:

```typescript
import { json, jsonError, cors, withCors, file, notFound } from './response.ts'
// ... existing imports minus createServer, IncomingMessage, gzipSync

export interface HandlerConfig {
  collections: string[]
  publicDir: string | null
  oauth: OAuthConfig | null
  admins: string[]
  renderer?: (request: Request, manifest: any) => Promise<{ html: string; head?: string }>
  resolveViewer?: (request: Request) => { did: string } | null
  onResync?: () => void
}

/**
 * Create a Web Standard request handler for all hatk routes.
 * Returns a pure function: (Request) → Promise<Response>
 */
export function createHandler(config: HandlerConfig): (request: Request) => Promise<Response> {
  const { collections, publicDir, oauth, admins } = config
  const devMode = process.env.DEV_MODE === '1'
  const coreXrpc = (method: string) => `/xrpc/dev.hatk.${method}`

  return async (request: Request): Promise<Response> => {
    const url = new URL(request.url)
    const acceptEncoding = request.headers.get('accept-encoding')

    // CORS preflight
    if (request.method === 'OPTIONS') return cors()

    // ... all the existing route handlers, converted to return Response objects
    // Each `jsonResponse(res, data)` becomes `return withCors(json(data, 200, acceptEncoding))`
    // Each `jsonError(res, status, msg)` becomes `return withCors(jsonError(status, msg, acceptEncoding))`

    return notFound()
  }
}

// Keep startServer as a thin wrapper for backward compatibility during migration
export { serve } from './adapter.ts'
```

**Key conversion rules for each route:**

1. **Auth**: `req.headers['authorization']` → `request.headers.get('authorization')`
2. **Body**: `readBody(req)` → `await request.text()`
3. **Binary body**: `readBodyRaw(req)` → `Buffer.from(await request.arrayBuffer())`
4. **JSON response**: `jsonResponse(res, data); return` → `return withCors(json(data, 200, acceptEncoding))`
5. **Error**: `jsonError(res, 400, msg); return` → `return withCors(jsonError(400, msg, acceptEncoding))`
6. **Binary response**: `res.writeHead(200, ...); res.end(png)` → `return withCors(file(png, 'image/png', 'public, max-age=300'))`
7. **HTML response**: `res.writeHead(200, ...); res.end(html)` → `return withCors(file(Buffer.from(html), 'text/html'))`
8. **Static files**: Same pattern as HTML but with appropriate MIME type
9. **SPA fallback with OG meta**: Read index.html, inject OG meta, return as HTML Response
10. **Request origin**: `req.headers['x-forwarded-proto']` → `request.headers.get('x-forwarded-proto')`

**Step 3: Handle the `requireAdmin` helper**

Convert from mutating `res` to returning `null` (meaning auth failed):

```typescript
function requireAdmin(viewer: { did: string } | null, acceptEncoding: string | null): Response | null {
  if (!viewer) return withCors(jsonError(401, 'Authentication required', acceptEncoding))
  if (!devMode && !admins.includes(viewer.did)) return withCors(jsonError(403, 'Admin access required', acceptEncoding))
  return null // auth OK
}
```

Usage: `const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied;`

**Step 4: Handle the viewer authentication**

Convert from reading `req.headers` to reading `request.headers`:

```typescript
let viewer: { did: string } | null = config.resolveViewer?.(request) ?? null
if (!viewer && oauth) {
  try {
    viewer = await authenticate(
      request.headers.get('authorization'),
      request.headers.get('dpop'),
      request.method,
      `${requestOrigin}${url.pathname}`,
    )
  } catch (err: any) {
    emit('oauth', 'authenticate_error', { error: err.message })
  }
}
```

**Step 5: Convert OAuth routes**

The OAuth routes (`/oauth/par`, `/oauth/token`, `/oauth/jwks`, etc.) follow the same pattern. The key ones:

- `handlePar` currently receives `(body, origin)` — keep this, just get body from `request.text()`
- `handleToken` same
- `handleCallback` receives `(code, iss, state)` from URL params

**Step 6: Convert all remaining routes**

Work through every `if (url.pathname === ...)` block systematically. The conversion is mechanical — just apply the mapping from Step 1.

**Step 7: Keep `proxyToPds` and `proxyToPdsRaw` unchanged**

These functions already use the fetch API internally. They return `{ ok, status, body }` objects. The caller just wraps the result in a `Response`.

**Step 8: Remove the old `startServer` function**

Replace it with:

```typescript
export function startServer(
  port: number,
  collections: string[],
  publicDir: string | null,
  oauth: OAuthConfig | null,
  admins: string[] = [],
  resolveViewer?: (request: Request) => { did: string } | null,
  onResync?: () => void,
): import('node:http').Server {
  const handler = createHandler({ collections, publicDir, oauth, admins, resolveViewer, onResync })
  return serve(handler, port)
}
```

**Step 9: Verify build**

Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit`
Expected: No errors

**Step 10: Commit**

```bash
git add packages/hatk/src/server.ts
git commit -m "feat: rewrite server.ts to Web Standard Request/Response"
```

---

### Task 4: defineRenderer and SSR assembly

Add the renderer define function and the logic that assembles SSR output into an HTML page.

**Files:**
- Create: `packages/hatk/src/renderer.ts`
- Modify: `packages/hatk/src/server.ts` (add SSR rendering to SPA fallback route)

**Step 1: Write renderer.ts**

```typescript
import { log } from './logger.ts'

export interface SSRManifest {
  getPreloadTags(url: string): string
}

export interface RenderResult {
  html: string
  head?: string
}

export type RendererHandler = (request: Request, manifest: SSRManifest) => Promise<RenderResult>

let renderer: RendererHandler | null = null
let ssrManifest: SSRManifest | null = null

export function defineRenderer(handler: RendererHandler) {
  return { __type: 'renderer' as const, handler }
}

export function registerRenderer(handler: RendererHandler): void {
  renderer = handler
  log('[renderer] SSR renderer registered')
}

export function setSSRManifest(manifest: SSRManifest): void {
  ssrManifest = manifest
}

export function getRenderer(): RendererHandler | null {
  return renderer
}

export function getSSRManifest(): SSRManifest | null {
  return ssrManifest
}

/**
 * Render an HTML page by calling the user's renderer and assembling the result
 * into the index.html template.
 *
 * @param template - The index.html content (with <!--ssr-outlet--> placeholder)
 * @param request - The incoming Request
 * @param ogMeta - Optional OG meta tags to inject
 * @returns Assembled HTML string, or null if no renderer is registered
 */
export async function renderPage(
  template: string,
  request: Request,
  ogMeta?: string | null,
): Promise<string | null> {
  if (!renderer) return null

  const manifest = ssrManifest || { getPreloadTags: () => '' }
  const result = await renderer(request, manifest)

  let html = template

  // Inject SSR head tags (preloads, styles)
  if (result.head) {
    html = html.replace('</head>', `${result.head}\n</head>`)
  }

  // Inject OG meta tags
  if (ogMeta) {
    html = html.replace('</head>', `${ogMeta}\n</head>`)
  }

  // Inject rendered HTML into the outlet
  html = html.replace('<!--ssr-outlet-->', result.html)

  return html
}
```

**Step 2: Wire renderer into server.ts SPA fallback**

In the SPA fallback section of `createHandler` (the part that serves index.html), add SSR rendering:

```typescript
// SSR or SPA fallback for HTML requests
if (publicDir && request.headers.get('accept')?.includes('text/html')) {
  const template = await readFile(join(publicDir, 'index.html'), 'utf-8')
  const ogMeta = buildOgMeta(url.pathname, requestOrigin)

  // Try SSR first
  const renderedHtml = await renderPage(template, request, ogMeta)
  if (renderedHtml) {
    return withCors(file(Buffer.from(renderedHtml), 'text/html'))
  }

  // SPA fallback — inject OG meta only
  let html = template
  if (ogMeta) {
    html = html.replace('</head>', `${ogMeta}\n</head>`)
  }
  return withCors(file(Buffer.from(html), 'text/html'))
}
```

**Step 3: Add renderer to scanner**

Update `packages/hatk/src/scanner.ts` to handle `__type: 'renderer'` and add to ScanResult.

Update `packages/hatk/src/server-init.ts` to call `registerRenderer` for scanned renderer modules.

**Step 4: Export defineRenderer**

Add to `packages/hatk/src/cli.ts` codegen:
```
out += `export { defineRenderer } from '@hatk/hatk/renderer'\n`
```

Add `"./renderer": "./dist/renderer.js"` to `packages/hatk/package.json` exports.

**Step 5: Verify build**

Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit`
Expected: No errors

**Step 6: Commit**

```bash
git add packages/hatk/src/renderer.ts packages/hatk/src/server.ts packages/hatk/src/scanner.ts packages/hatk/src/server-init.ts packages/hatk/src/cli.ts packages/hatk/package.json
git commit -m "feat: add defineRenderer and SSR assembly"
```

---

### Task 5: Rewrite Vite plugin with Environment API

Replace the tsx watch child process + proxy approach with Vite 8's `RunnableDevEnvironment` and middleware.

**Files:**
- Modify: `packages/hatk/src/vite-plugin.ts` (complete rewrite)

**Step 1: Read Vite 8 Environment API types**

Before writing, read:
- Vite's `Plugin` type
- `RunnableDevEnvironment` / `isRunnableDevEnvironment`
- `DevEnvironment` and its `runner` property
- How `configureServer` hooks work with environments

**Step 2: Rewrite vite-plugin.ts**

```typescript
import type { Plugin, ViteDevServer } from 'vite'
import { resolve } from 'node:path'

export function hatk(opts?: { port?: number }): Plugin {
  const devPort = opts?.port ?? 3000
  let handler: ((request: Request) => Promise<Response>) | null = null

  return {
    name: 'vite-plugin-hatk',

    config() {
      return {
        environments: {
          hatk: {
            // RunnableDevEnvironment for in-process module execution
            dev: {
              optimizeDeps: {
                // Externalize native modules
                exclude: ['better-sqlite3', '@duckdb/node-api'],
              },
            },
            build: {
              outDir: 'dist/server',
              ssr: true,
              rollupOptions: {
                external: ['better-sqlite3', '@duckdb/node-api'],
              },
            },
          },
        },
        server: {
          host: '127.0.0.1',
          port: devPort,
          watch: {
            ignored: ['**/db/**', '**/data/**'],
          },
        },
        test: {
          projects: [
            {
              test: {
                name: 'unit',
                include: ['test/server/**/*.test.ts', 'test/feeds/**/*.test.ts', 'test/xrpc/**/*.test.ts'],
              },
            },
            {
              test: {
                name: 'integration',
                include: ['test/integration/**/*.test.ts'],
              },
            },
          ],
        },
      }
    },

    configureServer(server: ViteDevServer) {
      // Boot hatk infrastructure and load handlers through the module runner.
      // Return a function so our middleware runs AFTER Vite's internal middleware
      // (this way Vite handles client assets, we handle backend routes).
      return async () => {
        // Import the boot module through the hatk environment's runner
        const env = server.environments.hatk
        if (!env || !('runner' in env)) {
          console.error('[hatk] hatk environment not available')
          return
        }

        // Load the hatk boot module — this initializes DB, indexer, OAuth, scans server/
        const mainPath = resolve(import.meta.dirname!, 'dev-entry.js')
        const mod = await (env as any).runner.import(mainPath)
        handler = mod.handler

        // Mount hatk as middleware for backend routes
        server.middlewares.use(async (req, res, next) => {
          const url = new URL(req.url!, `http://localhost:${devPort}`)

          // Only handle backend routes — let Vite handle everything else
          const isBackend =
            url.pathname.startsWith('/xrpc/') ||
            url.pathname.startsWith('/oauth/') ||
            url.pathname.startsWith('/.well-known/') ||
            url.pathname.startsWith('/og/') ||
            url.pathname.startsWith('/admin') ||
            url.pathname === '/_health' ||
            url.pathname === '/info' ||
            url.pathname === '/repos' ||
            url.pathname === '/robots.txt'

          if (!isBackend || !handler) {
            next()
            return
          }

          try {
            const { toRequest, sendResponse } = await import('./adapter.js')
            const request = toRequest(req, `http://localhost:${devPort}`)
            const response = await handler(request)
            await sendResponse(res, response)
          } catch (err: any) {
            console.error('[hatk]', err.message)
            next(err)
          }
        })
      }
    },

    // Handle HMR for server/ files in the hatk environment
    hotUpdate({ file, server, modules }) {
      if (file.includes('/server/')) {
        // Invalidate modules in the hatk environment
        const env = server.environments.hatk
        if (env) {
          for (const mod of modules) {
            env.moduleGraph.invalidateModule(mod)
          }
          // Re-import the entry to pick up changes
          // The handler reference will be updated on next request
        }
      }
    },

    // Two-stage production build
    async buildApp(builder) {
      // Stage 1: Build client
      await builder.build(builder.environments.client)
      // Stage 2: Build hatk server
      await builder.build(builder.environments.hatk)
    },
  }
}
```

**Step 3: Create dev-entry.ts**

This is the entry module loaded by the module runner in dev mode. It boots infrastructure and exports the handler.

Create `packages/hatk/src/dev-entry.ts`:

```typescript
/**
 * Dev mode entry point — loaded through Vite's module runner.
 * Boots hatk infrastructure and exports the fetch handler.
 */
import { loadConfig } from './config.ts'
import { loadLexicons, storeLexicons, discoverCollections, buildSchemas } from './database/schema.ts'
import { discoverViews } from './views.ts'
import { initDatabase, migrateSchema } from './database/db.ts'
import { createAdapter } from './database/adapter-factory.ts'
import { getDialect } from './database/dialect.ts'
import { setSearchPort } from './database/fts.ts'
import { configureRelay } from './xrpc.ts'
import { initOAuth } from './oauth/server.ts'
import { initServer } from './server-init.ts'
import { createHandler } from './server.ts'
import { startIndexer } from './indexer.ts'
import { getCursor } from './database/db.ts'
import { runBackfill } from './backfill.ts'
import { rebuildAllIndexes } from './database/fts.ts'
import { relayHttpUrl } from './config.ts'
import { validateLexicons } from '@bigmoves/lexicon'
import { log } from './logger.ts'
import { mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'

// Boot sequence (mirrors main.ts but exports handler instead of starting server)
const configPath = 'hatk.config.ts'
const configDir = dirname(resolve(configPath))

const config = await loadConfig(configPath)
configureRelay(config.relay)

const lexicons = loadLexicons(resolve(configDir, 'lexicons'))
const lexiconErrors = validateLexicons([...lexicons.values()])
if (lexiconErrors) {
  for (const [nsid, errors] of Object.entries(lexiconErrors)) {
    for (const err of errors) console.error(`Invalid lexicon ${nsid}: ${err}`)
  }
  throw new Error('Invalid lexicons')
}
storeLexicons(lexicons)

const collections = config.collections.length > 0 ? config.collections : discoverCollections(lexicons)
discoverViews()

const engineDialect = getDialect(config.databaseEngine)
const { schemas, ddlStatements } = buildSchemas(lexicons, collections, engineDialect)

if (config.database !== ':memory:') {
  mkdirSync(dirname(config.database), { recursive: true })
}
const { adapter, searchPort } = await createAdapter(config.databaseEngine)
setSearchPort(searchPort)
await initDatabase(adapter, config.database, schemas, ddlStatements)
await migrateSchema(schemas)

// Initialize handlers from server/ directory
await initServer(resolve(configDir, 'server'))

if (config.oauth) {
  await initOAuth(config.oauth, config.plc, config.relay)
}

// Start indexer
const collectionSet = new Set(collections)
const cursor = await getCursor('relay')
startIndexer({
  relayUrl: config.relay,
  collections: collectionSet,
  signalCollections: config.backfill.signalCollections ? new Set(config.backfill.signalCollections) : undefined,
  pinnedRepos: config.backfill.repos ? new Set(config.backfill.repos) : undefined,
  cursor,
  fetchTimeout: config.backfill.fetchTimeout,
  maxRetries: config.backfill.maxRetries,
  parallelism: config.backfill.parallelism,
  ftsRebuildInterval: config.ftsRebuildInterval,
})

// Run backfill in background (no restart in dev mode)
runBackfill({
  pdsUrl: relayHttpUrl(config.relay),
  plcUrl: config.plc,
  collections: collectionSet,
  config: config.backfill,
}).then(() => rebuildAllIndexes(Array.from(collectionSet)))
  .catch((err) => console.error('[backfill]', err.message))

// Export the handler for Vite middleware
export const handler = createHandler({
  collections: Array.from(collectionSet),
  publicDir: null, // Vite serves static assets in dev
  oauth: config.oauth,
  admins: config.admins,
})

log(`[hatk] Dev server ready`)
log(`  Relay: ${config.relay}`)
log(`  Database: ${config.database}`)
log(`  Collections: ${collections.join(', ')}`)
```

**Step 4: Update main.ts for production**

Modify `packages/hatk/src/main.ts` to use `createHandler` + `serve` adapter:

Replace the `startServer(...)` call near the end with:

```typescript
import { createHandler } from './server.ts'
import { serve } from './adapter.ts'

const handler = createHandler({
  collections,
  publicDir: config.publicDir,
  oauth: config.oauth,
  admins: config.admins,
  onResync: runBackfillAndRestart,
})

serve(handler, config.port)
```

**Step 5: Verify build**

Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit`
Expected: No errors

**Step 6: Commit**

```bash
git add packages/hatk/src/vite-plugin.ts packages/hatk/src/dev-entry.ts packages/hatk/src/main.ts
git commit -m "feat: rewrite Vite plugin with Environment API and dev entry"
```

---

### Task 6: Update Vite peer dependency

hatk needs to declare Vite 8 as a peer dependency.

**Files:**
- Modify: `packages/hatk/package.json`

**Step 1: Update peer dependency**

Add to package.json:
```json
"peerDependencies": {
  "vite": "^8.0.0"
}
```

Move `vite` from `devDependencies` to `peerDependencies`. Keep it in `devDependencies` as well for development.

**Step 2: Commit**

```bash
git add packages/hatk/package.json
git commit -m "feat: require Vite 8 as peer dependency"
```

---

### Task 7: Test with statusphere template

Validate the full dev and SSR flow by converting the statusphere template.

**Files:**
- Work in: `/Users/chadmiller/code/hatk-template-statusphere`

**Step 1: Add entry-server and entry-client (if using SSR)**

For now, statusphere can skip SSR and just validate the dev mode works without tsx watch:

- Ensure `server/` directory exists (from Task 11-12 of prior plan)
- Ensure `vite.config.ts` uses `hatk()` plugin
- Run `npm run dev` and verify:
  - No child process spawned
  - XRPC routes work
  - Feeds work
  - OAuth works
  - Frontend hot reloads
  - Editing a server/ file reloads the handler

**Step 2: Test SSR (optional)**

If we want to validate SSR with React or Svelte:

1. Add `src/entry-server.tsx` and `src/entry-client.tsx`
2. Add `server/render.tsx` with `defineRenderer`
3. Add `<!--ssr-outlet-->` to `index.html`
4. Verify server-rendered HTML appears on initial load
5. Verify client hydration takes over

**Step 3: Test production build**

```bash
npm run build
node dist/server/index.js
```

Verify: static assets served, API routes work, SSR renders (if configured).

---

### Task 8: Test with teal template

Same as Task 7 but for the more complex teal template. Validates:
- Multiple feeds
- Complex XRPC queries
- OG image generation
- Setup scripts
- Label definitions

**This task is validation only — no new code, just running the templates against the new server.**