GitHub

Extending Pulse

Pulse handles the standard request-response lifecycle through specs. For requirements outside that model — middleware, WebSockets, SSE, custom error pages — Pulse exposes deliberate integration points directly on the underlying Node.js server. There is no abstraction layer to fight through.

Request interception with onRequest

onRequest is a hook on createServer that fires on every incoming request before Pulse handles it. Return false to short-circuit Pulse entirely and handle the response yourself.

// server.js
import { createServer } from '@invisibleloop/pulse'
import home from './src/pages/home.js'

createServer([home], {
  port: 3000,
  onRequest(req, res) {
    // Logging
    console.log(req.method, req.url)

    // Block a path entirely
    if (req.url === '/internal') {
      res.writeHead(403)
      res.end('Forbidden')
      return false  // stops Pulse processing this request
    }

    // Custom header on every response
    res.setHeader('X-App-Version', '1.0.0')
    // returning nothing (or undefined) lets Pulse continue normally
  },
})
onRequest runs before routing, guard, and all server fetchers. Returning false gives full control of the response — Pulse steps aside entirely for that request.

Common uses:

Use caseApproach
Request loggingLog req.method, req.url, timing
IP allowlistingCheck req.socket.remoteAddress, return false with 403 if blocked
Rate limitingTrack request counts in a Map, return false with 429 when exceeded
Custom response headersCall res.setHeader() before returning
Health check endpointMatch /healthz, write 200 ok, return false

Accessing the raw server

createServer returns a { server } object where server is a plain Node.js http.Server. You can attach any listener to it directly.

const { server } = createServer([home], { port: 3000 })

// The server instance is available immediately after createServer() returns.
// It starts listening automatically — no need to call server.listen().
server.on('listening', () => {
  console.log('ready')
})

WebSockets

Use the upgrade event on the server instance. The ws package handles the WebSocket handshake and framing.

npm install ws
// server.js
import { WebSocketServer } from 'ws'
import { createServer } from '@invisibleloop/pulse'
import home from './src/pages/home.js'

const { server } = createServer([home], { port: 3000 })

const wss = new WebSocketServer({ noServer: true })

server.on('upgrade', (req, socket, head) => {
  if (req.url === '/ws') {
    wss.handleUpgrade(req, socket, head, (ws) => {
      wss.emit('connection', ws, req)
    })
  } else {
    socket.destroy()
  }
})

wss.on('connection', (ws) => {
  ws.send('connected')
  ws.on('message', (msg) => ws.send(`echo: ${msg}`))
})

The Pulse-rendered page can connect to the WebSocket using an inline script. Pass ctx.nonce through a server fetcher so the script is allowed by the CSP:

server: {
  meta: async (ctx) => ({ nonce: ctx.nonce }),
},

view: (_state, server) => `
  <main id="main-content">
    <p id="status">Connecting…</p>
    <script nonce="${server.meta.nonce}">
      const ws = new WebSocket('ws://' + location.host + '/ws')
      ws.onmessage = (e) => {
        document.getElementById('status').textContent = e.data
      }
    </script>
  </main>
`,

Server-Sent Events

SSE keeps an HTTP connection open and streams events to the browser. Use onRequest to intercept the SSE path and write to the response directly.

onRequest(req, res) {
  if (req.url !== '/events') return  // let Pulse handle everything else

  res.writeHead(200, {
    'Content-Type':  'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection':    'keep-alive',
  })

  // Send a keepalive comment every 15 seconds
  const keepalive = setInterval(() => res.write(': keepalive\n\n'), 15000)

  // Send an event
  function send(event, data) {
    res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
  }

  send('connected', { time: Date.now() })

  req.on('close', () => clearInterval(keepalive))

  return false
},

Custom error handling

onError in createServer receives unhandled errors from server fetchers and guard functions. Use it to log errors to an external service or render a custom error page.

createServer([home], {
  port: 3000,

  onError(err, req, res) {
    // Log to your error tracking service
    console.error(err)

    // Respond with a custom error page
    res.writeHead(500, { 'Content-Type': 'text/html' })
    res.end(`
      <!doctype html>
      <html lang="en">
        <head><title>Error</title></head>
        <body>
          <main id="main-content">
            <h1>Something went wrong</h1>
            <p>We've been notified and are looking into it.</p>
          </main>
        </body>
      </html>
    `)
  },
})

Custom client-side JavaScript

Pulse has no client-side JS of its own beyond the hydration runtime. For behaviour that genuinely needs to run in the browser — third-party widgets, analytics, canvas — use an inline script in the view with the request nonce. The nonce is unique per request and is required for the script to pass the CSP.

server: {
  meta: async (ctx) => ({ nonce: ctx.nonce }),
},

view: (_state, server) => `
  <main id="main-content">
    <canvas id="chart" width="600" height="300"></canvas>
    <script nonce="${server.meta.nonce}">
      const ctx = document.getElementById('chart').getContext('2d')
      // draw directly with Canvas API — no library needed for simple charts
      ctx.fillStyle = '#9b8dff'
      ctx.fillRect(10, 10, 100, 80)
    </script>
  </main>
`,
External scripts loaded via src also need the nonce attribute, or their domain must be added to the script-src directive in the CSP. The nonce approach is simpler and does not require config changes.

Choosing the right approach

What you needReach for
Middleware — logging, rate limiting, custom headersonRequest
Non-HTML responses — JSON APIs, webhooks, RSS, sitemapsRaw response spec (contentType + render)
Real-time bidirectional communicationWebSockets via server.on('upgrade')
Server-pushed updates (read-only stream)SSE via onRequest
Custom error pagesonError
Browser-only behaviourInline <script nonce> in the view