Server API
createServer(specs, options) starts an HTTP server with all guarantees active. Specs are validated before the server accepts connections. SSR, streaming, brotli compression, immutable asset caching, security headers, CSP nonces, and HSTS are all handled automatically.
createServer(specs, options)
import { createServer } from '@invisibleloop/pulse'
createServer(specs, options)
| Parameter | Type | Description |
|---|---|---|
specs | Spec[] | Array of page spec objects. Validated at startup — a bad spec throws before the server accepts connections. |
options | object | Server configuration options (see below). |
Options
| Option | Type | Default | Description |
|---|---|---|---|
port | number | 3000 | Port to listen on. |
stream | boolean | true | Enable streaming SSR globally. Individual specs also declare a stream field to opt in. |
staticDir | string | undefined | Path to a directory of static files to serve. Relative to the process working directory. |
manifest | string | object | null | Explicit manifest path or object. Overrides auto-detection from staticDir/dist/manifest.json. |
trailingSlash | "remove" | "add" | "allow" | "remove" | "remove" — 301 redirect /about/ → /about. "add" — 301 redirect /about → /about/. "allow" — serve both, no redirect. |
store | object | null | Global store definition (default export from pulse.store.js). See Global Store. |
maxBody | number | 1048576 | Maximum request body size in bytes (default 1 MB). Requests exceeding this limit receive a 413 response. |
defaultCache | boolean | number | object | null | Default HTML cache TTL for all pages in production. true = 1 h + 24 h SWR. A number sets max-age in seconds. An object accepts { public, maxAge, staleWhileRevalidate }. spec.cache overrides per-page. |
fetcherTimeout | number | null | Global timeout in milliseconds for all server fetchers. A fetcher that does not resolve within this limit rejects with a timeout error (→ 500). Override per page with spec.serverTimeout. |
shutdownTimeout | number | 30000 | Milliseconds to wait for in-flight requests to finish during graceful shutdown before force-exiting. See Graceful shutdown. |
healthCheck | string | false | "/healthz" | Path for the built-in health check endpoint. Returns { status: "ok", uptime }. Set to false to disable. The endpoint bypasses onRequest so load balancers always get a response. |
resolveBrand | async (host) => any | undefined | Multi-brand support. Called once per host (cached 60s). Result is attached to ctx.brand and available in guard, server, and meta functions. |
onRequest | function | undefined | Called on every request before routing. Return false to short-circuit Pulse handling. |
onError | function | undefined | Called on unhandled errors. Receives (err, req, res). |
Full example
import { createServer } from '@invisibleloop/pulse'
import home from './src/pages/home.js'
import contact from './src/pages/contact.js'
createServer([home, contact], {
port: 3000,
stream: true,
staticDir: 'public',
onRequest: (req, res) => {
// Add custom headers
res.setHeader('X-My-Header', 'my-value')
// Return false to block a request
if (req.url.startsWith('/admin') && !isAuthenticated(req)) {
res.writeHead(401)
res.end('Unauthorized')
return false
}
// Return undefined (or nothing) to let Pulse handle it
},
onError: (err, req, res) => {
console.error(err)
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'text/html' })
res.end('<h1>Internal Server Error</h1>')
}
},
})
Multi-brand sites
One Pulse server can serve multiple brands, using the request domain as the key. Pass resolveBrand to createServer — it receives the host header and returns a brand config object of any shape you choose. The result is cached per host for 60 seconds and attached to ctx.brand.
// server.js
createServer(specs, {
resolveBrand: async (host) => {
const slug = host.split('.')[0] // 'acme' from 'acme.myco.com'
return db.brands.findBySlug(slug) // { slug, name, accent, logo, ... }
}
})
ctx.brand is available in guard, server fetchers, and any meta field. Meta fields can be functions that receive ctx — Pulse calls them per request:
export default {
route: '/',
meta: {
title: (ctx) => `${ctx.brand.name} — Home`,
description: (ctx) => ctx.brand.tagline,
styles: (ctx) => ['/pulse-ui.css', `/themes/${ctx.brand.slug}.css`],
},
// Expose brand config to the view via server state
server: {
brand: (ctx) => ctx.brand,
},
view: (state, { brand }) => `
<header>
<img src="${brand.logo}" alt="${brand.name}">
<nav>...</nav>
</header>
<main>...</main>
`,
guard: async (ctx) => {
if (!ctx.brand) return { redirect: '/not-found' }
},
}
/pulse-ui.css handles layout and components — each /themes/brand.css only overrides :root variables like --color-accent and --font-heading. Theme files are typically under 1 kB.Startup validation
All specs are validated against the Pulse schema at startup. An invalid spec throws before the server accepts any connections — misconfigured specs are caught immediately, not when a user first hits the route. There is no silent failure path.
Static file serving
When staticDir is set, Pulse serves all files in that directory at their relative path. For example, a file at public/app.css is served at /app.css.
If staticDir/dist/manifest.json exists, Pulse automatically loads it to resolve production hydration bundle paths. No additional configuration is needed.
createServer(specs, {
staticDir: 'public', // serves public/* at /*
// manifest auto-detected from public/dist/manifest.json
})
Response behaviour
| Request type | Response |
|---|---|
| Full page request (GET/HEAD) | SSR HTML with doctype, head, body, and optional hydration script |
X-Pulse-Navigate: true header | JSON: { html, title, hydrate, serverState } for client-side navigation |
| POST/PUT/DELETE to a raw response spec | Handled by spec.render — used for webhooks and API endpoints |
| POST/PUT/DELETE to a page spec | 405 Method Not Allowed |
| Static file | File contents with appropriate Content-Type |
| No matching route | 404 response |
Reading request bodies
Body parsing is available in guard, server.* fetchers, and render (raw specs). All methods are lazy — the stream is consumed once and the result is memoised per request.
| Method | Returns | Description |
|---|---|---|
await ctx.json() | object | null | Parse a JSON request body. Returns null for an empty body. |
await ctx.text() | string | Read the body as a plain string. |
await ctx.formData() | object | null | Parse a URL-encoded body into a plain object. Returns null for an empty body. |
await ctx.buffer() | Buffer | Read the raw body as a Node.js Buffer. |
Bodies larger than maxBody (default 1 MB) are rejected with a 413 before the handler runs. Set maxBody in createServer options to adjust.
Page specs only accept GET and HEAD by default — POST returns 405. To accept other methods, declare spec.methods:
export default {
route: '/contact',
methods: ['GET', 'POST'],
guard: async (ctx) => {
if (ctx.method === 'POST') {
const data = await ctx.formData()
if (!data.email) return { status: 422, json: { error: 'Email required' } }
await db.leads.create(data)
return { redirect: '/contact?sent=1' }
}
},
state: {},
view: () => `<form method="POST">...</form>`,
}
contentType set) accept any HTTP method without spec.methods — they are always method-agnostic.Escaping user data
Import escHtml from @invisibleloop/pulse/html to safely embed untrusted values in HTML view strings:
import { escHtml } from '@invisibleloop/pulse/html'
view: (state) => `
<p>Hello, ${escHtml(state.username)}</p>
`
escHtml around values that originate from user input, URL params, or external APIs. Omitting it is an XSS vulnerability.To use a nonce on a view-authored inline script, pass ctx.nonce through a server fetcher:
server: {
meta: async (ctx) => ({ nonce: ctx.nonce }),
},
view: (state, server) => `
<script nonce="${server.meta.nonce}">console.log('inline ok')</script>
`
Inline scripts without the matching nonce are blocked by the CSP.
Security headers
Pulse sends the following headers on every response — including 404 and 500 errors. There is no configuration required and no way to accidentally omit them:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Content Security Policy
HTML page responses include a Content-Security-Policy header. Scripts require a per-request cryptographic nonce; stylesheets are restricted to same-origin; everything else defaults to 'none':
Content-Security-Policy:
default-src 'none';
script-src 'self' 'nonce-{random}';
style-src 'self';
style-src-attr 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self'
All inline scripts injected by the framework carry a matching nonce attribute. The nonce is also available as ctx.nonce so view functions can attach it to their own inline scripts.
To load resources from external origins — Google Fonts, a CDN, an external API — pass a csp object to createServer. Sources are merged into the framework defaults; existing directives are not replaced:
createServer(specs, {
csp: {
'style-src': ['https://fonts.googleapis.com'],
'font-src': ['https://fonts.gstatic.com'],
'connect-src': ['https://api.example.com'],
'img-src': ['https://images.unsplash.com'],
},
})
style-src-attr 'unsafe-inline' directive is required for inline style="..." attributes used by the UI component library to set CSS custom properties (e.g. spinner size, progress fill). It is scoped to attributes only — <style> blocks are fully nonce-controlled.HSTS
When a request arrives with x-forwarded-proto: https (or over a TLS socket), Pulse adds:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
This is automatic — no configuration required. On plain HTTP the header is omitted. The preload directive means you can submit the domain to hstspreload.org so browsers enforce HTTPS before the first connection — this is a separate manual step, not automatic.
Cookie defaults
Cookies set via ctx.setCookie() default to SameSite=Lax. CSRF protection is on by default — omitting a sameSite option does not weaken it.
Compression
Pulse compresses all compressible responses using brotli (preferred) or gzip (fallback), based on the Accept-Encoding header. Streaming responses use transform streams so compression and delivery happen concurrently.
X-Pulse-Navigate header
When a request includes X-Pulse-Navigate: true, Pulse returns a JSON response instead of full HTML. This is used by the client-side navigation system to swap page content without a full reload:
{
"html": "<main>...rendered content...</main>",
"title": "Page Title — Site",
"hydrate": "/dist/page.boot-abc123.js",
"serverState": { "product": { "id": 1, "name": "..." } }
}
Health check endpoint
Pulse exposes a built-in health check at /healthz (configurable). It responds before onRequest, static file serving, and route matching — so load balancers and orchestration systems always get a response even if a hook is faulty.
GET /healthz → 200 OK
{ "status": "ok", "uptime": 42.3 }
Configure the path or disable it entirely:
createServer(specs, {
healthCheck: '/ping', // custom path
// healthCheck: false, // disable
})
HEAD /healthz is also supported — returns the same status headers with no body. The endpoint sets Cache-Control: no-store so proxies never serve a stale health status.Graceful shutdown
Pulse registers SIGTERM and SIGINT handlers automatically. When either signal arrives:
server.close()stops accepting new connections.- Idle keep-alive sockets are destroyed immediately.
- In-flight requests are allowed to finish naturally.
- After
shutdownTimeoutms (default 30 000 ms), the process force-exits to prevent a stuck request from blocking a deploy indefinitely.
The shutdown() function is also returned from createServer so you can trigger it programmatically:
const { server, shutdown } = createServer(specs, {
port: 3000,
shutdownTimeout: 10000, // 10 s — override the 30 s default
})
// SIGTERM is already wired automatically.
// Call manually when needed — idempotent, safe to call multiple times.
shutdown()