pyreon

@pyreon/server is the full-stack application layer for Pyreon. It provides a Web-standard SSR request handler, a static site generator, an island architecture for partial hydration, and a middleware pipeline — all built on top of @pyreon/runtime-server, @pyreon/router, and @pyreon/head.

Overview

The package has two entry points:

EntryImportEnvironment
Server@pyreon/serverNode, Bun, Deno, Cloudflare Workers
Client@pyreon/server/clientBrowser

Server exports: createHandler, prerender, island, processTemplate, compileTemplate, processCompiledTemplate, buildScripts, buildScriptsFast, DEFAULT_TEMPLATE

Client exports: startClient, hydrateIslands

@pyreon/serverstable

Installation

npm install @pyreon/server
bun add @pyreon/server
pnpm add @pyreon/server
yarn add @pyreon/server

@pyreon/server depends on @pyreon/core, @pyreon/reactivity, @pyreon/runtime-dom, @pyreon/runtime-server, @pyreon/router, and @pyreon/head — all pulled automatically from the workspace.


SSR Handler — createHandler

createHandler produces a Web-standard (Request) => Promise<Response> function. It works with any server that speaks the Web Fetch API: Bun.serve, Deno.serve, Cloudflare Workers, and Express/Fastify via adapters.

HandlerOptions

interface HandlerOptions {
  /** Root application component */
  App: ComponentFn
  /** Route definitions */
  routes: RouteRecord[]
  /**
   * HTML template with comment placeholders:
   *   <!--pyreon-head-->     — head tags (title, meta, link, etc.)
   *   <!--pyreon-app-->      — rendered app HTML
   *   <!--pyreon-scripts-->  — client entry script + inline loader data
   *
   * Defaults to DEFAULT_TEMPLATE (a minimal HTML5 shell).
   */
  template?: string
  /**
   * Path to the client entry module (default: "/src/entry-client.ts").
   * Pass `false` to suppress the client-entry <script> injection entirely —
   * use this when `template` is a BUILT index.html that already carries the
   * production hashed <script type="module"> tag. Loader-data injection
   * still happens.
   */
  clientEntry?: string | false
  /** Middleware chain — runs before rendering */
  middleware?: Middleware[]
  /**
   * Rendering mode:
   *   "string" — full renderToString, complete HTML in one response (default)
   *   "stream" — progressive streaming via renderToStream (Suspense out-of-order)
   */
  mode?: 'string' | 'stream'
  /**
   * Collect CSS styles after rendering — return a <style> tag string to
   * inject into <head> (e.g. () => sheet.getStyleTag() from @pyreon/styler).
   */
  collectStyles?: () => string
  /**
   * Per-boundary Suspense timeout in ms for mode: "stream" (default 30_000).
   * Ignored in mode: "string".
   */
  suspenseTimeoutMs?: number
}

Request lifecycle

Every incoming request goes through these steps:

  1. Middleware pipeline — each middleware runs in order. Any middleware can short-circuit by returning a Response (for redirects, auth checks, etc.).

  2. Router creation — a per-request createRouter instance is created with the matched URL.

  3. Loader prefetch — route loaders run in parallel so data is ready before rendering.

  4. Render — the app component tree is rendered to HTML (string or stream mode).

  5. Head collection@pyreon/head collects title, meta, and link tags emitted during render.

  6. Template injection — head tags, app HTML, and scripts are injected into the HTML template.

  7. Response — a Response with text/html content type is returned.

Basic example — Bun

import { createHandler } from '@pyreon/server'
import { App } from './src/App'
import { routes } from './src/routes'

const handler = createHandler({
  App,
  routes,
  template: await Bun.file('index.html').text(),
})

Bun.serve({ fetch: handler, port: 3000 })
console.log('Listening on http://localhost:3000')

Custom template

The default template is a minimal HTML5 document. For production, provide your own:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="/assets/style.css" />
    <!--pyreon-head-->
  </head>
  <body>
    <div id="app"><!--pyreon-app--></div>
    <!--pyreon-scripts-->
  </body>
</html>

The three comment placeholders are required:

PlaceholderReplaced with
<!--pyreon-head-->Collected <title>, <meta>, <link> tags from @pyreon/head
<!--pyreon-app-->Rendered application HTML
<!--pyreon-scripts-->Client entry <script> tag + inline loader data

