#!/usr/bin/env node
/**
* Mini SSR dashboard for hyper-svc.
*
* Demonstrates the "app routes coexist with /rpc" pattern: hyper-svc owns
* the http.Server and reserves POST /rpc/<method>; the app registers its own
* 'request' listener for SSR pages.
*
* Pages:
* GET / → status overview (handlers, mounts, transport state)
* GET /handlers → list of registered handlers
* POST /call → form-driven call to any handler (echoes result)
*
* Usage: node examples/web.js [--port 9098] [--storage <path>]
*
* NOTE: Uses its own storage path by default (./.hyper-svc-web). Does NOT
* share state with examples/cli.js — daemons can't share storage anyway.
*/
import { Service } from '../index.js'
import b4a from 'b4a'
import { mkdir } from '#node:fs/promises'
import { join, dirname } from '#node:path'
import process from '#node:process'
const args = process.argv.slice(2)
function getFlag (name) {
const i = args.indexOf('--' + name)
return i !== -1 && args[i + 1] ? args[i + 1] : null
}
const port = parseInt(getFlag('port')) || 9098
const storagePath = getFlag('storage') || './.hyper-svc-web'
const sockPath = join(storagePath, 'svc.sock')
await mkdir(storagePath, { recursive: true })
const svc = new Service({
name: 'demo-web',
uds: sockPath,
http: { port }
})
svc.handle('echo', async (ctx, params) => ({ from: ctx.transport, params }))
svc.handle('uppercase', async (ctx, params) => String(params.text || '').toUpperCase())
svc.handle('reverse', async (ctx, params) => String(params.text || '').split('').reverse().join(''))
svc.handle('time', async () => new Date().toISOString())
// App-side routes — registered on the http.Server BEFORE listen()
svc.http.server.on('request', async (req, res) => {
// Skip /rpc/* — hyper-svc owns those
if (req.url.startsWith('/rpc/')) return
try {
if (req.method === 'GET' && req.url === '/') {
return sendHtml(res, renderIndex())
}
if (req.method === 'GET' && req.url === '/handlers') {
return sendHtml(res, renderHandlers())
}
if (req.method === 'POST' && req.url === '/call') {
const body = await collectBody(req)
const params = new URLSearchParams(b4a.toString(body, 'utf8'))
const method = params.get('method')
const args = params.get('args') || '{}'
let parsed = {}
let result, error
try {
parsed = JSON.parse(args)
} catch (e) {
error = 'bad JSON args: ' + e.message
}
if (!error) {
try {
result = await svc.dispatch(method, parsed, { transport: 'http', identity: null })
} catch (e) {
error = e.message
}
}
return sendHtml(res, renderResult(method, args, result, error))
}
} catch (err) {
res.writeHead(500, { 'Content-Type': 'text/plain' })
res.end(err.message)
}
})
await svc.listen()
console.log('hyper-svc dashboard:')
console.log(' http://localhost:%d', port)
console.log(' uds: %s', sockPath)
console.log(' storage: %s', storagePath)
// ── Helpers ──────────────────────────────────────────────────
function sendHtml (res, html) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(html)
}
function collectBody (req) {
return new Promise((resolve, reject) => {
const parts = []
req.on('data', (c) => parts.push(c))
req.on('end', () => resolve(b4a.concat(parts)))
req.on('error', reject)
})
}
function escapeHtml (s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"')
}
function styles () {
return `
body { font-family: monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 800px; margin: 0 auto; }
h1 { color: #58a6ff; }
h2 { font-size: 1rem; color: #c9d1d9; margin-top: 0; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1rem; margin: 1rem 0; }
.card h2 { margin: 0 0 0.5rem; font-size: 0.75rem; text-transform: uppercase; color: #8b949e; }
.row { display: flex; justify-content: space-between; padding: 0.3rem 0; border-bottom: 1px solid #21262d; }
.row:last-child { border-bottom: none; }
.label { color: #8b949e; }
pre { background: #0d1117; border: 1px solid #30363d; padding: 0.8rem; border-radius: 4px; overflow-x: auto; color: #c9d1d9; }
pre.error { color: #f85149; border-color: #f8514944; }
input, textarea, button, select { background: #0d1117; color: #c9d1d9; border: 1px solid #30363d; padding: 0.4rem 0.6rem; border-radius: 4px; font-family: monospace; font-size: 0.85rem; }
textarea { width: 100%; min-height: 4em; }
input, select { width: 100%; box-sizing: border-box; }
button { background: #1f6feb; border-color: #1f6feb; color: white; cursor: pointer; }
button:hover { background: #1158c7; }
.field { margin-bottom: 0.5rem; }
.field label { display: block; color: #8b949e; font-size: 0.75rem; margin-bottom: 0.2rem; }
.badge-ok { background: #2ea04322; color: #3fb950; border: 1px solid #2ea04344; padding: 0.05rem 0.4rem; border-radius: 4px; font-size: 0.7rem; }
.badge-off { background: #30363d; color: #8b949e; border: 1px solid #484f58; padding: 0.05rem 0.4rem; border-radius: 4px; font-size: 0.7rem; }
`
}
function renderLayout (title, body) {
return `<!DOCTYPE html>
<html><head><title>${escapeHtml(title)}</title><style>${styles()}</style></head><body>
<h1><a href="/">hyper-svc</a> · ${escapeHtml(title)}</h1>
${body}
</body></html>`
}
function renderIndex () {
const transports = [
{ name: 'uds', enabled: !!svc.uds, info: svc.uds ? svc.uds.path : '—' },
{ name: 'http', enabled: !!svc.http, info: svc.http ? '127.0.0.1:' + (svc.http.port || '?') : '—' },
{ name: 'swarm', enabled: !!svc.swarm, info: svc.swarm ? 'topic active' : '—' }
]
const handlers = svc.listHandlers()
const mounts = svc.listMounts()
const body = `
<div class="card">
<h2>Service</h2>
<div class="row"><div class="label">name</div><div>${escapeHtml(svc.name)}</div></div>
<div class="row"><div class="label">handlers</div><div>${handlers.length}</div></div>
<div class="row"><div class="label">mounts</div><div>${mounts.length}</div></div>
</div>
<div class="card">
<h2>Transports</h2>
${transports.map(tr => `
<div class="row">
<div>${escapeHtml(tr.name)} ${tr.enabled ? '<span class="badge-ok">on</span>' : '<span class="badge-off">off</span>'}</div>
<div class="label">${escapeHtml(tr.info)}</div>
</div>`).join('')}
</div>
<div class="card">
<h2>Handlers</h2>
${handlers.length === 0 ? '<div class="label">(none)</div>' : handlers.map(h => `
<div class="row"><div>${escapeHtml(h)}</div></div>`).join('')}
<div style="margin-top:0.8rem"><a href="/handlers">→ try a handler</a></div>
</div>
`
return renderLayout('overview', body)
}
function renderHandlers () {
const handlers = svc.listHandlers()
const body = `
<div class="card">
<h2>Call a handler</h2>
<form action="/call" method="POST">
<div class="field"><label>Method</label>
<select name="method">
${handlers.map(h => `<option value="${escapeHtml(h)}">${escapeHtml(h)}</option>`).join('')}
</select>
</div>
<div class="field"><label>JSON params</label><textarea name="args">{}</textarea></div>
<button type="submit">Call</button>
</form>
</div>
`
return renderLayout('handlers', body)
}
function renderResult (method, args, result, error) {
const body = `
<div class="card">
<h2>Call: ${escapeHtml(method)}</h2>
<div class="label">params</div>
<pre>${escapeHtml(args)}</pre>
${error
? `<div class="label">error</div><pre class="error">${escapeHtml(error)}</pre>`
: `<div class="label">result</div><pre>${escapeHtml(JSON.stringify(result, null, 2))}</pre>`}
<div style="margin-top:0.8rem"><a href="/handlers">← back</a></div>
</div>
`
return renderLayout(`call ${method}`, body)
}