@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:
| Entry | Import | Environment |
|---|---|---|
| Server | @pyreon/server | Node, Bun, Deno, Cloudflare Workers |
| Client | @pyreon/server/client | Browser |
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
Installation
npm install @pyreon/serverbun add @pyreon/serverpnpm add @pyreon/serveryarn 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:
Middleware pipeline — each middleware runs in order. Any middleware can short-circuit by returning a
Response(for redirects, auth checks, etc.).Router creation — a per-request
createRouterinstance is created with the matched URL.Preload —
router.preload(path, request)resolves the matched route's lazy components and runs its loaders, so the synchronous render produces the final page.Render — the app component tree is rendered to HTML (string or stream mode).
Head collection —
@pyreon/headcollects title, meta, and link tags emitted during render.Template injection — head tags, app HTML, and scripts are injected into the HTML template.
Response — a
Responsewithtext/htmlcontent 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:
| Placeholder | Replaced 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.remoteAddressIt'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
| Path | Output file |
|---|---|
/ | outDir/index.html |
/about | outDir/about/index.html |
/blog/hello | outDir/blog/hello/index.html |
/feed.xml | outDir/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:
Router creation — creates a history-mode router for client-side navigation.
Loader data hydration — reads
window.__PYREON_LOADER_DATA__injected by SSR to avoid re-fetching data on initial render.Hydration or mount — if the container has SSR content, it hydrates; otherwise it performs a fresh mount.
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:
Queries all
<pyreon-island>elements in the DOM.For each element, looks up the component loader in the registry by
data-component.Respects the
data-hydratestrategy (load, idle, visible, interaction, media, never).Deserializes
data-propsand hydrates the component in place.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:
| Counter | Meaning |
|---|---|
island.scheduled | Per-island hydration scheduled (idle / visible / etc.) |
island.hydrated | Completed hydrations |
island.skipped.never | Skipped — hydrate: 'never' (zero-JS) |
island.skipped.nested | Skipped — nested island (outer hydrates first, swaps DOM) |
island.skipped.no-loader | Skipped — registry mismatch (loader not found) |
island.error | Hydration error (also surfaces via data-island-error) |
island.prefetch | Prefetch 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:
createHandlerusescompileTemplateinternally (plus an internal pre-built-entry-tag variant ofbuildScripts). 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.tsServer 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)
| Export | Description |
|---|---|
createHandler(options) | Create an SSR request handler that returns a Web-standard fetch function |
REMOTE_ADDRESS | Symbol 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_TEMPLATE | Minimal HTML5 template string with all placeholders |
Client exports (@pyreon/server/client)
| Export | Description |
|---|---|
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
| Type | From |
|---|---|
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 |