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, REMOTE_ADDRESS, prerender, renderPage, island, serverIsland, renderServerIslandFragment, getRegisteredServerIslands, provideRequestLocals, useRequestLocals, processTemplate, compileTemplate, processCompiledTemplate, buildScripts, DEFAULT_TEMPLATE

Client exports: startClient, hydrateIslands, hydrateIslandsAuto, island, serverIsland, activateServerIslands, activateServerIslandElement, schedulePrefetch, scheduleHydration

@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>. Receives the per-request CSP nonce (undefined when
   * there is none) — forward it to `sheet.getStyleTag(nonce)` so a strict
   * `style-src 'nonce-…'` policy admits the emitted tag.
   *
   *   collectStyles: (nonce) => {
   *     const tag = sheet.getStyleTag(nonce)
   *     sheet.reset()
   *     return tag
   *   }
   *
   * **You usually don't need to pass this at all when using `@pyreon/styler`.**
   * `createHandler` only forwards `collectStyles` to the shared render
   * pipeline when you explicitly set it — when omitted, the pipeline falls
   * back to `globalThis.__PYREON_STYLER_COLLECT__`, which styler's own
   * singleton registers on SSR module init. So a plain `createHandler({ App, routes })`
   * already ships the correct CSS with no FOUC, and `collectStyles` is only
   * needed to override the default (a custom flush/reset sequence, a
   * different CSS-in-JS library).
   */
  collectStyles?: (nonce?: string) => string
  /**
   * Per-boundary Suspense timeout in ms for `mode: "stream"` (default
   * `30_000`). Ignored in `mode: "string"`. Set lower for tight-SLA pages
   * where the fallback is preferable to a delayed render; set to `Infinity`
   * to disable the timeout for renders that legitimately need long async
   * work. Values ≤0 or `NaN` fall back to the default.
   */
  suspenseTimeoutMs?: number
  /**
   * The app's base path for subpath deploys. Requests under it are routed
   * with the prefix removed, and RouterLink hrefs render with the prefix.
   *   createHandler({ App, routes, base: '/app/' })
   */
  base?: string
}

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. Preload — router.preload(path, request) resolves the matched route's lazy components and runs its loaders, so the synchronous render produces the final page.

  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 PLUS search (`/admin?x=1`) — the router's input. Do NOT match
   * routes against it directly: an EXACT comparison (`ctx.path === '/admin'`)
   * is bypassed by any query string. Use `ctx.url.pathname` for exact/segment
   * matching; `ctx.path.startsWith(...)` is safe for prefix checks either way.
   */
  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>
}

The client's real socket address — REMOTE_ADDRESS

ctx.locals.remoteAddress is populated automatically — if the runtime adapter set it. REMOTE_ADDRESS is a Symbol.for('pyreon.remoteAddress') key: createHandler looks for it on the incoming Request object and, when present, copies its value into ctx.locals.remoteAddress before any middleware runs (so rateLimitMiddleware and request logging can key on it).

import { REMOTE_ADDRESS } from '@pyreon/server'

// In a runtime adapter's request-handling code, BEFORE calling the handler:
;(req as unknown as Record<symbol, unknown>)[REMOTE_ADDRESS] = socket.remoteAddress

It's a Symbol.for (global symbol registry) key rather than a plain field so an adapter's generated runner can set it without depending on / bundling @pyreon/server at all. Deliberately NOT read from a header (X-Forwarded-For etc.) — a header is spoofable by the client; this can only be set by code that holds the raw Request before the handler ever sees it (the actual TCP/TLS layer).

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/index.html

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 === 'unmatched') { /* only when bailOnUnmatched: true */ }
if (result.kind === 'html') {
  // result.appHtml, result.head, result.loaderScript, result.status (200 | 404), result.routeModules
}

options carries request (forwarded to loaders — router.preload runs entirely inside runWithRequestContext, so loaders observe the same per-request context/store isolation the production handler gives them), skipLoaders, collectStyles, locals (bridged to useRequestLocals()), and bailOnUnmatched (returns { kind: 'unmatched' } instead of rendering — used by the dev middleware's static-404 fall-through).

result.head already carries the styler <style> tag prepended (via collectStyles, including the globalThis.__PYREON_STYLER_COLLECT__ default described under HandlerOptions.collectStyles above) when it's non-empty; result.routeModules is the matched chain's lazy-component source ids, used by the SSG plugin to emit per-route <link rel=modulepreload> — empty for a non-lazy chain.

renderPage is the ONE string-mode render pipeline shared by createHandler (mode: 'string'), zero's SSG prerender, and zero's dev-server SSR middleware — a per-page concern added here (the styler <style> tag, noindex injection, loader-data serialization) reaches all three consumers at once instead of drifting across copies.


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/submit replay

hydrate: 'interaction' defers hydration until first user interaction (focus / click / pointerenter / touchstart / submit by default — the first matching event triggers hydration and removes every listener, one-shot). Customize via 'interaction(<events>)', e.g. 'interaction(click,touchstart)'. Only click and form submit are replayed on the equivalent live element post-hydration (as a real MouseEvent/SubmitEvent) so the user's first click/submit both wakes the island AND fires the action — closes the "user clicks but nothing happens until they click again" UX trap. focus/pointerenter/touchstart wake the island but are NOT replayed (focus in particular can't be reliably re-fired once the user has tabbed past it). 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 client-side island hydration machinery (@pyreon/server/client) 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 JSON is serialized via @pyreon/router's stringifyLoaderData — it neutralizes the whole < class (not just </script> — a bare </-only escape is bypassable via <!--<script>, which the HTML tokenizer treats as a script-data-double-escape opener with no slash involved) plus the U+2028/U+2029 line terminators JSON permits but a <script> body doesn't, and throws a [Pyreon]-prefixed error naming the offending key on a circular reference instead of a bare Converting circular structure to JSON.

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-->.

Note: createHandler uses compileTemplate internally (plus an internal pre-built-entry-tag variant of buildScripts). 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
REMOTE_ADDRESSSymbol key an adapter sets on Request carrying the client's socket address, copied into ctx.locals.remoteAddress
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
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
schedulePrefetch(el, loader, strategy)Schedule an island chunk prefetch ('idle' / 'visible'); used by island()'s self-hydration
scheduleHydration(el, loader, propsJson, strategy, …)Schedule an island hydration per its strategy; used by island()'s self-hydration

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