Deploying
What you deploy depends on how your pages render:
- Only static and client-rendered pages: a folder of files. Any static host serves it.
- Any page rendered per request: a small server that runs your app, plus the same files.
Either way, the build output is ordinary: HTML, content-hashed JavaScript and CSS, and your public files. Gyral adds no runtime service of its own. If your server is written in Rust, see Rust servers.
Static hosts
Build with the layout from Static sites and prerendering: Vite and the
prerender step both write to dist/. Put fixed headers files in public/, which Vite copies
into dist/ as they are, and write the Content Security Policy at build time
(Headers and CSP). On every host the build command is
npm run build and the output directory is dist; what differs is where response headers (CSP,
caching) go:
| Host | Response headers |
|---|---|
| Cloudflare Pages | a _headers file in the output |
| Netlify | a _headers file in the output, or netlify.toml |
| Vercel | headers in vercel.json (framework preset: Other) |
| GitHub Pages | not configurable; use a <meta> CSP (see below) |
GitHub Pages builds in a GitHub Actions workflow that uploads dist as its Pages artifact.
This website is the worked example: it builds with pnpm run build on Cloudflare Pages, with
NODE_VERSION set to 24. Its _headers file holds the security and cache headers, and the build
adds the Content Security Policy to it.
Hosts that serve about/index.html for /about/ (all four above do) need no rewrite rules.
Set the host's 404 page to your prerendered 404.html if you render one.
Node
When some pages render per request, build into two folders and run Gyral's production server.
npm create gyral@latest my-app -- --template ssr sets up a project like this one; the files
below are a trimmed-down version.
// src/counter.ts
import { define, html } from '@gyral/core';
export const Counter = define<{ readonly count: number }, { readonly _tag: 'Increment' }>()(
'my-counter',
{
init: () => ({ count: 0 }),
intent: { Increment: true },
update: { Increment: (s) => ({ count: s.count + 1 }) },
view: (s, i) => html`
<output aria-live="polite">${s.count}</output>
<button type="button" data-intent=${i.Increment}>Increment</button>
`,
},
);// server/app.ts
import { Hono } from 'hono';
import { html } from '@gyral/core';
import { renderPage } from '@gyral/ssr';
import type { AppAssets } from '@gyral/ssr/static';
import '../src/counter.js';
/** Paths rendered at build time into dist/static. Everything else renders per request. */
export const staticPaths: readonly string[] = ['/'];
export function createApp({ components, stylesheets }: AppAssets): Hono {
const app = new Hono();
app.get('/', () =>
renderPage({
title: 'Home',
body: html`<my-counter></my-counter>`,
stylesheets,
// Each page loads and preloads exactly the components it rendered.
...(components === undefined ? {} : { components }),
}),
);
app.get('/hello/:name', (c) =>
renderPage({ title: 'Hello', body: html`<h1>Hello, ${c.req.param('name')}</h1>` }),
);
return app;
}// server/prod.ts
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
import { toNodeListener } from '@gyral/ssr/node';
import { productionServer } from '@gyral/ssr/static';
import { createApp } from './app.js';
const app = await productionServer({
distDir: fileURLToPath(new URL('../dist', import.meta.url)),
createApp,
});
// origin: the public origin request URLs are built on (by default, the Host header).
createServer(toNodeListener(app.fetch, { origin: 'https://example.com' })).listen(
Number(process.env['PORT'] ?? 3000),
);productionServer({ distDir, createApp }) expects Vite's output in dist/client/ (with
build.manifest: true and gyralVitePreset({ components: true })) and prerendered pages in
dist/static/. It reads the manifests once and hands createApp the components for
renderPage and the hashed CSS as stylesheets. With a client entry of your own instead, it
hands over clientEntry and the chunks to preload with it as modulepreload (Gyral's hydration
chunk included). A page whose
route module is imported lazily spreads assets(['src/routes/product.ts']) (also given to
createApp) into renderPage instead: it returns { modulepreload, stylesheets } with that
module, its imports and its CSS added. It answers:
| Request | Served from | cache-control |
|---|---|---|
GET/HEAD /assets/* |
assetsDir (default dist/client/assets/) |
public, max-age=31536000, immutable |
GET/HEAD of a prerendered path |
staticDir (default dist/static/) |
public, max-age=0, must-revalidate |
anything else (other GETs, every POST) |
your app | no-cache, unless your app set one |
Assets carry their content-type, content-length and x-content-type-options: nosniff, and
stay in memory once served (up to a bound; cache: false reads them from disk every time). A
malformed URL is a 400. A path that leads outside assetsDir, or a missing file, is a 404 with
cache-control: no-store, so a CDN never caches a miss while a deploy lands. Pass
staticDir: false if nothing is prerendered, so page requests don't look for a file first.
A single Range request (bytes=0-1023) gets 206 Partial Content with that slice, so an
imported video or audio file can seek; every asset says accept-ranges: bytes. A range past the
end is a 416, and several ranges or a malformed header get the whole file.
Served under a path, with Vite's base: '/app/'? Pass the same base to productionServer:
productionServer({ distDir, createApp, base: '/app/' }) puts it in front of the entry,
preload and stylesheet URLs and serves assets at /app/assets/. For a static build, pass it to
the manifest helpers as their last argument:
clientAssetsFromManifest(path, entry, [], { base: '/app/' }).
After a deploy, tabs opened before it still ask for the old release's chunks. To keep serving
them, point assetsDir at a volume that keeps every release's files, or use assetHandler
from @gyral/ssr/static on its own. It answers under /assets/ and returns undefined for
any other path, so it fits in front of any router:
// server/assets.ts
import { assetHandler } from '@gyral/ssr/static';
// A volume that keeps the hashed files of every release.
const assets = assetHandler({ dir: '/srv/releases/assets' });
export async function handle(request: Request): Promise<Response> {
return (await assets(request)) ?? new Response('Not found', { status: 404 });
}productionServer({ onResponse }) sees every response before it goes out, prerendered files and
assets included, so security headers or a cookie are one function:
onResponse: (response) => { response.headers.set('x-frame-options', 'DENY'); return response; }.
productionServer returns a plain { fetch }. Mount it on Node's http module with
createServer(toNodeListener(app.fetch, { origin })) from @gyral/ssr/node, as above, or on any
server that takes a fetch handler, or as a route in a larger Hono app. toNodeListener streams
request bodies in, writes each chunk of the page only as fast as the client reads it, and aborts
request.signal when the client leaves. Set origin when your pages build absolute URLs from
the request: otherwise it comes from the Host header, which the client chooses.
The client's address
A Request doesn't say who sent it. toNodeListener passes the handler a second argument,
{ incoming, remoteAddress }: Node's request object and the client's IP address, for rate
limits, logs and audits.
// server/rate-limit.ts
import { createServer } from 'node:http';
import { toNodeListener } from '@gyral/ssr/node';
const LIMIT = 100;
const hits = new Map<string, number>();
createServer(
toNodeListener((_request, { remoteAddress }) => {
const client = remoteAddress ?? 'unknown';
const count = (hits.get(client) ?? 0) + 1;
hits.set(client, count);
return count > LIMIT
? new Response('Too many requests', { status: 429, headers: { 'retry-after': '60' } })
: new Response('ok');
}),
).listen(3000);- Behind a proxy or a load balancer,
remoteAddressis the proxy's address. The client's is in a header such asX-Forwarded-For, but anyone can send that header. Read it only when the request came from a proxy you run (remoteAddressis that proxy's address), and take the address your proxy added, the last one in the list, not the first. remoteAddressisundefinedonce the connection has closed, so give it a fallback.- A Hono app gets the object as
c.env, the shape Hono's own Node adapter passes, sogetConnInfo(c)from@hono/node-server/conninfoworks with it.
Bun, Deno and Cloudflare Workers
renderPage returns a web-standard Response (chunked, from a synchronous render), so
per-request rendering fits any runtime that speaks fetch:
// Bun
Bun.serve({ fetch: app.fetch });
// Deno
Deno.serve(app.fetch);
// Cloudflare Workers
export default { fetch: app.fetch };How far each one is tested today:
- Bun:
renderPagewith Declarative Shadow DOM output andcontentSecurityPolicy()were checked by hand on Bun 1.3.14 with Gyral 0.3. It isn't part of Gyral's CI. - Deno 2.9.6: the same self-contained SSR bundle passes native stream chunking, declarative shadow roots, slotted content, request-store isolation, CSP and cancellation checks without a DOM shim or runtime permissions.
- Cloudflare Workers: those checks pass in local workerd through Wrangler 4.147.0, with
compatibility date
2025-01-01and nonodejs_compatflag. This verifies the local runtime; deployed bindings, asset hosting and edge behavior still need your application's checks. @gyral/ssr/static(prerender,productionServer,assetHandler) reads and writes files withnode:fs. Use it in Node at build time; on an edge runtime, serve the static files from the platform's asset hosting instead.@gyral/ssr/nodeis for Node'shttpmodule only; the runtimes above takeapp.fetchdirectly.
Run the networked pnpm verify:platforms in the framework repository to reproduce the Deno
and workerd checks. The
spike report
describes the tested boundary.
Cache headers
productionServer and assetHandler set these for you:
| Response | cache-control |
|---|---|
content-hashed files (/assets/*) |
public, max-age=31536000, immutable |
| prerendered pages | public, max-age=0, must-revalidate |
| pages rendered per request | no-cache, unless your app set one |
| misses and errors | no-store |
Writing your own server, set the header on the Response, as renderPage(options, { headers: { 'cache-control': 'no-cache' } }).
Content Security Policy
Gyral works under a strict policy:
- No
'unsafe-eval'. This website's components hydrate and run under a policy without it, and its build fails on any CSP violation. - No inline scripts. Hydration seeds are
data-gyral-seedattributes, not script elements, and your client entry is a module file. - No
'unsafe-inline'for styles. Declarative Shadow DOM writes each component's styles as a<style>element inside its<template>, andrenderPage'sstylesoption writes<style>in the head.renderPage({ csp: { directives } })lists all of them by hash in the header it sends, andcontentSecurityPolicy()from@gyral/ssrbuilds the same value for a static headers file (see Server rendering).
A good starting point, with the hashes appended by Gyral:
Content-Security-Policy: default-src 'self'; script-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; style-src 'self' 'sha256-…'This website's policy is the same, plus font-src and connect-src for its own files and
'wasm-unsafe-eval', which only its search index needs. Linked stylesheets need no hash:
style-src 'self' allows them. style="…" attributes in server HTML are the one thing the
policy blocks: they apply only once their component hydrates (see
Styling), so this site's pages have none. On
GitHub Pages, which can't set headers, put the policy in a
<meta http-equiv="Content-Security-Policy"> element through renderPage's extraHead option;
frame-ancestors doesn't work there.
Checklist
- Build for production and test it before deploying: hydration problems can appear only in
bundled builds.
@gyral/testing'smountSsrandhydratedmake that a unit test. - Cache hashed assets for a year and pages not at all (or with revalidation).
- Serve a CSP header:
renderPage({ csp: { directives } })per request, orcontentSecurityPolicy()written into a static host's headers file. Gyral needs no'unsafe-inline'and no'unsafe-eval'.