Custom client entry

By default the handler injects <script type="module" src="/src/entry-client.ts">. Override it:

const handler = createHandler({
  App,
  routes,
  clientEntry: '/dist/client.js',
})

Streaming mode

Enable progressive streaming for large pages with Suspense boundaries:

const handler = createHandler({
  App,
  routes,
  mode: 'stream',
})

In streaming mode:

  • The HTML shell (everything before <!--pyreon-app-->) is flushed immediately.

  • App content streams progressively as components resolve.

  • Suspense boundaries resolve out-of-order via inline <template> elements and swap scripts.

  • The closing shell (after <!--pyreon-app-->) is sent after all content is flushed.

This gives the browser a head start on parsing CSS and fetching resources while the app renders.

Error handling

If rendering throws, the handler catches the error, logs it to console.error, and returns a 500 Internal Server Error plain-text response. In streaming mode, since the status code is already sent (200), an inline error script is emitted instead.

// The handler never throws — it always returns a Response
const res = await handler(new Request('http://localhost/broken'))
// res.status === 500
// await res.text() === "Internal Server Error"

Middleware

Middleware functions run before rendering and can inspect/modify the request context or short-circuit with a Response.

Types

interface MiddlewareContext {
  /** The incoming request */
  req: Request
  /** Parsed URL */
  url: URL
  /** Pathname + search (passed to router) */
  path: string
  /** Response headers — middleware can set custom headers */
  headers: Headers
  /** Arbitrary per-request data shared between middleware and components */
  locals: Record<string, unknown>
}

type Middleware = (ctx: MiddlewareContext) => Response | void | Promise<Response | void>

Short-circuiting

Return a Response to stop the middleware chain and skip rendering entirely:

const authMiddleware: Middleware = async (ctx) => {
  const token = ctx.req.headers.get('Authorization')
  if (!token) {
    return new Response('Unauthorized', { status: 401 })
  }
  ctx.locals.user = await verifyToken(token)
}

Setting headers

Middleware can set response headers via ctx.headers. These are included in the final response:

const cacheMiddleware: Middleware = (ctx) => {
  if (ctx.path.startsWith('/static/')) {
    ctx.headers.set('Cache-Control', 'public, max-age=31536000, immutable')
  } else {
    ctx.headers.set('Cache-Control', 'no-cache')
  }
}

Redirects

const trailingSlashMiddleware: Middleware = (ctx) => {
  if (ctx.path !== '/' && ctx.path.endsWith('/')) {
    const target = ctx.path.slice(0, -1) + ctx.url.search
    return Response.redirect(new URL(target, ctx.url.origin).href, 301)
  }
}

Sharing data via locals

ctx.locals is an untyped bag for passing data from middleware to components. The data is available for the lifetime of the request:

const timingMiddleware: Middleware = (ctx) => {
  ctx.locals.requestStart = performance.now()
}

const geoMiddleware: Middleware = (ctx) => {
  ctx.locals.country = ctx.req.headers.get('CF-IPCountry') ?? 'unknown'
}

On the component side, useRequestLocals() reads the locals during SSR:

import { useRequestLocals } from '@pyreon/server'

function Footer() {
  const country = useRequestLocals().country as string
  return <p>Served to {country}</p>
}

Composing middleware

Middleware runs in array order. The first to return a Response wins:

const handler = createHandler({
  App,
  routes,
  middleware: [
    corsMiddleware, // 1. CORS headers
    rateLimitMiddleware, // 2. Rate limiting
    authMiddleware, // 3. Authentication
    cacheMiddleware, // 4. Cache headers
  ],
})

Static file middleware example

import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'

const MIME_TYPES: Record<string, string> = {
  '.js': 'application/javascript',
  '.css': 'text/css',
  '.html': 'text/html',
  '.json': 'application/json',
  '.png': 'image/png',
  '.svg': 'image/svg+xml',
}

const staticMiddleware: Middleware = async (ctx) => {
  const filePath = join('public', ctx.path)
  if (existsSync(filePath)) {
    const ext = ctx.path.slice(ctx.path.lastIndexOf('.'))
    const contentType = MIME_TYPES[ext] ?? 'application/octet-stream'
    const body = await readFile(filePath)
    return new Response(body, {
      headers: { 'Content-Type': contentType },
    })
  }
}

