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

peterullrich.com/hatk

AT Protocol app framework

Forked from chadtmiller.com/hatk

0
0
main
204 lines · 8.8 KB Raw History

Vite SSR & Environment API Integration Design

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.

Status: Design complete


Architecture Overview

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.

Three layers:

  1. Infrastructure — Database, firehose indexer, OAuth, backfill workers. Boots once in configureServer, survives handler reloads. Exposed via existing global singletons (db.ts, indexer.ts).

  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.

  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.

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.

Dev Mode

The Vite plugin registers a hatk environment and hooks into the dev server lifecycle:

// vite.config.ts
import { defineConfig } from 'vite-plus'
import { hatk } from '@hatk/hatk/vite-plugin'

export default defineConfig({
  plugins: [hatk()],
  test: { include: ['test/**/*.test.ts'] },
  lint: { ignorePatterns: ['dist/**'] },
})

Boot sequence:

  1. Vite starts, hatk() plugin's config() hook registers the hatk environment
  2. configureServer() fires — boots infrastructure (DB, indexer, OAuth) from hatk.config.ts
  3. Module runner imports the handler entry module
  4. Entry module scans server/, registers handlers, exports a fetch(Request) → Response function
  5. Plugin mounts the fetch handler as Vite middleware for backend routes (/xrpc/*, /oauth/*, /og/*)
  6. For HTML requests: if defineRenderer exists, calls it with the Request and SSR manifest, serves rendered HTML with asset preloads and OG meta
  7. If no renderer: falls through to Vite's client pipeline (SPA mode, same as today)

HMR flow:

  1. User edits server/recent.ts
  2. Vite watcher fires, hotUpdate hook invalidates the module in the hatk environment's graph
  3. Module runner re-imports entry — scanner re-registers handlers
  4. Next request uses updated code. No restart, no dropped DB connections.

No proxy, no child process, no ECONNREFUSED errors. Everything runs in-process.

Request/Response Architecture

hatk's server rewrites from Node.js IncomingMessage/ServerResponse to Web Standard Request/Response. The core becomes a pure function:

type HatkHandler = (request: Request) => Promise<Response>

Routing order inside the handler:

  1. /xrpc/* → XRPC query/procedure dispatch
  2. /oauth/* → OAuth server
  3. /.well-known/* → AT Protocol discovery
  4. /og/* → OpenGraph image generation
  5. /* with Accept: text/html → defineRenderer if exists, else SPA shell
  6. /* → static assets (production only, dev falls through to Vite)

Node.js adapter (~50 lines):

// Converts IncomingMessage → Request
function toRequest(req: IncomingMessage): Request { ... }

// Pipes Response → ServerResponse
function sendResponse(res: ServerResponse, response: Response): Promise<void> { ... }

// Bridge for production
createServer(async (req, res) => {
  const response = await handler(toRequest(req))
  await sendResponse(res, response)
}).listen(port)

In dev, Vite middleware calls handler(request) directly — no adapter needed since Vite 8's environment API works with the fetch pattern.

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.

SSR Rendering

hatk provides the plumbing. The user brings the framework.

The hook:

// server/render.tsx
export default defineRenderer(async (request, manifest) => {
  const url = new URL(request.url).pathname
  const { render } = await import('../src/entry-server.tsx')
  const html = render(url)
  const preloads = manifest.getPreloadTags(url)
  return { html, head: preloads }
})

What hatk does with the result:

  1. Reads index.html as a template
  2. Injects head (asset preload tags) into <head>
  3. Injects OG meta tags into <head> (from defineOG handlers, same as today)
  4. Injects html into the app mount point (e.g. <!--ssr-outlet--> or <div id="app">)
  5. Returns the assembled page as a Response

What hatk does NOT do:

  • No routing — the renderer decides what to render based on the URL
  • No data fetching magic — the renderer calls its own APIs or uses an XRPC client
  • No framework-specific transforms — Vite plugins handle that (@vitejs/plugin-react, @sveltejs/vite-plugin-svelte, etc.)

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.

Client hydration is entirely the user's responsibility:

// src/entry-client.tsx
hydrateRoot(document, <App />)

Production Build

vite build produces two outputs via Vite's buildApp hook:

Stage 1: Client

  • Standard Vite client build → dist/client/
  • Generates SSR manifest at dist/client/.vite/ssr-manifest.json
  • Static assets with content-hashed filenames

Stage 2: Server (hatk environment)

  • Bundles handler entry + all server/ code → dist/server/index.js
  • Externalizes native dependencies (better-sqlite3, @duckdb/node-api)
  • Embeds the Node.js adapter (~50 lines)
  • SSR manifest inlined or referenced for asset injection

Running in production:

node dist/server/index.js

This boots infrastructure (DB, indexer, OAuth), imports the bundled handlers, and starts an HTTP server that:

  • Serves API routes via the Request → Response handler
  • SSR renders HTML requests through the bundled renderer with correct asset preloads
  • Serves static assets from dist/client/ with cache headers
  • Injects OG meta tags for all HTML responses

Single deployable artifact. No separate frontend/backend deploy. dist/ contains everything.

Example App Structure (React)

vite.config.ts
hatk.config.ts
index.html
lexicons/
  xyz/myapp/
    post.json
    profile.json
server/
  feed.ts              → defineFeed(...)
  get-profile.ts       → defineQuery(...)
  on-login.ts          → defineHook(...)
  render.tsx           → defineRenderer(...)
src/
  entry-client.tsx     → hydrateRoot(document, <App />)
  entry-server.tsx     → renderToString(<App />)
  App.tsx
  routes/
    Home.tsx
    Profile.tsx

Framework-agnostic: swap React for Svelte or Vue by changing the entry files and Vite plugin. hatk's server/ code stays identical.

Implementation Scope

ComponentWhat changes
server.tsRewrite as Request → Response function
vite-plugin.tsReplace tsx watch with DevEnvironment + middleware
New: adapter.ts~50 line Node.js Request/Response bridge
New: defineRendererNew define function + SSR assembly logic
main.tsProduction entry: boot infra + start server via adapter
cli.ts codegenAdd defineRenderer export to hatk.generated.ts
TemplatesMigrate to vite-plus, add entry-server/entry-client

What doesn't change: Database layer, indexer, backfill, OAuth, lexicon loading, schema migration, feeds/xrpc/labels/og handler APIs.

Key Decisions

  1. Vite 8 Environment API with RunnableDevEnvironment (not legacy ssrLoadModule)
  2. Web Standard Request/Response throughout (not Node.js IncomingMessage/ServerResponse)
  3. Hand-rolled Node.js adapter (~50 lines, no h3 or @whatwg-node dependency)
  4. defineRenderer(async (request, manifest) => ...) for framework-agnostic SSR
  5. Global singletons for infrastructure (DB, indexer) — survives HMR reloads
  6. Two-stage production build (client with SSR manifest + server environment)
  7. Peer dependency on vite — works with both raw Vite and vite-plus
  8. SSR is opt-in — no renderer = SPA mode, same as today