AppView in a box as a Vite plugin thing hatk.dev
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: add design specs and plans for server-directory and Vite SSR

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+2092
+866
docs/superpowers/plans/2026-03-14-server-directory.md
··· 1 + # Server Directory Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Consolidate all server-side code (feeds, xrpc, hooks, labels, og, setup) into a single `server/` directory scanned by export type, with Vite SSR HMR for handler code. 6 + 7 + **Architecture:** A new `scanner.ts` module recursively walks `server/`, imports each file, inspects default exports for type tags (`__type: 'feed' | 'query' | 'procedure' | ...`), and routes them to existing subsystem registration. The Vite plugin replaces the `tsx watch` child process with `ssrLoadModule()` for true HMR. Define functions that don't exist yet (`defineSetup`, `defineHook`, `defineLabels`, `defineOG`) are added as thin typed wrappers. 8 + 9 + **Tech Stack:** TypeScript, Vite SSR API (`ssrLoadModule`), existing hatk subsystems 10 + 11 + **Working directory:** `/Users/chadmiller/code/hatk/.worktrees/server-directory` 12 + 13 + --- 14 + 15 + ### Task 1: Add type tags to existing define functions 16 + 17 + The scanner needs to distinguish what kind of handler a file exports. We add a `__type` property to the objects returned by `defineFeed`, `defineQuery`, and `defineProcedure`. 18 + 19 + **Files:** 20 + - Modify: `packages/hatk/src/feeds.ts:136-138` 21 + - Modify: `packages/hatk/src/cli.ts:1666-1677` (the generated defineQuery/defineProcedure) 22 + 23 + **Step 1: Add `__type` to defineFeed** 24 + 25 + In `packages/hatk/src/feeds.ts`, change the `defineFeed` function at line 136: 26 + 27 + ```typescript 28 + export function defineFeed(opts: FeedOpts) { 29 + return { __type: 'feed' as const, ...opts, generate: (ctx: any) => opts.generate({ ...ctx, ok: (v: any) => v }) } 30 + } 31 + ``` 32 + 33 + **Step 2: Add `__type` to generated defineQuery and defineProcedure** 34 + 35 + In `packages/hatk/src/cli.ts`, update the code generation (around line 1666) so the emitted `defineQuery` returns `{ __type: 'query', nsid, handler }` and `defineProcedure` returns `{ __type: 'procedure', nsid, handler }`: 36 + 37 + Change the `defineQuery` template from: 38 + ```typescript 39 + out += ` return { handler: (ctx: any) => handler({ ...ctx, ok: (v: any) => v }) }\n` 40 + ``` 41 + to: 42 + ```typescript 43 + out += ` return { __type: 'query' as const, nsid, handler: (ctx: any) => handler({ ...ctx, ok: (v: any) => v }) }\n` 44 + ``` 45 + 46 + Same for `defineProcedure` — change to: 47 + ```typescript 48 + out += ` return { __type: 'procedure' as const, nsid, handler: (ctx: any) => handler({ ...ctx, ok: (v: any) => v }) }\n` 49 + ``` 50 + 51 + **Step 3: Verify build** 52 + 53 + Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit` 54 + Expected: No errors 55 + 56 + **Step 4: Commit** 57 + 58 + ```bash 59 + git add packages/hatk/src/feeds.ts packages/hatk/src/cli.ts 60 + git commit -m "feat: add __type tags to defineFeed/defineQuery/defineProcedure" 61 + ``` 62 + 63 + --- 64 + 65 + ### Task 2: Create defineSetup, defineHook, defineLabels, defineOG 66 + 67 + New thin define functions for handler types that currently use raw exports. 68 + 69 + **Files:** 70 + - Modify: `packages/hatk/src/setup.ts` (add `defineSetup`) 71 + - Modify: `packages/hatk/src/hooks.ts` (add `defineHook`) 72 + - Modify: `packages/hatk/src/labels.ts` (add `defineLabels`) 73 + - Modify: `packages/hatk/src/opengraph.ts` (add `defineOG`) 74 + 75 + **Step 1: Add `defineSetup` to setup.ts** 76 + 77 + Add after the `SetupContext` interface (after line 43): 78 + 79 + ```typescript 80 + export type SetupHandler = (ctx: SetupContext) => Promise<void> 81 + 82 + export function defineSetup(handler: SetupHandler) { 83 + return { __type: 'setup' as const, handler } 84 + } 85 + ``` 86 + 87 + **Step 2: Add `defineHook` to hooks.ts** 88 + 89 + Add after the `OnLoginCtx` type (after line 36): 90 + 91 + ```typescript 92 + export function defineHook(event: 'on-login', handler: (ctx: OnLoginCtx) => Promise<void>) { 93 + return { __type: 'hook' as const, event, handler } 94 + } 95 + ``` 96 + 97 + **Step 3: Add `defineLabels` to labels.ts** 98 + 99 + First read the `LabelDefinition` type from `config.ts`. Add after the `LabelRuleContext` interface (after line 48): 100 + 101 + ```typescript 102 + export interface LabelModule { 103 + definition?: LabelDefinition 104 + evaluate?: (ctx: LabelRuleContext) => Promise<string[]> 105 + } 106 + 107 + export function defineLabels(module: LabelModule) { 108 + return { __type: 'labels' as const, ...module } 109 + } 110 + ``` 111 + 112 + **Step 4: Add `defineOG` to opengraph.ts** 113 + 114 + Add after the `OpengraphContext` interface (after line 39). First read the full file to find `OpengraphResult` type, then add: 115 + 116 + ```typescript 117 + export function defineOG( 118 + path: string, 119 + generate: (ctx: OpengraphContext) => Promise<OpengraphResult>, 120 + ) { 121 + return { __type: 'og' as const, path, generate } 122 + } 123 + ``` 124 + 125 + **Step 5: Verify build** 126 + 127 + Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit` 128 + Expected: No errors 129 + 130 + **Step 6: Commit** 131 + 132 + ```bash 133 + git add packages/hatk/src/setup.ts packages/hatk/src/hooks.ts packages/hatk/src/labels.ts packages/hatk/src/opengraph.ts 134 + git commit -m "feat: add defineSetup, defineHook, defineLabels, defineOG" 135 + ``` 136 + 137 + --- 138 + 139 + ### Task 3: Create the server scanner module 140 + 141 + This is the core new module. It recursively walks a directory, imports each file, inspects the `__type` of the default export, and returns categorized results. 142 + 143 + **Files:** 144 + - Create: `packages/hatk/src/scanner.ts` 145 + 146 + **Step 1: Write scanner.ts** 147 + 148 + ```typescript 149 + import { resolve, relative } from 'node:path' 150 + import { readdirSync, statSync, existsSync } from 'node:fs' 151 + import { log } from './logger.ts' 152 + 153 + export interface ScannedModule { 154 + path: string 155 + name: string 156 + mod: any 157 + } 158 + 159 + export interface ScanResult { 160 + feeds: ScannedModule[] 161 + queries: ScannedModule[] 162 + procedures: ScannedModule[] 163 + hooks: ScannedModule[] 164 + setup: ScannedModule[] 165 + labels: ScannedModule[] 166 + og: ScannedModule[] 167 + unknown: ScannedModule[] 168 + } 169 + 170 + /** Recursively collect .ts/.js files, skipping _ prefixed files */ 171 + function walkDir(dir: string): string[] { 172 + const results: string[] = [] 173 + try { 174 + for (const entry of readdirSync(dir)) { 175 + if (entry.startsWith('_') || entry.startsWith('.')) continue 176 + const full = resolve(dir, entry) 177 + if (statSync(full).isDirectory()) { 178 + results.push(...walkDir(full)) 179 + } else if (entry.endsWith('.ts') || entry.endsWith('.js')) { 180 + results.push(full) 181 + } 182 + } 183 + } catch {} 184 + return results.sort() 185 + } 186 + 187 + /** 188 + * Scan a directory for hatk server modules. 189 + * Each file's default export is inspected for a `__type` tag. 190 + * Files without a __type tag are checked for legacy export shapes 191 + * (raw handler functions, objects with `generate`/`evaluate`/`path` fields). 192 + */ 193 + export async function scanServerDir(serverDir: string): Promise<ScanResult> { 194 + const result: ScanResult = { 195 + feeds: [], 196 + queries: [], 197 + procedures: [], 198 + hooks: [], 199 + setup: [], 200 + labels: [], 201 + og: [], 202 + unknown: [], 203 + } 204 + 205 + if (!existsSync(serverDir)) return result 206 + 207 + const files = walkDir(serverDir) 208 + 209 + for (const filePath of files) { 210 + const name = relative(serverDir, filePath).replace(/\.(ts|js)$/, '') 211 + const mod = await import(filePath) 212 + const exported = mod.default 213 + 214 + if (!exported) { 215 + log(`[scanner] ${name}: no default export, skipping`) 216 + continue 217 + } 218 + 219 + const entry: ScannedModule = { path: filePath, name, mod: exported } 220 + 221 + if (exported.__type) { 222 + switch (exported.__type) { 223 + case 'feed': 224 + result.feeds.push(entry) 225 + break 226 + case 'query': 227 + result.queries.push(entry) 228 + break 229 + case 'procedure': 230 + result.procedures.push(entry) 231 + break 232 + case 'hook': 233 + result.hooks.push(entry) 234 + break 235 + case 'setup': 236 + result.setup.push(entry) 237 + break 238 + case 'labels': 239 + result.labels.push(entry) 240 + break 241 + case 'og': 242 + result.og.push(entry) 243 + break 244 + default: 245 + log(`[scanner] ${name}: unknown __type '${exported.__type}'`) 246 + result.unknown.push(entry) 247 + } 248 + } else { 249 + log(`[scanner] ${name}: no __type tag, skipping`) 250 + result.unknown.push(entry) 251 + } 252 + } 253 + 254 + return result 255 + } 256 + ``` 257 + 258 + **Step 2: Verify build** 259 + 260 + Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit` 261 + Expected: No errors 262 + 263 + **Step 3: Commit** 264 + 265 + ```bash 266 + git add packages/hatk/src/scanner.ts 267 + git commit -m "feat: add server directory scanner module" 268 + ``` 269 + 270 + --- 271 + 272 + ### Task 4: Add registration functions to subsystems 273 + 274 + Each subsystem needs a function to register a handler from a scanned module, so the scanner results can be wired up without duplicating the init logic. 275 + 276 + **Files:** 277 + - Modify: `packages/hatk/src/feeds.ts` (add `registerFeed`) 278 + - Modify: `packages/hatk/src/xrpc.ts` (add `registerXrpcHandler`) 279 + - Modify: `packages/hatk/src/labels.ts` (add `registerLabelModule`) 280 + - Modify: `packages/hatk/src/opengraph.ts` (add `registerOgHandler`) 281 + - Modify: `packages/hatk/src/hooks.ts` (add `registerHook`) 282 + - Modify: `packages/hatk/src/setup.ts` (add `runSetupHandler`) 283 + 284 + **Step 1: Add `registerFeed` to feeds.ts** 285 + 286 + Read `feeds.ts` fully to understand the `FeedHandler` type and how `initFeeds` builds one from a module's default export. Add a new exported function that does the same thing for a single module: 287 + 288 + ```typescript 289 + /** Register a single feed from a scanned module. */ 290 + export function registerFeed(name: string, generator: ReturnType<typeof defineFeed>): void { 291 + const handler: FeedHandler = { 292 + name, 293 + label: generator.label || name, 294 + collection: generator.collection, 295 + view: generator.view, 296 + generate: async (params, cursor, limit, viewer) => { 297 + const paginateDeps = { db: { query: querySQL }, cursor, limit, packCursor, unpackCursor } 298 + const ctx: FeedContext = { 299 + db: { query: querySQL }, 300 + params, 301 + cursor, 302 + limit, 303 + viewer, 304 + packCursor, 305 + unpackCursor, 306 + isTakendown: isTakendownDid, 307 + filterTakendownDids, 308 + paginate: createPaginate(paginateDeps), 309 + } 310 + return generator.generate(ctx) 311 + }, 312 + hydrate: generator.hydrate 313 + ? async (items, viewer) => { 314 + const ctx = buildHydrateContext(items, viewer, querySQL, resolveRecords) 315 + return generator.hydrate!(ctx) 316 + } 317 + : undefined, 318 + } 319 + feeds.set(name, handler) 320 + } 321 + ``` 322 + 323 + Note: Copy the handler construction logic from `initFeeds` exactly — read the file to get the full context including `paginate` construction and `hydrate` wrapping. 324 + 325 + **Step 2: Add `registerXrpcHandler` to xrpc.ts** 326 + 327 + Read `xrpc.ts` fully to understand how `initXrpc` registers a handler with its NSID. Add: 328 + 329 + ```typescript 330 + /** Register a single XRPC handler from a scanned module. */ 331 + export function registerXrpcHandler(nsid: string, handlerModule: { handler: Function }): void { 332 + // Use the same registration logic as initXrpc — look up lexicon params, wrap handler 333 + const handler = handlerModule.handler 334 + xrpcHandlers.set(nsid, { nsid, handler, params: extractLexiconParams(nsid) }) 335 + } 336 + ``` 337 + 338 + The exact shape depends on what `initXrpc` does internally — read the file and mirror the logic. 339 + 340 + **Step 3: Add `registerLabelModule` to labels.ts** 341 + 342 + ```typescript 343 + /** Register a single label module from a scanned module. */ 344 + export function registerLabelModule(name: string, labelMod: LabelModule): void { 345 + if (labelMod.definition) { 346 + labelDefs.push(labelMod.definition) 347 + } 348 + if (labelMod.evaluate) { 349 + rules.push({ name, evaluate: labelMod.evaluate }) 350 + } 351 + } 352 + ``` 353 + 354 + **Step 4: Add `registerOgHandler` to opengraph.ts** 355 + 356 + Read `opengraph.ts` fully to understand how handlers are stored and matched. Add a function that registers a single OG handler with its compiled path pattern. 357 + 358 + **Step 5: Add `registerHook` to hooks.ts** 359 + 360 + ```typescript 361 + /** Register a hook from a scanned module. */ 362 + export function registerHook(event: string, handler: Function): void { 363 + if (event === 'on-login') { 364 + onLoginHook = handler as OnLoginHook 365 + log('[hooks] on-login hook registered') 366 + } 367 + } 368 + ``` 369 + 370 + **Step 6: Add `runSetupHandler` to setup.ts** 371 + 372 + ```typescript 373 + /** Run a single setup handler with a SetupContext. */ 374 + export async function runSetupHandler(name: string, handler: SetupHandler): Promise<void> { 375 + const ctx: SetupContext = { 376 + db: { query: querySQL, run: runSQL, runBatch, createBulkInserter: createBulkInserterSQL }, 377 + } 378 + log(`[setup] running: ${name}`) 379 + await handler(ctx) 380 + log(`[setup] done: ${name}`) 381 + } 382 + ``` 383 + 384 + **Step 7: Verify build** 385 + 386 + Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit` 387 + Expected: No errors 388 + 389 + **Step 8: Commit** 390 + 391 + ```bash 392 + git add packages/hatk/src/feeds.ts packages/hatk/src/xrpc.ts packages/hatk/src/labels.ts packages/hatk/src/opengraph.ts packages/hatk/src/hooks.ts packages/hatk/src/setup.ts 393 + git commit -m "feat: add register functions to each subsystem for scanner integration" 394 + ``` 395 + 396 + --- 397 + 398 + ### Task 5: Create `initServer` that ties scanner to subsystems 399 + 400 + This replaces the 6 individual init calls in `main.ts` with one call. 401 + 402 + **Files:** 403 + - Create: `packages/hatk/src/server-init.ts` 404 + 405 + **Step 1: Write server-init.ts** 406 + 407 + ```typescript 408 + import { resolve } from 'node:path' 409 + import { existsSync } from 'node:fs' 410 + import { log } from './logger.ts' 411 + import { scanServerDir } from './scanner.ts' 412 + import { registerFeed, listFeeds } from './feeds.ts' 413 + import { registerXrpcHandler, listXrpc } from './xrpc.ts' 414 + import { registerLabelModule, getLabelDefinitions } from './labels.ts' 415 + import { registerOgHandler } from './opengraph.ts' 416 + import { registerHook } from './hooks.ts' 417 + import { runSetupHandler } from './setup.ts' 418 + 419 + /** 420 + * Scan the server/ directory and register all discovered handlers. 421 + * Setup scripts run immediately (in sorted order). 422 + * Returns the scan result for logging. 423 + */ 424 + export async function initServer(serverDir: string): Promise<void> { 425 + if (!existsSync(serverDir)) { 426 + log(`[server] No server/ directory found, skipping`) 427 + return 428 + } 429 + 430 + const scanned = await scanServerDir(serverDir) 431 + 432 + // 1. Run setup scripts first (sorted by name) 433 + for (const entry of scanned.setup.sort((a, b) => a.name.localeCompare(b.name))) { 434 + await runSetupHandler(entry.name, entry.mod.handler) 435 + } 436 + 437 + // 2. Register feeds 438 + for (const entry of scanned.feeds) { 439 + const feedName = entry.mod.label ? entry.name : entry.name 440 + registerFeed(feedName, entry.mod) 441 + log(`[server] Feed registered: ${feedName}`) 442 + } 443 + 444 + // 3. Register XRPC handlers 445 + for (const entry of scanned.queries) { 446 + registerXrpcHandler(entry.mod.nsid, entry.mod) 447 + log(`[server] Query registered: ${entry.mod.nsid}`) 448 + } 449 + for (const entry of scanned.procedures) { 450 + registerXrpcHandler(entry.mod.nsid, entry.mod) 451 + log(`[server] Procedure registered: ${entry.mod.nsid}`) 452 + } 453 + 454 + // 4. Register hooks 455 + for (const entry of scanned.hooks) { 456 + registerHook(entry.mod.event, entry.mod.handler) 457 + } 458 + 459 + // 5. Register labels 460 + for (const entry of scanned.labels) { 461 + registerLabelModule(entry.name, entry.mod) 462 + } 463 + 464 + // 6. Register OG handlers 465 + for (const entry of scanned.og) { 466 + registerOgHandler(entry.mod) 467 + } 468 + 469 + log(`[server] Initialized from server/ directory:`) 470 + log(` Feeds: ${listFeeds().map((f) => f.name).join(', ') || 'none'}`) 471 + log(` XRPC: ${listXrpc().join(', ') || 'none'}`) 472 + log(` Labels: ${getLabelDefinitions().length} definitions`) 473 + } 474 + ``` 475 + 476 + **Step 2: Verify build** 477 + 478 + Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit` 479 + Expected: No errors 480 + 481 + **Step 3: Commit** 482 + 483 + ```bash 484 + git add packages/hatk/src/server-init.ts 485 + git commit -m "feat: add initServer to tie scanner to subsystem registration" 486 + ``` 487 + 488 + --- 489 + 490 + ### Task 6: Wire `initServer` into main.ts 491 + 492 + Replace the individual init calls with `initServer()`, keeping backward compatibility with the old directory-based init calls as a fallback. 493 + 494 + **Files:** 495 + - Modify: `packages/hatk/src/main.ts` 496 + 497 + **Step 1: Add server/ directory scanning** 498 + 499 + In `main.ts`, add the import: 500 + ```typescript 501 + import { initServer } from './server-init.ts' 502 + ``` 503 + 504 + Then, after the setup/schema section (around line 97), replace the block from `initSetup` through `initLabels` (lines 97-142) with: 505 + 506 + ```typescript 507 + // 3b. Initialize from server/ directory (or fall back to legacy directories) 508 + const serverDir = resolve(configDir, 'server') 509 + if (existsSync(serverDir)) { 510 + await initServer(serverDir) 511 + } else { 512 + // Legacy: separate directories 513 + await initSetup(resolve(configDir, 'setup')) 514 + await initFeeds(resolve(configDir, 'feeds')) 515 + await initXrpc(resolve(configDir, 'xrpc')) 516 + await initOpengraph(resolve(configDir, 'og')) 517 + await initLabels(resolve(configDir, 'labels')) 518 + await loadOnLoginHook(resolve(configDir, 'hooks')) 519 + } 520 + ``` 521 + 522 + Add `existsSync` import from `node:fs` (it's already imported on line 1 as `mkdirSync, writeFileSync` — add `existsSync`). 523 + 524 + **Step 2: Move schema.sql write after initServer** 525 + 526 + The schema.sql write (lines 100-109) should remain after initServer since setup scripts may create tables. 527 + 528 + **Step 3: Verify build** 529 + 530 + Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit` 531 + Expected: No errors 532 + 533 + **Step 4: Commit** 534 + 535 + ```bash 536 + git add packages/hatk/src/main.ts 537 + git commit -m "feat: wire initServer into main.ts with legacy fallback" 538 + ``` 539 + 540 + --- 541 + 542 + ### Task 7: Export new define functions from package 543 + 544 + Users need to import `defineSetup`, `defineHook`, `defineLabels`, `defineOG` from the hatk package. 545 + 546 + **Files:** 547 + - Modify: `packages/hatk/package.json` (add exports if needed) 548 + - Modify: `packages/hatk/src/cli.ts` (update `hatk.generated.ts` code generation to re-export new defines) 549 + 550 + **Step 1: Update generated file template in cli.ts** 551 + 552 + Find where `hatk.generated.ts` emits its imports and re-exports. Add re-exports for the new define functions: 553 + 554 + ```typescript 555 + out += `export { defineSetup } from '@hatk/hatk/setup'\n` 556 + out += `export { defineHook } from '@hatk/hatk/hooks'\n` 557 + out += `export { defineLabels } from '@hatk/hatk/labels'\n` 558 + out += `export { defineOG } from '@hatk/hatk/opengraph'\n` 559 + ``` 560 + 561 + These should go near the existing re-exports (around the XRPC Helpers section, line 1654-1656). 562 + 563 + **Step 2: Add `./hooks` export to package.json if missing** 564 + 565 + Check `packages/hatk/package.json` exports. If `./hooks` isn't listed, add it. Same for `./setup`, `./labels`, `./opengraph` — most of these already exist. 566 + 567 + **Step 3: Verify build** 568 + 569 + Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit` 570 + Expected: No errors 571 + 572 + **Step 4: Commit** 573 + 574 + ```bash 575 + git add packages/hatk/src/cli.ts packages/hatk/package.json 576 + git commit -m "feat: export new define functions from hatk.generated.ts" 577 + ``` 578 + 579 + --- 580 + 581 + ### Task 8: Replace `tsx watch` with Vite SSR in the plugin 582 + 583 + This is the biggest change — the Vite plugin no longer spawns a child process. Instead, it boots hatk's core runtime and uses `ssrLoadModule()` for handler code. 584 + 585 + **Files:** 586 + - Modify: `packages/hatk/src/vite-plugin.ts` 587 + 588 + **Step 1: Understand the current plugin** 589 + 590 + Read `packages/hatk/src/vite-plugin.ts` completely. Currently it: 591 + - Spawns `npx tsx watch` with `main.js` as the entry 592 + - Proxies backend routes to the child process 593 + - Kills child on server close 594 + 595 + **Step 2: Rewrite configureServer hook** 596 + 597 + Replace the `spawn`-based approach with Vite SSR module loading: 598 + 599 + ```typescript 600 + async configureServer(server) { 601 + // Boot hatk core runtime (database, indexer, OAuth) once 602 + const mainModule = await server.ssrLoadModule( 603 + resolve(import.meta.dirname!, 'main.js') 604 + ) 605 + // The main module's side effects boot the runtime 606 + 607 + // For HMR: watch server/ directory and re-scan on changes 608 + server.watcher.on('change', async (file) => { 609 + if (file.includes('/server/')) { 610 + // Invalidate the module in Vite's module graph 611 + const mod = server.moduleGraph.getModuleById(file) 612 + if (mod) { 613 + server.moduleGraph.invalidateModule(mod) 614 + } 615 + // Re-scan and re-register handlers 616 + // ... (call initServer again) 617 + } 618 + }) 619 + } 620 + ``` 621 + 622 + **Important considerations:** 623 + - `main.ts` currently does `process.exit(1)` after backfill — that needs to be skipped in dev mode (already handled by `DEV_MODE` env) 624 + - The proxy rules can be removed since the server runs in-process 625 + - Need to handle the case where ssrLoadModule resolves the module but hatk's main.ts is designed as a script, not a module that exports anything 626 + 627 + **Step 3: Alternative approach — middleware mode** 628 + 629 + A simpler approach that preserves more of the current architecture: instead of running main.ts through ssrLoadModule, have the Vite plugin register middleware that intercepts backend routes and passes them to hatk's server handler directly. This avoids the complexity of running the full main.ts through Vite's SSR pipeline. 630 + 631 + Read `server.ts` to understand the request handler shape. The plugin can import `startServer` or the underlying handler function and mount it as Vite middleware. 632 + 633 + **NOTE:** This task requires careful investigation of the Vite SSR API and hatk's server module. The implementing engineer should: 634 + 1. Read Vite's SSR documentation on `ssrLoadModule` and `server.middlewares` 635 + 2. Read `packages/hatk/src/server.ts` fully to understand the request handler 636 + 3. Decide between full SSR module loading vs middleware mounting 637 + 4. The key goal is: edits to files in `server/` should trigger re-import without restarting the database/indexer 638 + 639 + **Step 4: Verify the dev server starts** 640 + 641 + Run: `cd /path/to/template && npm run dev` 642 + Expected: Vite dev server starts, backend routes work, editing a feed handler triggers HMR 643 + 644 + **Step 5: Commit** 645 + 646 + ```bash 647 + git add packages/hatk/src/vite-plugin.ts 648 + git commit -m "feat: replace tsx watch with Vite SSR for handler HMR" 649 + ``` 650 + 651 + --- 652 + 653 + ### Task 9: Update `hatk new` CLI scaffolding 654 + 655 + The `hatk new` command scaffolds a new project. It needs to create `server/` instead of `feeds/`, `xrpc/`, `hooks/`, etc. 656 + 657 + **Files:** 658 + - Modify: `packages/hatk/src/cli.ts` (the `new` command scaffolding section) 659 + 660 + **Step 1: Find the scaffolding code** 661 + 662 + Search `cli.ts` for where it creates the project directory structure (look for `mkdirSync` calls creating `feeds/`, `xrpc/`, etc.). 663 + 664 + **Step 2: Update directory creation** 665 + 666 + Replace: 667 + ``` 668 + feeds/ 669 + xrpc/ 670 + hooks/ 671 + labels/ 672 + og/ 673 + setup/ 674 + ``` 675 + 676 + With: 677 + ``` 678 + server/ 679 + ``` 680 + 681 + **Step 3: Update template files** 682 + 683 + Update any template feed/xrpc/hook files that get scaffolded to use the new import paths and live in `server/`. 684 + 685 + **Step 4: Verify** 686 + 687 + Run: `cd /tmp && hatk new test-app` (or the equivalent CLI command) 688 + Expected: Project created with `server/` directory instead of separate directories 689 + 690 + **Step 5: Commit** 691 + 692 + ```bash 693 + git add packages/hatk/src/cli.ts 694 + git commit -m "feat: update hatk new to scaffold server/ directory" 695 + ``` 696 + 697 + --- 698 + 699 + ### Task 10: Update build output 700 + 701 + `vite build` needs to produce a server entry point alongside static assets. 702 + 703 + **Files:** 704 + - Modify: `packages/hatk/src/vite-plugin.ts` (add build config) 705 + 706 + **Step 1: Add SSR build configuration** 707 + 708 + In the `config()` hook of the Vite plugin, add build configuration: 709 + 710 + ```typescript 711 + build: { 712 + ssrManifest: true, 713 + rollupOptions: { 714 + input: { 715 + // Include main.ts as SSR entry 716 + }, 717 + }, 718 + }, 719 + ssr: { 720 + // External node modules that shouldn't be bundled 721 + external: ['better-sqlite3', '@duckdb/node-api'], 722 + }, 723 + ``` 724 + 725 + **Step 2: Add a production entry point** 726 + 727 + Create a file that boots hatk in production mode (no HMR, serves static files from dist/). 728 + 729 + **Step 3: Verify build** 730 + 731 + Run: `cd /path/to/template && npm run build && node dist/server.js` 732 + Expected: Server starts, serves static assets and handles XRPC/feed routes 733 + 734 + **Step 4: Commit** 735 + 736 + ```bash 737 + git add packages/hatk/src/vite-plugin.ts 738 + git commit -m "feat: add vite build SSR output for production" 739 + ``` 740 + 741 + --- 742 + 743 + ### Task 11: Test with statusphere template 744 + 745 + Convert the statusphere template to the new `server/` layout and verify everything works. 746 + 747 + **Files:** 748 + - Work in: `/Users/chadmiller/code/hatk-template-statusphere` 749 + 750 + **Step 1: Create server/ directory and move files** 751 + 752 + ``` 753 + server/ 754 + recent.ts ← was feeds/recent.ts 755 + get-profile.ts ← was xrpc/xyz/statusphere/getProfile.ts 756 + on-login.ts ← was hooks/on-login.ts 757 + ``` 758 + 759 + **Step 2: Update imports in moved files** 760 + 761 + Change `from '../hatk.generated.ts'` to `from '../hatk.generated.ts'` (path may change depending on new location). 762 + 763 + Update define function usage: 764 + - `recent.ts`: already uses `defineFeed` — no change needed 765 + - `get-profile.ts`: already uses `defineQuery` — no change needed 766 + - `on-login.ts`: change from raw export to `defineHook`: 767 + ```typescript 768 + import { defineHook } from '../hatk.generated.ts' 769 + 770 + export default defineHook('on-login', async ({ did, ensureRepo }) => { 771 + await ensureRepo(did) 772 + }) 773 + ``` 774 + 775 + **Step 3: Remove old directories** 776 + 777 + Delete `feeds/`, `xrpc/`, `hooks/` directories. 778 + 779 + **Step 4: Test** 780 + 781 + Run: `npm run dev` 782 + Expected: App starts, feeds work, XRPC queries work, login hook fires 783 + 784 + **Step 5: Commit in template repo** 785 + 786 + ```bash 787 + git add -A 788 + git commit -m "feat: migrate to server/ directory layout" 789 + ``` 790 + 791 + --- 792 + 793 + ### Task 12: Test with teal template 794 + 795 + Convert the teal template — this is the more complex case with shared hydration, multiple feeds, OG generators, and setup scripts. 796 + 797 + **Files:** 798 + - Work in: `/Users/chadmiller/code/hatk-template-teal` 799 + 800 + **Step 1: Create server/ directory structure** 801 + 802 + ``` 803 + server/ 804 + feeds/ 805 + recent.ts 806 + actor.ts 807 + artist.ts 808 + bookmarks.ts 809 + following.ts 810 + genre.ts 811 + release.ts 812 + track.ts 813 + _hydrate.ts ← shared helper, _ prefix means scanner skips it 814 + xrpc/ 815 + getActorProfile.ts 816 + getPlay.ts 817 + getStats.ts 818 + getTrendingArtists.ts 819 + searchArtists.ts 820 + ... (all other handlers) 821 + og/ 822 + artist.ts 823 + release.ts 824 + track.ts 825 + setup/ 826 + import-genres.ts 827 + on-login.ts 828 + ``` 829 + 830 + **Step 2: Update imports and define functions** 831 + 832 + - Feed files: update relative import paths, already use `defineFeed` 833 + - XRPC files: update relative import paths, already use `defineQuery` 834 + - OG files: wrap with `defineOG`: 835 + ```typescript 836 + import { defineOG } from '../../hatk.generated.ts' 837 + 838 + export default defineOG('/og/artist/:artist', async (ctx) => { 839 + // ... existing generate logic 840 + }) 841 + ``` 842 + - Setup files: wrap with `defineSetup`: 843 + ```typescript 844 + import { defineSetup } from '../../hatk.generated.ts' 845 + 846 + export default defineSetup(async (ctx) => { 847 + // ... existing handler logic 848 + }) 849 + ``` 850 + - Hook: wrap with `defineHook` 851 + 852 + **Step 3: Remove old directories** 853 + 854 + Delete `feeds/`, `xrpc/`, `hooks/`, `og/`, `setup/` at project root. 855 + 856 + **Step 4: Test** 857 + 858 + Run: `npm run dev` 859 + Expected: All feeds, queries, OG images, labels, setup scripts work as before 860 + 861 + **Step 5: Commit in template repo** 862 + 863 + ```bash 864 + git add -A 865 + git commit -m "feat: migrate to server/ directory layout" 866 + ```
+900
docs/superpowers/plans/2026-03-14-vite-ssr.md
··· 1 + # Vite SSR & Environment API Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **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. 6 + 7 + **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. 8 + 9 + **Tech Stack:** Vite 8 Environment API, Web Standard Request/Response, TypeScript 10 + 11 + **Working directory:** `/Users/chadmiller/code/hatk/.worktrees/server-directory` 12 + 13 + --- 14 + 15 + ### Task 1: Node.js Request/Response adapter 16 + 17 + Create the ~50 line bridge between Node.js HTTP and Web Standard APIs. This is the foundation everything else builds on. 18 + 19 + **Files:** 20 + - Create: `packages/hatk/src/adapter.ts` 21 + 22 + **Step 1: Write adapter.ts** 23 + 24 + ```typescript 25 + import { type IncomingMessage, type ServerResponse, createServer } from 'node:http' 26 + 27 + /** 28 + * Convert a Node.js IncomingMessage to a Web Standard Request. 29 + */ 30 + export function toRequest(req: IncomingMessage, base: string): Request { 31 + const url = new URL(req.url!, base) 32 + const headers = new Headers() 33 + for (const [key, value] of Object.entries(req.headers)) { 34 + if (value) { 35 + if (Array.isArray(value)) { 36 + for (const v of value) headers.append(key, v) 37 + } else { 38 + headers.set(key, value) 39 + } 40 + } 41 + } 42 + 43 + const init: RequestInit = { 44 + method: req.method, 45 + headers, 46 + } 47 + 48 + // GET and HEAD requests cannot have a body 49 + if (req.method !== 'GET' && req.method !== 'HEAD') { 50 + // @ts-expect-error — Node.js streams are valid body sources 51 + init.body = req 52 + init.duplex = 'half' 53 + } 54 + 55 + return new Request(url.href, init) 56 + } 57 + 58 + /** 59 + * Pipe a Web Standard Response back to a Node.js ServerResponse. 60 + */ 61 + export async function sendResponse(res: ServerResponse, response: Response): Promise<void> { 62 + res.writeHead(response.status, Object.fromEntries(response.headers.entries())) 63 + 64 + if (!response.body) { 65 + res.end() 66 + return 67 + } 68 + 69 + const reader = response.body.getReader() 70 + try { 71 + while (true) { 72 + const { done, value } = await reader.read() 73 + if (done) break 74 + res.write(value) 75 + } 76 + } finally { 77 + reader.releaseLock() 78 + res.end() 79 + } 80 + } 81 + 82 + /** 83 + * Create a Node.js HTTP server from a Web Standard fetch handler. 84 + */ 85 + export function serve( 86 + handler: (request: Request) => Promise<Response>, 87 + port: number, 88 + base?: string, 89 + ) { 90 + const origin = base || `http://localhost:${port}` 91 + const server = createServer(async (req, res) => { 92 + try { 93 + const request = toRequest(req, origin) 94 + const response = await handler(request) 95 + await sendResponse(res, response) 96 + } catch (err: any) { 97 + if (!res.headersSent) { 98 + res.writeHead(500, { 'Content-Type': 'application/json' }) 99 + } 100 + res.end(JSON.stringify({ error: err.message })) 101 + } 102 + }) 103 + server.listen(port) 104 + return server 105 + } 106 + ``` 107 + 108 + **Step 2: Verify build** 109 + 110 + Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit` 111 + Expected: No errors 112 + 113 + **Step 3: Commit** 114 + 115 + ```bash 116 + git add packages/hatk/src/adapter.ts 117 + git commit -m "feat: add Node.js Request/Response adapter" 118 + ``` 119 + 120 + --- 121 + 122 + ### Task 2: Response helper functions 123 + 124 + Create the utility functions that the rewritten server will use. These replace the old `jsonResponse`/`jsonError`/`sendJson` functions. 125 + 126 + **Files:** 127 + - Create: `packages/hatk/src/response.ts` 128 + 129 + **Step 1: Write response.ts** 130 + 131 + ```typescript 132 + import { gzipSync } from 'node:zlib' 133 + import { normalizeValue } from './database/db.ts' 134 + 135 + /** 136 + * Create a JSON Response with optional gzip compression. 137 + * Mirrors the old jsonResponse/sendJson behavior. 138 + */ 139 + export function json(data: unknown, status = 200, acceptEncoding?: string | null): Response { 140 + const body = Buffer.from(JSON.stringify(data, (_, v) => normalizeValue(v))) 141 + 142 + if (body.length > 1024 && acceptEncoding && /\bgzip\b/.test(acceptEncoding)) { 143 + const compressed = gzipSync(body) 144 + return new Response(compressed, { 145 + status, 146 + headers: { 147 + 'Content-Type': 'application/json', 148 + 'Content-Encoding': 'gzip', 149 + 'Vary': 'Accept-Encoding', 150 + ...(status === 200 ? { 'Cache-Control': 'no-store' } : {}), 151 + }, 152 + }) 153 + } 154 + 155 + return new Response(body, { 156 + status, 157 + headers: { 158 + 'Content-Type': 'application/json', 159 + ...(status === 200 ? { 'Cache-Control': 'no-store' } : {}), 160 + }, 161 + }) 162 + } 163 + 164 + /** Create a JSON error Response. */ 165 + export function jsonError(status: number, message: string, acceptEncoding?: string | null): Response { 166 + return json({ error: message }, status, acceptEncoding) 167 + } 168 + 169 + /** CORS preflight Response. */ 170 + export function cors(): Response { 171 + return new Response(null, { 172 + status: 200, 173 + headers: { 174 + 'Access-Control-Allow-Origin': '*', 175 + 'Access-Control-Allow-Headers': '*', 176 + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 177 + }, 178 + }) 179 + } 180 + 181 + /** Add CORS headers to an existing Response. */ 182 + export function withCors(response: Response): Response { 183 + const headers = new Headers(response.headers) 184 + headers.set('Access-Control-Allow-Origin', '*') 185 + headers.set('Access-Control-Allow-Headers', '*') 186 + headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') 187 + return new Response(response.body, { 188 + status: response.status, 189 + statusText: response.statusText, 190 + headers, 191 + }) 192 + } 193 + 194 + /** Create a static file Response with correct MIME type. */ 195 + export function file(content: Buffer | Uint8Array, contentType: string, cacheControl?: string): Response { 196 + return new Response(content, { 197 + status: 200, 198 + headers: { 199 + 'Content-Type': contentType, 200 + ...(cacheControl ? { 'Cache-Control': cacheControl } : {}), 201 + }, 202 + }) 203 + } 204 + 205 + /** 404 Not Found. */ 206 + export function notFound(): Response { 207 + return new Response('Not Found', { status: 404 }) 208 + } 209 + ``` 210 + 211 + **Step 2: Verify build** 212 + 213 + Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit` 214 + Expected: No errors 215 + 216 + **Step 3: Commit** 217 + 218 + ```bash 219 + git add packages/hatk/src/response.ts 220 + git commit -m "feat: add Web Standard Response helper functions" 221 + ``` 222 + 223 + --- 224 + 225 + ### Task 3: Rewrite server.ts to Request → Response 226 + 227 + 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()`. 228 + 229 + **Files:** 230 + - Modify: `packages/hatk/src/server.ts` (complete rewrite) 231 + 232 + **Step 1: Understand the mapping** 233 + 234 + | Old pattern | New pattern | 235 + |---|---| 236 + | `readBody(req)` | `await request.text()` | 237 + | `readBodyRaw(req)` | `Buffer.from(await request.arrayBuffer())` | 238 + | `req.url` | `request.url` (already a full URL string) | 239 + | `req.method` | `request.method` | 240 + | `req.headers['authorization']` | `request.headers.get('authorization')` | 241 + | `jsonResponse(res, data)` | `return json(data, 200, acceptEncoding)` | 242 + | `jsonError(res, 400, 'msg')` | `return jsonError(400, 'msg', acceptEncoding)` | 243 + | `res.writeHead(200, {...}); res.end(buf)` | `return file(buf, 'image/png', 'public, max-age=300')` | 244 + | `url.searchParams.get(...)` | Same (URL is constructed from request.url) | 245 + 246 + **Step 2: Rewrite the file** 247 + 248 + The new structure: 249 + 250 + ```typescript 251 + import { json, jsonError, cors, withCors, file, notFound } from './response.ts' 252 + // ... existing imports minus createServer, IncomingMessage, gzipSync 253 + 254 + export interface HandlerConfig { 255 + collections: string[] 256 + publicDir: string | null 257 + oauth: OAuthConfig | null 258 + admins: string[] 259 + renderer?: (request: Request, manifest: any) => Promise<{ html: string; head?: string }> 260 + resolveViewer?: (request: Request) => { did: string } | null 261 + onResync?: () => void 262 + } 263 + 264 + /** 265 + * Create a Web Standard request handler for all hatk routes. 266 + * Returns a pure function: (Request) → Promise<Response> 267 + */ 268 + export function createHandler(config: HandlerConfig): (request: Request) => Promise<Response> { 269 + const { collections, publicDir, oauth, admins } = config 270 + const devMode = process.env.DEV_MODE === '1' 271 + const coreXrpc = (method: string) => `/xrpc/dev.hatk.${method}` 272 + 273 + return async (request: Request): Promise<Response> => { 274 + const url = new URL(request.url) 275 + const acceptEncoding = request.headers.get('accept-encoding') 276 + 277 + // CORS preflight 278 + if (request.method === 'OPTIONS') return cors() 279 + 280 + // ... all the existing route handlers, converted to return Response objects 281 + // Each `jsonResponse(res, data)` becomes `return withCors(json(data, 200, acceptEncoding))` 282 + // Each `jsonError(res, status, msg)` becomes `return withCors(jsonError(status, msg, acceptEncoding))` 283 + 284 + return notFound() 285 + } 286 + } 287 + 288 + // Keep startServer as a thin wrapper for backward compatibility during migration 289 + export { serve } from './adapter.ts' 290 + ``` 291 + 292 + **Key conversion rules for each route:** 293 + 294 + 1. **Auth**: `req.headers['authorization']` → `request.headers.get('authorization')` 295 + 2. **Body**: `readBody(req)` → `await request.text()` 296 + 3. **Binary body**: `readBodyRaw(req)` → `Buffer.from(await request.arrayBuffer())` 297 + 4. **JSON response**: `jsonResponse(res, data); return` → `return withCors(json(data, 200, acceptEncoding))` 298 + 5. **Error**: `jsonError(res, 400, msg); return` → `return withCors(jsonError(400, msg, acceptEncoding))` 299 + 6. **Binary response**: `res.writeHead(200, ...); res.end(png)` → `return withCors(file(png, 'image/png', 'public, max-age=300'))` 300 + 7. **HTML response**: `res.writeHead(200, ...); res.end(html)` → `return withCors(file(Buffer.from(html), 'text/html'))` 301 + 8. **Static files**: Same pattern as HTML but with appropriate MIME type 302 + 9. **SPA fallback with OG meta**: Read index.html, inject OG meta, return as HTML Response 303 + 10. **Request origin**: `req.headers['x-forwarded-proto']` → `request.headers.get('x-forwarded-proto')` 304 + 305 + **Step 3: Handle the `requireAdmin` helper** 306 + 307 + Convert from mutating `res` to returning `null` (meaning auth failed): 308 + 309 + ```typescript 310 + function requireAdmin(viewer: { did: string } | null, acceptEncoding: string | null): Response | null { 311 + if (!viewer) return withCors(jsonError(401, 'Authentication required', acceptEncoding)) 312 + if (!devMode && !admins.includes(viewer.did)) return withCors(jsonError(403, 'Admin access required', acceptEncoding)) 313 + return null // auth OK 314 + } 315 + ``` 316 + 317 + Usage: `const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied;` 318 + 319 + **Step 4: Handle the viewer authentication** 320 + 321 + Convert from reading `req.headers` to reading `request.headers`: 322 + 323 + ```typescript 324 + let viewer: { did: string } | null = config.resolveViewer?.(request) ?? null 325 + if (!viewer && oauth) { 326 + try { 327 + viewer = await authenticate( 328 + request.headers.get('authorization'), 329 + request.headers.get('dpop'), 330 + request.method, 331 + `${requestOrigin}${url.pathname}`, 332 + ) 333 + } catch (err: any) { 334 + emit('oauth', 'authenticate_error', { error: err.message }) 335 + } 336 + } 337 + ``` 338 + 339 + **Step 5: Convert OAuth routes** 340 + 341 + The OAuth routes (`/oauth/par`, `/oauth/token`, `/oauth/jwks`, etc.) follow the same pattern. The key ones: 342 + 343 + - `handlePar` currently receives `(body, origin)` — keep this, just get body from `request.text()` 344 + - `handleToken` same 345 + - `handleCallback` receives `(code, iss, state)` from URL params 346 + 347 + **Step 6: Convert all remaining routes** 348 + 349 + Work through every `if (url.pathname === ...)` block systematically. The conversion is mechanical — just apply the mapping from Step 1. 350 + 351 + **Step 7: Keep `proxyToPds` and `proxyToPdsRaw` unchanged** 352 + 353 + These functions already use the fetch API internally. They return `{ ok, status, body }` objects. The caller just wraps the result in a `Response`. 354 + 355 + **Step 8: Remove the old `startServer` function** 356 + 357 + Replace it with: 358 + 359 + ```typescript 360 + export function startServer( 361 + port: number, 362 + collections: string[], 363 + publicDir: string | null, 364 + oauth: OAuthConfig | null, 365 + admins: string[] = [], 366 + resolveViewer?: (request: Request) => { did: string } | null, 367 + onResync?: () => void, 368 + ): import('node:http').Server { 369 + const handler = createHandler({ collections, publicDir, oauth, admins, resolveViewer, onResync }) 370 + return serve(handler, port) 371 + } 372 + ``` 373 + 374 + **Step 9: Verify build** 375 + 376 + Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit` 377 + Expected: No errors 378 + 379 + **Step 10: Commit** 380 + 381 + ```bash 382 + git add packages/hatk/src/server.ts 383 + git commit -m "feat: rewrite server.ts to Web Standard Request/Response" 384 + ``` 385 + 386 + --- 387 + 388 + ### Task 4: defineRenderer and SSR assembly 389 + 390 + Add the renderer define function and the logic that assembles SSR output into an HTML page. 391 + 392 + **Files:** 393 + - Create: `packages/hatk/src/renderer.ts` 394 + - Modify: `packages/hatk/src/server.ts` (add SSR rendering to SPA fallback route) 395 + 396 + **Step 1: Write renderer.ts** 397 + 398 + ```typescript 399 + import { log } from './logger.ts' 400 + 401 + export interface SSRManifest { 402 + getPreloadTags(url: string): string 403 + } 404 + 405 + export interface RenderResult { 406 + html: string 407 + head?: string 408 + } 409 + 410 + export type RendererHandler = (request: Request, manifest: SSRManifest) => Promise<RenderResult> 411 + 412 + let renderer: RendererHandler | null = null 413 + let ssrManifest: SSRManifest | null = null 414 + 415 + export function defineRenderer(handler: RendererHandler) { 416 + return { __type: 'renderer' as const, handler } 417 + } 418 + 419 + export function registerRenderer(handler: RendererHandler): void { 420 + renderer = handler 421 + log('[renderer] SSR renderer registered') 422 + } 423 + 424 + export function setSSRManifest(manifest: SSRManifest): void { 425 + ssrManifest = manifest 426 + } 427 + 428 + export function getRenderer(): RendererHandler | null { 429 + return renderer 430 + } 431 + 432 + export function getSSRManifest(): SSRManifest | null { 433 + return ssrManifest 434 + } 435 + 436 + /** 437 + * Render an HTML page by calling the user's renderer and assembling the result 438 + * into the index.html template. 439 + * 440 + * @param template - The index.html content (with <!--ssr-outlet--> placeholder) 441 + * @param request - The incoming Request 442 + * @param ogMeta - Optional OG meta tags to inject 443 + * @returns Assembled HTML string, or null if no renderer is registered 444 + */ 445 + export async function renderPage( 446 + template: string, 447 + request: Request, 448 + ogMeta?: string | null, 449 + ): Promise<string | null> { 450 + if (!renderer) return null 451 + 452 + const manifest = ssrManifest || { getPreloadTags: () => '' } 453 + const result = await renderer(request, manifest) 454 + 455 + let html = template 456 + 457 + // Inject SSR head tags (preloads, styles) 458 + if (result.head) { 459 + html = html.replace('</head>', `${result.head}\n</head>`) 460 + } 461 + 462 + // Inject OG meta tags 463 + if (ogMeta) { 464 + html = html.replace('</head>', `${ogMeta}\n</head>`) 465 + } 466 + 467 + // Inject rendered HTML into the outlet 468 + html = html.replace('<!--ssr-outlet-->', result.html) 469 + 470 + return html 471 + } 472 + ``` 473 + 474 + **Step 2: Wire renderer into server.ts SPA fallback** 475 + 476 + In the SPA fallback section of `createHandler` (the part that serves index.html), add SSR rendering: 477 + 478 + ```typescript 479 + // SSR or SPA fallback for HTML requests 480 + if (publicDir && request.headers.get('accept')?.includes('text/html')) { 481 + const template = await readFile(join(publicDir, 'index.html'), 'utf-8') 482 + const ogMeta = buildOgMeta(url.pathname, requestOrigin) 483 + 484 + // Try SSR first 485 + const renderedHtml = await renderPage(template, request, ogMeta) 486 + if (renderedHtml) { 487 + return withCors(file(Buffer.from(renderedHtml), 'text/html')) 488 + } 489 + 490 + // SPA fallback — inject OG meta only 491 + let html = template 492 + if (ogMeta) { 493 + html = html.replace('</head>', `${ogMeta}\n</head>`) 494 + } 495 + return withCors(file(Buffer.from(html), 'text/html')) 496 + } 497 + ``` 498 + 499 + **Step 3: Add renderer to scanner** 500 + 501 + Update `packages/hatk/src/scanner.ts` to handle `__type: 'renderer'` and add to ScanResult. 502 + 503 + Update `packages/hatk/src/server-init.ts` to call `registerRenderer` for scanned renderer modules. 504 + 505 + **Step 4: Export defineRenderer** 506 + 507 + Add to `packages/hatk/src/cli.ts` codegen: 508 + ``` 509 + out += `export { defineRenderer } from '@hatk/hatk/renderer'\n` 510 + ``` 511 + 512 + Add `"./renderer": "./dist/renderer.js"` to `packages/hatk/package.json` exports. 513 + 514 + **Step 5: Verify build** 515 + 516 + Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit` 517 + Expected: No errors 518 + 519 + **Step 6: Commit** 520 + 521 + ```bash 522 + 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 523 + git commit -m "feat: add defineRenderer and SSR assembly" 524 + ``` 525 + 526 + --- 527 + 528 + ### Task 5: Rewrite Vite plugin with Environment API 529 + 530 + Replace the tsx watch child process + proxy approach with Vite 8's `RunnableDevEnvironment` and middleware. 531 + 532 + **Files:** 533 + - Modify: `packages/hatk/src/vite-plugin.ts` (complete rewrite) 534 + 535 + **Step 1: Read Vite 8 Environment API types** 536 + 537 + Before writing, read: 538 + - Vite's `Plugin` type 539 + - `RunnableDevEnvironment` / `isRunnableDevEnvironment` 540 + - `DevEnvironment` and its `runner` property 541 + - How `configureServer` hooks work with environments 542 + 543 + **Step 2: Rewrite vite-plugin.ts** 544 + 545 + ```typescript 546 + import type { Plugin, ViteDevServer } from 'vite' 547 + import { resolve } from 'node:path' 548 + 549 + export function hatk(opts?: { port?: number }): Plugin { 550 + const devPort = opts?.port ?? 3000 551 + let handler: ((request: Request) => Promise<Response>) | null = null 552 + 553 + return { 554 + name: 'vite-plugin-hatk', 555 + 556 + config() { 557 + return { 558 + environments: { 559 + hatk: { 560 + // RunnableDevEnvironment for in-process module execution 561 + dev: { 562 + optimizeDeps: { 563 + // Externalize native modules 564 + exclude: ['better-sqlite3', '@duckdb/node-api'], 565 + }, 566 + }, 567 + build: { 568 + outDir: 'dist/server', 569 + ssr: true, 570 + rollupOptions: { 571 + external: ['better-sqlite3', '@duckdb/node-api'], 572 + }, 573 + }, 574 + }, 575 + }, 576 + server: { 577 + host: '127.0.0.1', 578 + port: devPort, 579 + watch: { 580 + ignored: ['**/db/**', '**/data/**'], 581 + }, 582 + }, 583 + test: { 584 + projects: [ 585 + { 586 + test: { 587 + name: 'unit', 588 + include: ['test/server/**/*.test.ts', 'test/feeds/**/*.test.ts', 'test/xrpc/**/*.test.ts'], 589 + }, 590 + }, 591 + { 592 + test: { 593 + name: 'integration', 594 + include: ['test/integration/**/*.test.ts'], 595 + }, 596 + }, 597 + ], 598 + }, 599 + } 600 + }, 601 + 602 + configureServer(server: ViteDevServer) { 603 + // Boot hatk infrastructure and load handlers through the module runner. 604 + // Return a function so our middleware runs AFTER Vite's internal middleware 605 + // (this way Vite handles client assets, we handle backend routes). 606 + return async () => { 607 + // Import the boot module through the hatk environment's runner 608 + const env = server.environments.hatk 609 + if (!env || !('runner' in env)) { 610 + console.error('[hatk] hatk environment not available') 611 + return 612 + } 613 + 614 + // Load the hatk boot module — this initializes DB, indexer, OAuth, scans server/ 615 + const mainPath = resolve(import.meta.dirname!, 'dev-entry.js') 616 + const mod = await (env as any).runner.import(mainPath) 617 + handler = mod.handler 618 + 619 + // Mount hatk as middleware for backend routes 620 + server.middlewares.use(async (req, res, next) => { 621 + const url = new URL(req.url!, `http://localhost:${devPort}`) 622 + 623 + // Only handle backend routes — let Vite handle everything else 624 + const isBackend = 625 + url.pathname.startsWith('/xrpc/') || 626 + url.pathname.startsWith('/oauth/') || 627 + url.pathname.startsWith('/.well-known/') || 628 + url.pathname.startsWith('/og/') || 629 + url.pathname.startsWith('/admin') || 630 + url.pathname === '/_health' || 631 + url.pathname === '/info' || 632 + url.pathname === '/repos' || 633 + url.pathname === '/robots.txt' 634 + 635 + if (!isBackend || !handler) { 636 + next() 637 + return 638 + } 639 + 640 + try { 641 + const { toRequest, sendResponse } = await import('./adapter.js') 642 + const request = toRequest(req, `http://localhost:${devPort}`) 643 + const response = await handler(request) 644 + await sendResponse(res, response) 645 + } catch (err: any) { 646 + console.error('[hatk]', err.message) 647 + next(err) 648 + } 649 + }) 650 + } 651 + }, 652 + 653 + // Handle HMR for server/ files in the hatk environment 654 + hotUpdate({ file, server, modules }) { 655 + if (file.includes('/server/')) { 656 + // Invalidate modules in the hatk environment 657 + const env = server.environments.hatk 658 + if (env) { 659 + for (const mod of modules) { 660 + env.moduleGraph.invalidateModule(mod) 661 + } 662 + // Re-import the entry to pick up changes 663 + // The handler reference will be updated on next request 664 + } 665 + } 666 + }, 667 + 668 + // Two-stage production build 669 + async buildApp(builder) { 670 + // Stage 1: Build client 671 + await builder.build(builder.environments.client) 672 + // Stage 2: Build hatk server 673 + await builder.build(builder.environments.hatk) 674 + }, 675 + } 676 + } 677 + ``` 678 + 679 + **Step 3: Create dev-entry.ts** 680 + 681 + This is the entry module loaded by the module runner in dev mode. It boots infrastructure and exports the handler. 682 + 683 + Create `packages/hatk/src/dev-entry.ts`: 684 + 685 + ```typescript 686 + /** 687 + * Dev mode entry point — loaded through Vite's module runner. 688 + * Boots hatk infrastructure and exports the fetch handler. 689 + */ 690 + import { loadConfig } from './config.ts' 691 + import { loadLexicons, storeLexicons, discoverCollections, buildSchemas } from './database/schema.ts' 692 + import { discoverViews } from './views.ts' 693 + import { initDatabase, migrateSchema } from './database/db.ts' 694 + import { createAdapter } from './database/adapter-factory.ts' 695 + import { getDialect } from './database/dialect.ts' 696 + import { setSearchPort } from './database/fts.ts' 697 + import { configureRelay } from './xrpc.ts' 698 + import { initOAuth } from './oauth/server.ts' 699 + import { initServer } from './server-init.ts' 700 + import { createHandler } from './server.ts' 701 + import { startIndexer } from './indexer.ts' 702 + import { getCursor } from './database/db.ts' 703 + import { runBackfill } from './backfill.ts' 704 + import { rebuildAllIndexes } from './database/fts.ts' 705 + import { relayHttpUrl } from './config.ts' 706 + import { validateLexicons } from '@bigmoves/lexicon' 707 + import { log } from './logger.ts' 708 + import { mkdirSync } from 'node:fs' 709 + import { dirname, resolve } from 'node:path' 710 + 711 + // Boot sequence (mirrors main.ts but exports handler instead of starting server) 712 + const configPath = 'hatk.config.ts' 713 + const configDir = dirname(resolve(configPath)) 714 + 715 + const config = await loadConfig(configPath) 716 + configureRelay(config.relay) 717 + 718 + const lexicons = loadLexicons(resolve(configDir, 'lexicons')) 719 + const lexiconErrors = validateLexicons([...lexicons.values()]) 720 + if (lexiconErrors) { 721 + for (const [nsid, errors] of Object.entries(lexiconErrors)) { 722 + for (const err of errors) console.error(`Invalid lexicon ${nsid}: ${err}`) 723 + } 724 + throw new Error('Invalid lexicons') 725 + } 726 + storeLexicons(lexicons) 727 + 728 + const collections = config.collections.length > 0 ? config.collections : discoverCollections(lexicons) 729 + discoverViews() 730 + 731 + const engineDialect = getDialect(config.databaseEngine) 732 + const { schemas, ddlStatements } = buildSchemas(lexicons, collections, engineDialect) 733 + 734 + if (config.database !== ':memory:') { 735 + mkdirSync(dirname(config.database), { recursive: true }) 736 + } 737 + const { adapter, searchPort } = await createAdapter(config.databaseEngine) 738 + setSearchPort(searchPort) 739 + await initDatabase(adapter, config.database, schemas, ddlStatements) 740 + await migrateSchema(schemas) 741 + 742 + // Initialize handlers from server/ directory 743 + await initServer(resolve(configDir, 'server')) 744 + 745 + if (config.oauth) { 746 + await initOAuth(config.oauth, config.plc, config.relay) 747 + } 748 + 749 + // Start indexer 750 + const collectionSet = new Set(collections) 751 + const cursor = await getCursor('relay') 752 + startIndexer({ 753 + relayUrl: config.relay, 754 + collections: collectionSet, 755 + signalCollections: config.backfill.signalCollections ? new Set(config.backfill.signalCollections) : undefined, 756 + pinnedRepos: config.backfill.repos ? new Set(config.backfill.repos) : undefined, 757 + cursor, 758 + fetchTimeout: config.backfill.fetchTimeout, 759 + maxRetries: config.backfill.maxRetries, 760 + parallelism: config.backfill.parallelism, 761 + ftsRebuildInterval: config.ftsRebuildInterval, 762 + }) 763 + 764 + // Run backfill in background (no restart in dev mode) 765 + runBackfill({ 766 + pdsUrl: relayHttpUrl(config.relay), 767 + plcUrl: config.plc, 768 + collections: collectionSet, 769 + config: config.backfill, 770 + }).then(() => rebuildAllIndexes(Array.from(collectionSet))) 771 + .catch((err) => console.error('[backfill]', err.message)) 772 + 773 + // Export the handler for Vite middleware 774 + export const handler = createHandler({ 775 + collections: Array.from(collectionSet), 776 + publicDir: null, // Vite serves static assets in dev 777 + oauth: config.oauth, 778 + admins: config.admins, 779 + }) 780 + 781 + log(`[hatk] Dev server ready`) 782 + log(` Relay: ${config.relay}`) 783 + log(` Database: ${config.database}`) 784 + log(` Collections: ${collections.join(', ')}`) 785 + ``` 786 + 787 + **Step 4: Update main.ts for production** 788 + 789 + Modify `packages/hatk/src/main.ts` to use `createHandler` + `serve` adapter: 790 + 791 + Replace the `startServer(...)` call near the end with: 792 + 793 + ```typescript 794 + import { createHandler } from './server.ts' 795 + import { serve } from './adapter.ts' 796 + 797 + const handler = createHandler({ 798 + collections, 799 + publicDir: config.publicDir, 800 + oauth: config.oauth, 801 + admins: config.admins, 802 + onResync: runBackfillAndRestart, 803 + }) 804 + 805 + serve(handler, config.port) 806 + ``` 807 + 808 + **Step 5: Verify build** 809 + 810 + Run: `cd packages/hatk && npx tsc -p tsconfig.build.json --noEmit` 811 + Expected: No errors 812 + 813 + **Step 6: Commit** 814 + 815 + ```bash 816 + git add packages/hatk/src/vite-plugin.ts packages/hatk/src/dev-entry.ts packages/hatk/src/main.ts 817 + git commit -m "feat: rewrite Vite plugin with Environment API and dev entry" 818 + ``` 819 + 820 + --- 821 + 822 + ### Task 6: Update Vite peer dependency 823 + 824 + hatk needs to declare Vite 8 as a peer dependency. 825 + 826 + **Files:** 827 + - Modify: `packages/hatk/package.json` 828 + 829 + **Step 1: Update peer dependency** 830 + 831 + Add to package.json: 832 + ```json 833 + "peerDependencies": { 834 + "vite": "^8.0.0" 835 + } 836 + ``` 837 + 838 + Move `vite` from `devDependencies` to `peerDependencies`. Keep it in `devDependencies` as well for development. 839 + 840 + **Step 2: Commit** 841 + 842 + ```bash 843 + git add packages/hatk/package.json 844 + git commit -m "feat: require Vite 8 as peer dependency" 845 + ``` 846 + 847 + --- 848 + 849 + ### Task 7: Test with statusphere template 850 + 851 + Validate the full dev and SSR flow by converting the statusphere template. 852 + 853 + **Files:** 854 + - Work in: `/Users/chadmiller/code/hatk-template-statusphere` 855 + 856 + **Step 1: Add entry-server and entry-client (if using SSR)** 857 + 858 + For now, statusphere can skip SSR and just validate the dev mode works without tsx watch: 859 + 860 + - Ensure `server/` directory exists (from Task 11-12 of prior plan) 861 + - Ensure `vite.config.ts` uses `hatk()` plugin 862 + - Run `npm run dev` and verify: 863 + - No child process spawned 864 + - XRPC routes work 865 + - Feeds work 866 + - OAuth works 867 + - Frontend hot reloads 868 + - Editing a server/ file reloads the handler 869 + 870 + **Step 2: Test SSR (optional)** 871 + 872 + If we want to validate SSR with React or Svelte: 873 + 874 + 1. Add `src/entry-server.tsx` and `src/entry-client.tsx` 875 + 2. Add `server/render.tsx` with `defineRenderer` 876 + 3. Add `<!--ssr-outlet-->` to `index.html` 877 + 4. Verify server-rendered HTML appears on initial load 878 + 5. Verify client hydration takes over 879 + 880 + **Step 3: Test production build** 881 + 882 + ```bash 883 + npm run build 884 + node dist/server/index.js 885 + ``` 886 + 887 + Verify: static assets served, API routes work, SSR renders (if configured). 888 + 889 + --- 890 + 891 + ### Task 8: Test with teal template 892 + 893 + Same as Task 7 but for the more complex teal template. Validates: 894 + - Multiple feeds 895 + - Complex XRPC queries 896 + - OG image generation 897 + - Setup scripts 898 + - Label definitions 899 + 900 + **This task is validation only — no new code, just running the templates against the new server.**
+122
docs/superpowers/specs/2026-03-14-server-directory-design.md
··· 1 + # Server Directory Design 2 + 3 + **Goal:** Consolidate all server-side code into a single `server/` directory with Vite SSR integration, inspired by Nitro's DX. 4 + 5 + **Status:** Design complete 6 + 7 + --- 8 + 9 + ## Overview 10 + 11 + All server-side code lives in a single `server/` directory. hatk recursively scans it on startup, inspects each file's default export, and wires it up based on the define function used. File names and subdirectory structure are purely organizational — hatk derives all routing and semantics from the define calls themselves. 12 + 13 + ## Define Functions 14 + 15 + | Function | Purpose | Key args | 16 + |---|---|---| 17 + | `defineQuery(nsid, opts)` | XRPC query handler | lexicon-typed input/output | 18 + | `defineProcedure(nsid, opts)` | XRPC mutation handler | lexicon-typed input/output | 19 + | `defineFeed(name, opts)` | Feed generator | handler + hydrator | 20 + | `defineHook(event, opts)` | Lifecycle hook | event name (e.g. `'on-login'`) | 21 + | `defineSetup(fn)` | Boot-time setup | runs before server starts | 22 + | `defineLabels(defs)` | Label definitions | array of label configs | 23 + | `defineOG(path, fn)` | OpenGraph image | route path, returns JSX | 24 + 25 + **Execution order:** Setup scripts run first (boot), then all other handlers register. During dev, handler files get Vite SSR HMR — edits reload instantly without restarting the database or indexer. 26 + 27 + ## Vite Integration 28 + 29 + Adding `hatk()` to your Vite config is the entire setup. No separate server process, no CLI to run alongside Vite. 30 + 31 + ```ts 32 + // vite.config.ts 33 + import { defineConfig } from 'vite' 34 + import { hatk } from '@hatk/hatk/vite-plugin' 35 + 36 + export default defineConfig({ 37 + plugins: [hatk()] 38 + }) 39 + ``` 40 + 41 + **Dev mode (`vite dev`):** hatk boots its core runtime (database, indexer, OAuth) inside Vite's SSR context. Handler files in `server/` are loaded through Vite's module pipeline, giving them true HMR. Editing a feed or XRPC handler reloads just that handler — no database reconnection, no indexer restart, no lost firehose cursor. 42 + 43 + **Build mode (`vite build`):** hatk compiles server code alongside the frontend. The output is a self-contained app — static assets plus a Node server entry point. `node dist/server.js` runs everything. 44 + 45 + **Proxy rules:** In dev, Vite's dev server handles the frontend. hatk's plugin automatically proxies `/xrpc/*`, `/oauth/*`, `/.well-known/*`, `/og/*`, and other backend routes to the hatk runtime. No manual proxy configuration. 46 + 47 + **What stays in the long-running core (no HMR):** 48 + - Database connections (SQLite/DuckDB) 49 + - Firehose indexer + websocket 50 + - OAuth server state 51 + - Backfill workers 52 + 53 + **What gets HMR'd:** 54 + - Feeds, queries, procedures, hooks, labels, OG handlers — anything defined in `server/` 55 + 56 + ## Project Structure 57 + 58 + A minimal hatk app: 59 + 60 + ``` 61 + vite.config.ts 62 + hatk.config.ts 63 + lexicons/ 64 + xyz/statusphere/ 65 + profile.json 66 + status.json 67 + server/ 68 + feed.ts 69 + get-profile.ts 70 + src/ 71 + index.html 72 + App.tsx 73 + ``` 74 + 75 + A larger app organizes with optional subdirectories: 76 + 77 + ``` 78 + vite.config.ts 79 + hatk.config.ts 80 + lexicons/ 81 + xyz/teal/ 82 + post.json 83 + profile.json 84 + like.json 85 + server/ 86 + setup/ 87 + seed-data.ts 88 + feeds/ 89 + recent.ts 90 + popular.ts 91 + my-posts.ts 92 + xrpc/ 93 + get-profile.ts 94 + set-status.ts 95 + create-post.ts 96 + hooks/ 97 + on-login.ts 98 + labels.ts 99 + og-card.tsx 100 + src/ 101 + ...frontend code 102 + ``` 103 + 104 + Both are valid. hatk doesn't enforce directory structure inside `server/` — it scans recursively and only cares about exports. 105 + 106 + **`hatk.config.ts` gets simpler.** Only non-code config: collections, OAuth, relay URL, database path. Everything behavioral moves into `server/`. 107 + 108 + **`lexicons/` stays separate.** Lexicons are JSON schema definitions, not server code. They're consumed at build time for type generation and at runtime for validation. 109 + 110 + ## Implementation Scope 111 + 112 + **1. Server scanner** — New module that recursively walks `server/`, imports each file, inspects the default export, and registers it with the appropriate subsystem. Replaces the current `initFeeds()`, `initXrpc()`, `initLabels()`, `initOpengraph()`, `loadOnLoginHook()`, and `initSetup()` calls in `main.ts` with a single `initServer('server/')` call. 113 + 114 + **2. Vite SSR integration** — Replace the `tsx watch` spawn in the Vite plugin with Vite's `ssrLoadModule()` for handler files. The hatk core runtime (database, indexer, OAuth) boots once and stays alive. Handler modules get loaded/reloaded through Vite's module graph, giving us HMR for free. 115 + 116 + **3. Define functions** — `defineQuery`, `defineProcedure`, `defineFeed`, `defineHook`, `defineSetup`, `defineLabels`, `defineOG` all export from `@hatk/hatk`. Each returns a typed descriptor object that the scanner knows how to register. The define functions themselves are thin — they just tag the config with a type and return it. 117 + 118 + **4. Build output** — `vite build` produces a server entry point alongside static assets. The entry point imports the scanned handlers and boots the hatk runtime. Production runs with `node dist/server.js`. 119 + 120 + **5. Template updates** — Rewrite statusphere and teal templates to use the new `server/` layout. 121 + 122 + **What doesn't change:** Database layer, indexer, backfill, OAuth, lexicon loading, schema migration — all the infrastructure work stays as-is.
+204
docs/superpowers/specs/2026-03-14-vite-ssr-design.md
··· 1 + # Vite SSR & Environment API Integration Design 2 + 3 + **Goal:** Replace tsx watch child process with Vite 8 Environment API for in-process HMR, rewrite server to Web Standard Request/Response, add framework-agnostic SSR rendering, and produce a single deployable artifact from `vite build`. 4 + 5 + **Status:** Design complete 6 + 7 + --- 8 + 9 + ## Architecture Overview 10 + 11 + hatk registers a custom `hatk` environment with Vite 8's Environment API. In dev, a `RunnableDevEnvironment` runs the hatk server through Vite's module runner with full HMR. In production, `vite build` produces client assets with an SSR manifest plus a server entry point. 12 + 13 + **Three layers:** 14 + 15 + 1. **Infrastructure** — Database, firehose indexer, OAuth, backfill workers. Boots once in `configureServer`, survives handler reloads. Exposed via existing global singletons (`db.ts`, `indexer.ts`). 16 + 17 + 2. **Handler layer** — Everything in `server/`. Loaded through the module runner. On file change, module graph invalidates the changed module, entry re-imports. Handlers get fresh code while DB connections persist. 18 + 19 + 3. **Request handler** — A Web Standard `fetch` function (`Request → Response`) exported from the handler entry module. Routes API requests to XRPC/feeds/OG handlers. Routes HTML requests through the user's `defineRenderer` if provided, otherwise serves the SPA shell. A hand-rolled ~50 line adapter bridges this to Node.js `createServer` in production. 20 + 21 + **SSR model:** hatk is not an SSR framework. It provides the hook point (`defineRenderer`), the SSR manifest, and the `Request` object. The user brings their own framework renderer (Vue, React, Svelte). Vite handles framework-specific compilation. hatk serves the result with correct asset preloads and OG meta tags. 22 + 23 + ## Dev Mode 24 + 25 + The Vite plugin registers a `hatk` environment and hooks into the dev server lifecycle: 26 + 27 + ```ts 28 + // vite.config.ts 29 + import { defineConfig } from 'vite-plus' 30 + import { hatk } from '@hatk/hatk/vite-plugin' 31 + 32 + export default defineConfig({ 33 + plugins: [hatk()], 34 + test: { include: ['test/**/*.test.ts'] }, 35 + lint: { ignorePatterns: ['dist/**'] }, 36 + }) 37 + ``` 38 + 39 + **Boot sequence:** 40 + 1. Vite starts, `hatk()` plugin's `config()` hook registers the `hatk` environment 41 + 2. `configureServer()` fires — boots infrastructure (DB, indexer, OAuth) from `hatk.config.ts` 42 + 3. Module runner imports the handler entry module 43 + 4. Entry module scans `server/`, registers handlers, exports a `fetch(Request) → Response` function 44 + 5. Plugin mounts the fetch handler as Vite middleware for backend routes (`/xrpc/*`, `/oauth/*`, `/og/*`) 45 + 6. For HTML requests: if `defineRenderer` exists, calls it with the `Request` and SSR manifest, serves rendered HTML with asset preloads and OG meta 46 + 7. If no renderer: falls through to Vite's client pipeline (SPA mode, same as today) 47 + 48 + **HMR flow:** 49 + 1. User edits `server/recent.ts` 50 + 2. Vite watcher fires, `hotUpdate` hook invalidates the module in the `hatk` environment's graph 51 + 3. Module runner re-imports entry — scanner re-registers handlers 52 + 4. Next request uses updated code. No restart, no dropped DB connections. 53 + 54 + **No proxy, no child process, no ECONNREFUSED errors.** Everything runs in-process. 55 + 56 + ## Request/Response Architecture 57 + 58 + hatk's server rewrites from Node.js `IncomingMessage`/`ServerResponse` to Web Standard `Request`/`Response`. The core becomes a pure function: 59 + 60 + ```ts 61 + type HatkHandler = (request: Request) => Promise<Response> 62 + ``` 63 + 64 + **Routing order inside the handler:** 65 + 1. `/xrpc/*` → XRPC query/procedure dispatch 66 + 2. `/oauth/*` → OAuth server 67 + 3. `/.well-known/*` → AT Protocol discovery 68 + 4. `/og/*` → OpenGraph image generation 69 + 5. `/*` with `Accept: text/html` → `defineRenderer` if exists, else SPA shell 70 + 6. `/*` → static assets (production only, dev falls through to Vite) 71 + 72 + **Node.js adapter (~50 lines):** 73 + ```ts 74 + // Converts IncomingMessage → Request 75 + function toRequest(req: IncomingMessage): Request { ... } 76 + 77 + // Pipes Response → ServerResponse 78 + function sendResponse(res: ServerResponse, response: Response): Promise<void> { ... } 79 + 80 + // Bridge for production 81 + createServer(async (req, res) => { 82 + const response = await handler(toRequest(req)) 83 + await sendResponse(res, response) 84 + }).listen(port) 85 + ``` 86 + 87 + In dev, Vite middleware calls `handler(request)` directly — no adapter needed since Vite 8's environment API works with the fetch pattern. 88 + 89 + **What changes from current `server.ts`:** The 1200-line file gets rewritten as a pure `Request → Response` function. All the route handling logic stays, but `res.writeHead()`/`res.end()` calls become `new Response()` constructors. The viewer auth, CORS, error handling all work the same way, just returning `Response` objects. 90 + 91 + ## SSR Rendering 92 + 93 + hatk provides the plumbing. The user brings the framework. 94 + 95 + **The hook:** 96 + ```ts 97 + // server/render.tsx 98 + export default defineRenderer(async (request, manifest) => { 99 + const url = new URL(request.url).pathname 100 + const { render } = await import('../src/entry-server.tsx') 101 + const html = render(url) 102 + const preloads = manifest.getPreloadTags(url) 103 + return { html, head: preloads } 104 + }) 105 + ``` 106 + 107 + **What hatk does with the result:** 108 + 1. Reads `index.html` as a template 109 + 2. Injects `head` (asset preload tags) into `<head>` 110 + 3. Injects OG meta tags into `<head>` (from `defineOG` handlers, same as today) 111 + 4. Injects `html` into the app mount point (e.g. `<!--ssr-outlet-->` or `<div id="app">`) 112 + 5. Returns the assembled page as a `Response` 113 + 114 + **What hatk does NOT do:** 115 + - No routing — the renderer decides what to render based on the URL 116 + - No data fetching magic — the renderer calls its own APIs or uses an XRPC client 117 + - No framework-specific transforms — Vite plugins handle that (`@vitejs/plugin-react`, `@sveltejs/vite-plugin-svelte`, etc.) 118 + 119 + **If no `defineRenderer` exists:** hatk falls back to SPA mode — serves `index.html` with OG meta tags injected, exactly like today. SSR is opt-in. 120 + 121 + **Client hydration** is entirely the user's responsibility: 122 + ```tsx 123 + // src/entry-client.tsx 124 + hydrateRoot(document, <App />) 125 + ``` 126 + 127 + ## Production Build 128 + 129 + `vite build` produces two outputs via Vite's `buildApp` hook: 130 + 131 + **Stage 1: Client** 132 + - Standard Vite client build → `dist/client/` 133 + - Generates SSR manifest at `dist/client/.vite/ssr-manifest.json` 134 + - Static assets with content-hashed filenames 135 + 136 + **Stage 2: Server (hatk environment)** 137 + - Bundles handler entry + all `server/` code → `dist/server/index.js` 138 + - Externalizes native dependencies (`better-sqlite3`, `@duckdb/node-api`) 139 + - Embeds the Node.js adapter (~50 lines) 140 + - SSR manifest inlined or referenced for asset injection 141 + 142 + **Running in production:** 143 + ```bash 144 + node dist/server/index.js 145 + ``` 146 + 147 + This boots infrastructure (DB, indexer, OAuth), imports the bundled handlers, and starts an HTTP server that: 148 + - Serves API routes via the `Request → Response` handler 149 + - SSR renders HTML requests through the bundled renderer with correct asset preloads 150 + - Serves static assets from `dist/client/` with cache headers 151 + - Injects OG meta tags for all HTML responses 152 + 153 + **Single deployable artifact.** No separate frontend/backend deploy. `dist/` contains everything. 154 + 155 + ## Example App Structure (React) 156 + 157 + ``` 158 + vite.config.ts 159 + hatk.config.ts 160 + index.html 161 + lexicons/ 162 + xyz/myapp/ 163 + post.json 164 + profile.json 165 + server/ 166 + feed.ts → defineFeed(...) 167 + get-profile.ts → defineQuery(...) 168 + on-login.ts → defineHook(...) 169 + render.tsx → defineRenderer(...) 170 + src/ 171 + entry-client.tsx → hydrateRoot(document, <App />) 172 + entry-server.tsx → renderToString(<App />) 173 + App.tsx 174 + routes/ 175 + Home.tsx 176 + Profile.tsx 177 + ``` 178 + 179 + Framework-agnostic: swap React for Svelte or Vue by changing the entry files and Vite plugin. hatk's `server/` code stays identical. 180 + 181 + ## Implementation Scope 182 + 183 + | Component | What changes | 184 + |---|---| 185 + | `server.ts` | Rewrite as `Request → Response` function | 186 + | `vite-plugin.ts` | Replace tsx watch with DevEnvironment + middleware | 187 + | New: `adapter.ts` | ~50 line Node.js `Request`/`Response` bridge | 188 + | New: `defineRenderer` | New define function + SSR assembly logic | 189 + | `main.ts` | Production entry: boot infra + start server via adapter | 190 + | `cli.ts` codegen | Add `defineRenderer` export to `hatk.generated.ts` | 191 + | Templates | Migrate to vite-plus, add `entry-server`/`entry-client` | 192 + 193 + **What doesn't change:** Database layer, indexer, backfill, OAuth, lexicon loading, schema migration, feeds/xrpc/labels/og handler APIs. 194 + 195 + ## Key Decisions 196 + 197 + 1. **Vite 8 Environment API** with `RunnableDevEnvironment` (not legacy `ssrLoadModule`) 198 + 2. **Web Standard `Request`/`Response`** throughout (not Node.js IncomingMessage/ServerResponse) 199 + 3. **Hand-rolled Node.js adapter** (~50 lines, no h3 or @whatwg-node dependency) 200 + 4. **`defineRenderer(async (request, manifest) => ...)`** for framework-agnostic SSR 201 + 5. **Global singletons** for infrastructure (DB, indexer) — survives HMR reloads 202 + 6. **Two-stage production build** (client with SSR manifest + server environment) 203 + 7. **Peer dependency on `vite`** — works with both raw Vite and vite-plus 204 + 8. **SSR is opt-in** — no renderer = SPA mode, same as today