Static Site Generation — prerender

prerender takes an SSR handler and a list of paths, renders each one, and writes the HTML to disk.

PrerenderOptions

interface PrerenderOptions {
  /** SSR handler created by createHandler() */
  handler: (req: Request) => Promise<Response>
  /** Routes to pre-render — array of URL paths or async function that returns them */
  paths: string[] | (() => string[] | Promise<string[]>)
  /** Output directory for the generated HTML files */
  outDir: string
  /** Origin for constructing full URLs (default: "http://localhost") */
  origin?: string
  /**
   * Called after each page is rendered.
   * Return false to skip writing this page.
   */
  onPage?: (path: string, html: string) => void | boolean | Promise<void | boolean>
}

PrerenderResult

interface PrerenderResult {
  /** Number of pages generated */
  pages: number
  /** Paths that failed to render */
  errors: Array<{ path: string; error: unknown }>
  /** Total elapsed time in milliseconds */
  elapsed: number
}

File output mapping

PathOutput file
/outDir/index.html
/aboutoutDir/about/index.html
/blog/hellooutDir/blog/hello/index.html
/feed.xmloutDir/feed.xml (if path ends with extension)

Basic SSG build script

import { createHandler, prerender } from '@pyreon/server'
import { App } from './src/App'
import { routes } from './src/routes'

const handler = createHandler({ App, routes })

const result = await prerender({
  handler,
  paths: ['/', '/about', '/blog', '/contact'],
  outDir: 'dist',
})

console.log(`Generated ${result.pages} pages in ${result.elapsed}ms`)
if (result.errors.length > 0) {
  console.error('Errors:', result.errors)
  process.exit(1)
}

Dynamic paths from a CMS

The paths option accepts an async function for dynamic route discovery:

import { createHandler, prerender } from '@pyreon/server'
import { App } from './src/App'
import { routes } from './src/routes'

const handler = createHandler({ App, routes })

const result = await prerender({
  handler,
  paths: async () => {
    const posts = await fetch('https://cms.example.com/api/posts').then((r) => r.json())

    return ['/', '/about', ...posts.map((p: { slug: string }) => `/blog/${p.slug}`)]
  },
  outDir: 'dist',
})

Progress tracking with onPage

Use the onPage callback for progress logging, HTML post-processing, or conditional skipping:

const result = await prerender({
  handler,
  paths: allPaths,
  outDir: 'dist',
  onPage: (path, html) => {
    console.log(`  ✓ ${path} (${html.length} bytes)`)
    // Return false to skip writing
    if (html.includes('<!-- draft -->')) return false
  },
})

Concurrency

prerender processes paths in batches of 10 concurrently. This balances throughput with memory usage — each path creates a full SSR render context.


Island Architecture — island

Pyreon's island architecture (partial hydration) has its own dedicated page covering the six hydration strategies, the prefetch hint, the auto-registry, and the static + runtime audits.

Read the Island Architecture guide

The short version: island(loader, { name, hydrate }) wraps an async component import and returns a ComponentFn that renders inside a <pyreon-island> custom element with serialized props + the hydration strategy as data attributes. Six strategies (load / idle / visible / interaction / media(...) / never), plus a prefetch hint that pre-warms the chunk before deferred-hydration triggers fire. Auto-discovered registry under @pyreon/vite-plugin (hydrateIslandsAuto()) eliminates the manual sync between island() declarations and the client registry. Project-wide audit at pyreon doctor --check-islands + MCP audit_islands tool catches duplicate names, dead islands, registry drift, nested islands, and never-with-registry foot-guns at build time.


Server Islands — serverIsland

The inverse of a client island: a cacheable page with per-request server-rendered holes. serverIsland(loader, { name, fallback?, cache? }) registers the component and renders a <pyreon-server-island> marker with the fallback content inline (what no-JS visitors keep). On the client the marker self-activates and fetches its real content from the fragment endpoint — GET /_pyreon/fragment/<name>?props=… — which renders ONLY that component server-side per request. Names are allowlisted: the endpoint serves only registered islands. Fragment responses default to no-store; the opt-in cache option sets a Cache-Control value for slow-but-public widgets (never cache a fragment that varies on cookies).

