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 case | Approach |
|---|---|
| Request logging | Log req.method, req.url, timing |
| IP allowlisting | Check req.socket.remoteAddress, return false with 403 if blocked |
| Rate limiting | Track request counts in a Map, return false with 429 when exceeded |
| Custom response headers | Call res.setHeader() before returning |
| Health check endpoint | Match /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>
`,
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 need | Reach for |
|---|---|
| Middleware — logging, rate limiting, custom headers | onRequest |
| Non-HTML responses — JSON APIs, webhooks, RSS, sitemaps | Raw response spec (contentType + render) |
| Real-time bidirectional communication | WebSockets via server.on('upgrade') |
| Server-pushed updates (read-only stream) | SSE via onRequest |
| Custom error pages | onError |
| Browser-only behaviour | Inline <script nonce> in the view |