OverviewFilesCommitsIssuesDocsReleasesPeers

mirall-cli / node_modules / hyper-svc / examples / web.js

220 lines · javascript ref:
#!/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, '&amp;').replace(/</g, '&lt;')
    .replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

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)
}