import { serverIsland } from '@pyreon/server' // or '@pyreon/zero' in zero apps

const CartBadge = serverIsland(() => import('./CartBadge'), {
  name: 'cart-badge',
  fallback: <span class="badge">Cart</span>,
})
// The page around <CartBadge /> stays SSG/ISR/CDN-cacheable; the hole is per-request.

→ See Zero → Server Islands for the full surface (zero auto-mounts the fragment endpoint and activation).


One render pipeline — renderPage

renderPage(App, router, path, options?) is the single string-mode render pipeline shared by createHandler, SSG prerendering, and zero's dev SSR — preload (lazy components + loaders), render, head collection, optional collectStyles, loader-data serialization, all inside runWithRequestContext. It returns composable parts instead of a full document, so each caller injects them into its own template:

import { renderPage } from '@pyreon/server'

const result = await renderPage(App, router, '/dashboard', { request, locals })
if (result.kind === 'redirect') return Response.redirect(result.to, result.status)
if (result.kind === 'html') {
  // result.appHtml, result.head, result.loaderScript, result.status (200 | 404)
}

options carries request (forwarded to loaders), skipLoaders, collectStyles, locals (bridged to useRequestLocals()), and bailOnUnmatched (returns { kind: 'unmatched' } instead of rendering — used by the dev middleware's static-404 fall-through).


Client-Side Hydration

The @pyreon/server/client entry provides two functions for client-side hydration.

startClient — Full app hydration

For traditional SSR where the entire app is interactive:

import { startClient } from '@pyreon/server/client'
import { App } from './App'
import { routes } from './routes'

const cleanup = startClient({ App, routes })

StartClientOptions

interface StartClientOptions {
  /** Root application component */
  App: ComponentFn
  /** Route definitions (same as server) */
  routes: RouteRecord[]
  /** CSS selector or element for the app container (default: "#app") */
  container?: string | Element
}

startClient handles:

  1. Router creation — creates a history-mode router for client-side navigation.

  2. Loader data hydration — reads window.__PYREON_LOADER_DATA__ injected by SSR to avoid re-fetching data on initial render.

  3. Hydration or mount — if the container has SSR content, it hydrates; otherwise it performs a fresh mount.

  4. Cleanup — returns a function that unmounts the app.

Custom container

startClient({
  App,
  routes,
  container: '#root', // CSS selector
})

// Or pass an element directly
startClient({
  App,
  routes,
  container: document.getElementById('root')!,
})

Loader data hydration

When the server renders a page with route loaders, the loader results are serialized into window.__PYREON_LOADER_DATA__. The client reads this data and hydrates the router's loader cache, so the initial render uses server data without an extra fetch:

// Server: route with loader
const routes = [
  {
    path: '/users/:id',
    component: UserPage,
    loader: async ({ params }) => {
      const user = await db.users.findById(params.id)
      return { user }
    },
  },
]

// SSR injects into HTML:
// <script>window.__PYREON_LOADER_DATA__={"users/42":{"user":{...}}}</script>

// Client: startClient reads __PYREON_LOADER_DATA__ automatically
startClient({ App, routes })

hydrateIslands — Partial hydration (manual registry)

For island architecture where only specific components are interactive:

import { hydrateIslands } from '@pyreon/server/client'

const cleanup = hydrateIslands({
  Counter: () => import('./components/Counter'),
  SearchBar: () => import('./components/SearchBar'),
  Comments: () => import('./components/Comments'),
})

The registry keys must match the name in the server-side island() calls.

hydrateIslands:

  1. Queries all <pyreon-island> elements in the DOM.

  2. For each element, looks up the component loader in the registry by data-component.

  3. Respects the data-hydrate strategy (load, idle, visible, interaction, media, never).

  4. Deserializes data-props and hydrates the component in place.

  5. Returns a cleanup function that disconnects any pending observers/listeners.

Only components actually present in the HTML are loaded — if a page doesn't use SearchBar, its JavaScript is never fetched.

hydrateIslandsAuto — Auto-discovered registry (preferred)

When using @pyreon/vite-plugin (pyreon({ islands: true }) is the default), the plugin auto-scans the source tree for every island() declaration and emits a virtual module containing the registry. Use hydrateIslandsAuto(registry) to consume it without writing the registry by hand:

import { hydrateIslandsAuto } from '@pyreon/server/client'
import islandsRegistry from 'virtual:pyreon/islands-registry'

hydrateIslandsAuto(islandsRegistry)

This eliminates the manual sync between every island() declaration and the client registry — typo / forgotten entry / registry drift was the #1 author foot-gun before auto-registry shipped. Reference: examples/islands-showcase.

hydrate: 'never' islands are deliberately omitted from the auto-registry so their components stay out of the client bundle. Don't pair hydrate: 'never' with a manual hydrateIslands({ X }) entry — the lint rule pyreon/island-never-with-registry-entry flags this in the same file; the project-wide pyreon doctor --check-islands audit catches the cross-file shape.

interaction strategy + click replay

hydrate: 'interaction' defers hydration until first user interaction (focus / click / pointerenter / touchstart by default). Customize via 'interaction(<events>)'. Click events are replayed on the equivalent live element post-hydration so the user's first click both wakes the island AND fires the action — closes the "user clicks but nothing happens until they click again" UX trap. The replay path uses data-testid when present, falling back to a tag + child-index walk relative to the island root.

Pair with prefetch: 'idle' | 'visible' to pre-warm the chunk before the trigger fires:

// Server side:
island(() => import('./components/MobileMenu'), {
  name: 'MobileMenu',
  hydrate: 'interaction',
  prefetch: 'idle',          // chunk fetched during browser idle
})

Suppressed (no data-prefetch attribute) when hydrate: 'load' (loader runs synchronously) or hydrate: 'never' (defeats zero-JS).

Island perf counters

When @pyreon/perf-harness is installed, the server-side island machinery emits 7 counters under the island.* namespace:

CounterMeaning
island.scheduledPer-island hydration scheduled (idle / visible / etc.)
island.hydratedCompleted hydrations
island.skipped.neverSkipped — hydrate: 'never' (zero-JS)
island.skipped.nestedSkipped — nested island (outer hydrates first, swaps DOM)
island.skipped.no-loaderSkipped — registry mismatch (loader not found)
island.errorHydration error (also surfaces via data-island-error)
island.prefetchPrefetch hint fired (idle / visible)

scheduled - hydrated at steady state = islands still waiting on a deferred trigger; skipped.no-loader should be zero (registry drift); error should be zero (pair with data-island-error="invalid-props"|"hydration-failed" on the failing element to diagnose).


HTML Template Utilities

These lower-level utilities are exported for advanced use cases (custom renderers, build tools, etc.).

DEFAULT_TEMPLATE

A minimal HTML5 template with all three placeholders:

import { DEFAULT_TEMPLATE } from '@pyreon/server'

console.log(DEFAULT_TEMPLATE)
// <!DOCTYPE html>
// <html lang="en">
// <head>
//   <meta charset="UTF-8">
//   <meta name="viewport" content="width=device-width, initial-scale=1.0">
//   <!--pyreon-head-->
// </head>
// <body>
//   <div id="app"><!--pyreon-app--></div>
//   <!--pyreon-scripts-->
// </body>
// </html>

processTemplate

Replaces the three comment placeholders in an HTML template:

import { processTemplate } from '@pyreon/server'

const html = processTemplate(template, {
  head: '<title>My Page</title><meta name="description" content="...">',
  app: '<div><h1>Hello</h1></div>',
  scripts: '<script type="module" src="/client.js"></script>',
})

TemplateData

interface TemplateData {
  head: string
  app: string
  scripts: string
}

buildScripts

Builds the script tags for client hydration:

import { buildScripts } from '@pyreon/server'

const scripts = buildScripts('/client.js', { users: [{ id: 1 }] })
// <script>window.__PYREON_LOADER_DATA__={"users":[{"id":1}]}</script>
// <script type="module" src="/client.js"></script>

If no loader data is present (empty object), only the module script is emitted. The function also escapes </script> sequences inside the JSON to prevent XSS via premature tag closing.

compileTemplate

Pre-split a template into parts at initialization time for faster per-request processing. This avoids repeated string scanning on every request — up to 17x faster than processTemplate on realistic templates (1KB+).

import { compileTemplate, processCompiledTemplate } from '@pyreon/server'

// Once at startup:
const compiled = compileTemplate(template)

// Per request (fast concatenation, no scanning):
const html = processCompiledTemplate(compiled, {
  head: headTags,
  app: appHtml,
  scripts: scriptTags,
})

CompiledTemplate

interface CompiledTemplate {
  parts: [string, string, string, string]
}

The four parts correspond to: before <!--pyreon-head-->, between head and app, between app and scripts, and after <!--pyreon-scripts-->.

Throws if the template does not contain <!--pyreon-app-->.

buildScriptsFast

A pre-optimized variant of buildScripts that accepts a pre-built client entry tag string instead of a path:

import { buildClientEntryTag, buildScriptsFast } from '@pyreon/server'

// Once at startup:
const clientEntryTag = buildClientEntryTag('/client.js')

// Per request:
const scripts = buildScriptsFast(clientEntryTag, loaderData)

This avoids reconstructing the <script type="module" src="..."> tag on every request. Used internally by createHandler.

Note: createHandler uses compileTemplate and buildScriptsFast internally. You only need these APIs when building custom rendering pipelines.


Full Example — SSR Application

Project structure

my-app/
├── index.html
├── src/
│   ├── App.tsx
│   ├── routes.ts
│   ├── entry-client.ts
│   ├── server.ts
│   └── pages/
│       ├── Home.tsx
│       └── About.tsx
├── package.json
└── vite.config.ts

Server entry

import { createHandler } from '@pyreon/server'
import { App } from './App'
import { routes } from './routes'

const template = await Bun.file('index.html').text()

const handler = createHandler({
  App,
  routes,
  template,
  clientEntry: '/src/entry-client.ts',
  middleware: [
    // Add custom headers
    (ctx) => {
      ctx.headers.set('X-Powered-By', 'Pyreon')
    },
  ],
})

Bun.serve({
  fetch: handler,
  port: Number(process.env.PORT ?? 3000),
})

console.log(`Server running at http://localhost:3000`)

Client entry

import { startClient } from '@pyreon/server/client'
import { App } from './App'
import { routes } from './routes'

startClient({ App, routes })

Routes

import type { RouteRecord } from '@pyreon/router'

export const routes: RouteRecord[] = [
  {
    path: '/',
    component: () => import('./pages/Home'),
    loader: async () => {
      const res = await fetch('https://api.example.com/featured')
      return { featured: await res.json() }
    },
  },
  {
    path: '/about',
    component: () => import('./pages/About'),
  },
]

Full Example — Islands Mode

Server

import { createHandler, island } from "@pyreon/server"
import { defineComponent, h } from "@pyreon/core"

// Define islands
const Counter = island(() => import("./components/Counter"), {
  name: "Counter",
})
const Newsletter = island(() => import("./components/Newsletter"), {
  name: "Newsletter",
  hydrate: "visible",
})

// Static page shell — no JavaScript
const Page = defineComponent(() => {
  return () => (
    <main>
      <h1>Welcome</h1>
      <p>This paragraph is static HTML. No JS.</p>

      <Counter initial={0} />

      <section>
        <h2>More static content...</h2>
        <p>Still no JavaScript here.</p>
      </section>

      <Newsletter />
    </main>
  )
})

const handler = createHandler({
  App: Page,
  routes: [{ path: "/", component: Page }],
  clientEntry: "/src/entry-client.ts",
})

Bun.serve({ fetch: handler, port: 3000 })

Client (islands mode)

import { hydrateIslands } from '@pyreon/server/client'

hydrateIslands({
  Counter: () => import('./components/Counter'),
  Newsletter: () => import('./components/Newsletter'),
})

Full Example — Static Site Generation

import { createHandler, prerender } from '@pyreon/server'
import { App } from './src/App'
import { routes } from './src/routes'

const handler = createHandler({ App, routes })

console.log('Building static site...')

const result = await prerender({
  handler,
  paths: async () => {
    // Static pages
    const staticPaths = ['/', '/about', '/contact']

    // Dynamic blog posts from CMS
    const posts = await fetch('https://cms.example.com/api/posts').then(
      (r) => r.json() as Promise<Array<{ slug: string }>>,
    )
    const blogPaths = posts.map((p) => `/blog/${p.slug}`)

    return [...staticPaths, ...blogPaths]
  },
  outDir: 'dist',
  onPage: (path, html) => {
    const kb = (html.length / 1024).toFixed(1)
    console.log(`  ${path}${kb} KB`)
  },
})

console.log(`\nDone! ${result.pages} pages in ${result.elapsed}ms`)

if (result.errors.length > 0) {
  console.error('\nFailed pages:')
  for (const { path, error } of result.errors) {
    console.error(`  ${path}: ${error}`)
  }
  process.exit(1)
}

Platform Compatibility

createHandler returns a standard (Request) => Promise<Response> function. Here's how to use it with different runtimes:

Bun

Bun.serve({ fetch: handler, port: 3000 })

Deno

Deno.serve({ port: 3000 }, handler)

Cloudflare Workers

export default { fetch: handler }

Node.js (with adapter)

import { createServer } from 'node:http'

createServer(async (req, res) => {
  const url = new URL(req.url!, `http://${req.headers.host}`)
  const request = new Request(url.href, {
    method: req.method,
    headers: req.headers as HeadersInit,
  })

  const response = await handler(request)
  res.writeHead(response.status, Object.fromEntries(response.headers))
  res.end(await response.text())
}).listen(3000)

Express

import express from 'express'

const app = express()

app.use(async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`)
  const request = new Request(url.href, {
    method: req.method,
    headers: req.headers as HeadersInit,
  })

  const response = await handler(request)
  res.status(response.status)
  response.headers.forEach((value, key) => res.setHeader(key, value))
  res.send(await response.text())
})

app.listen(3000)

API Reference

Server exports (@pyreon/server)

ExportDescription
createHandler(options)Create an SSR request handler that returns a Web-standard fetch function
prerender(options)Pre-render routes to static HTML files
renderPage(App, router, path, options?)The one string-mode render pipeline shared by the handler, SSG, and dev SSR
island(loader, options)Create an island component for partial hydration
serverIsland(loader, options)Register a per-request server-rendered hole inside a cacheable page
getRegisteredServerIslands()List the registered server islands (the fragment endpoint's name allowlist)
renderServerIslandFragment(name, rawProps, locals?)Render one server-island fragment (what GET /_pyreon/fragment/<name> calls)
useRequestLocals()Read middleware ctx.locals inside components during SSR
provideRequestLocals(locals)Establish the request-locals context for the current render
processTemplate(template, data)Replace placeholders in an HTML template
compileTemplate(template)Pre-split template into parts for fast per-request processing
processCompiledTemplate(compiled, data)Assemble HTML from a pre-compiled template (17x faster on realistic templates)
buildScripts(clientEntry, loaderData)Build script tags for client hydration
buildScriptsFast(clientEntryTag, loaderData)Build script tags with a pre-built entry tag (avoids per-request string construction)
buildClientEntryTag(clientEntry)Build the <script type="module"> tag string once at startup
DEFAULT_TEMPLATEMinimal HTML5 template string with all placeholders

Client exports (@pyreon/server/client)

ExportDescription
startClient(options)Hydrate a full SSR app on the client, returns cleanup function
hydrateIslands(registry)Hydrate island components on the page, returns cleanup function
hydrateIslandsAuto(registry)Hydrate islands from the vite-plugin's auto-generated registry
island(loader, options)Client-safe re-export — use this import path in code that ships to the client
serverIsland(loader, options)Client-safe re-export of the server-island declaration
activateServerIslands(base?)Scan the DOM for <pyreon-server-island> markers and activate them
activateServerIslandElement(el, base?)Activate a single server-island marker element

Type exports

TypeFrom
HandlerOptions@pyreon/server
TemplateData@pyreon/server
IslandOptions@pyreon/server
IslandMeta@pyreon/server
HydrationStrategy@pyreon/server
PrerenderOptions@pyreon/server
PrerenderResult@pyreon/server
Middleware@pyreon/server
MiddlewareContext@pyreon/server
CompiledTemplate@pyreon/server
RenderPageOptions@pyreon/server
RenderPageResult@pyreon/server
ServerIslandOptions@pyreon/server
FragmentResult@pyreon/server
StartClientOptions@pyreon/server/client
@pyreon/server