GitHub

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)
ParameterTypeDescription
specsSpec[]Array of page spec objects. Validated at startup — a bad spec throws before the server accepts connections.
optionsobjectServer configuration options (see below).

Options

OptionTypeDefaultDescription
portnumber3000Port to listen on.
streambooleantrueEnable streaming SSR globally. Individual specs also declare a stream field to opt in.
staticDirstringundefinedPath to a directory of static files to serve. Relative to the process working directory.
manifeststring | objectnullExplicit 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.
storeobjectnullGlobal store definition (default export from pulse.store.js). See Global Store.
maxBodynumber1048576Maximum request body size in bytes (default 1 MB). Requests exceeding this limit receive a 413 response.
defaultCacheboolean | number | objectnullDefault 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.
fetcherTimeoutnumbernullGlobal 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.
shutdownTimeoutnumber30000Milliseconds to wait for in-flight requests to finish during graceful shutdown before force-exiting. See Graceful shutdown.
healthCheckstring | 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.
resolveBrandasync (host) => anyundefinedMulti-brand support. Called once per host (cached 60s). Result is attached to ctx.brand and available in guard, server, and meta functions.
onRequestfunctionundefinedCalled on every request before routing. Return false to short-circuit Pulse handling.
onErrorfunctionundefinedCalled 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' }
  },
}
Keep brand theme differences in CSS custom properties. One /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 typeResponse
Full page request (GET/HEAD)SSR HTML with doctype, head, body, and optional hydration script
X-Pulse-Navigate: true headerJSON: { html, title, hydrate, serverState } for client-side navigation
POST/PUT/DELETE to a raw response specHandled by spec.render — used for webhooks and API endpoints
POST/PUT/DELETE to a page spec405 Method Not Allowed
Static fileFile contents with appropriate Content-Type
No matching route404 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.

MethodReturnsDescription
await ctx.json()object | nullParse a JSON request body. Returns null for an empty body.
await ctx.text()stringRead the body as a plain string.
await ctx.formData()object | nullParse a URL-encoded body into a plain object. Returns null for an empty body.
await ctx.buffer()BufferRead 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>`,
}
Raw response specs (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>
`
Always use 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'],
  },
})
The 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.

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:

  1. server.close() stops accepting new connections.
  2. Idle keep-alive sockets are destroyed immediately.
  3. In-flight requests are allowed to finish naturally.
  4. After shutdownTimeout ms (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()
Idle keep-alive sockets are destroyed immediately on shutdown. In-flight streaming responses finish sending before the socket is closed — no partial responses are delivered to clients.