OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-rooms / examples / web.js

211 lines · javascript ref:
#!/usr/bin/env node
/**
 * Mini web example for hyper-rooms
 *
 * SSR dashboard for a "todos" room. Lets you add, check, and delete todos
 * via HTML form submissions. Shows the room key, local writer key, and
 * writer list.
 *
 * Usage: node examples/web.js [--port 9095] [--storage <path>]
 */

import http from 'http'
import Corestore from 'corestore'
import { Room, jsonSchema } from '../index.js'
import b4a from 'b4a'

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')) || 9095
// NOTE: distinct default from examples/cli.js — they don't share state.
const storagePath = getFlag('storage') || './.hyper-rooms-web'

const todosSchema = jsonSchema({
  async apply (batch, op, host) {
    if (op.type === 'add-todo') {
      await batch.put(`todo:${op.id}`, {
        id: op.id,
        text: op.text,
        done: false,
        createdAt: op.timestamp
      })
      const counter = await batch.get('meta:next-id')
      await batch.put('meta:next-id', { value: Math.max(counter?.value?.value || 1, op.id + 1) })
    } else if (op.type === 'check-todo') {
      const e = await batch.get(`todo:${op.id}`)
      if (e) await batch.put(`todo:${op.id}`, { ...e.value, done: true })
    } else if (op.type === 'delete-todo') {
      await batch.del(`todo:${op.id}`)
    }
  }
})

const store = new Corestore(storagePath)
await store.ready()
const room = new Room(store, { schema: todosSchema, namespace: 'todos' })
await room.ready()

function escapeHtml (s) {
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

function collectBody (req) {
  return new Promise((resolve, reject) => {
    const parts = []
    req.on('data', chunk => parts.push(chunk))
    req.on('end', () => resolve(b4a.concat(parts).toString('utf8')))
    req.on('error', reject)
  })
}

async function nextId () {
  const counter = await room.view.get('meta:next-id')
  return counter ? counter.value.value : 1
}

async function listTodos () {
  const out = []
  for await (const entry of room.view.createReadStream({
    gte: 'todo:', lt: 'todo:\xff'
  })) {
    out.push(entry.value)
  }
  return out
}

const httpServer = http.createServer(async (req, res) => {
  const url = req.url.split('?')[0]
  try {
    if (req.method === 'GET' && url === '/') {
      const todos = await listTodos()
      const writers = await room.listWriters()
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
      res.end(renderPage(todos, writers))
      return
    }

    if (req.method === 'POST' && url === '/add') {
      const params = new URLSearchParams(await collectBody(req))
      const text = params.get('text')
      if (text && room.writable) {
        const id = await nextId()
        await room.append({ type: 'add-todo', id, text, timestamp: Date.now() })
        await room.base.update()
      }
      res.writeHead(303, { Location: '/' })
      res.end()
      return
    }

    if (req.method === 'POST' && url === '/check') {
      const params = new URLSearchParams(await collectBody(req))
      const id = parseInt(params.get('id'))
      if (id && room.writable) {
        await room.append({ type: 'check-todo', id })
        await room.base.update()
      }
      res.writeHead(303, { Location: '/' })
      res.end()
      return
    }

    if (req.method === 'POST' && url === '/del') {
      const params = new URLSearchParams(await collectBody(req))
      const id = parseInt(params.get('id'))
      if (id && room.writable) {
        await room.append({ type: 'delete-todo', id })
        await room.base.update()
      }
      res.writeHead(303, { Location: '/' })
      res.end()
      return
    }

    res.writeHead(404, { 'Content-Type': 'text/plain' })
    res.end('Not found')
  } catch (err) {
    console.error(err)
    res.writeHead(500, { 'Content-Type': 'text/plain' })
    res.end(err.message)
  }
})

function renderPage (todos, writers) {
  return `<!DOCTYPE html>
<html><head><title>hyper-rooms</title>
<style>
  body { font-family: monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 720px; margin: 0 auto; }
  h1 { color: #58a6ff; }
  .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; }
  .label { color: #8b949e; }
  .value { font-family: monospace; color: #79c0ff; word-break: break-all; max-width: 70%; text-align: right; font-size: 0.8rem; }
  .todo { display: flex; align-items: center; padding: 0.4rem 0; border-bottom: 1px solid #21262d; }
  .todo:last-child { border-bottom: none; }
  .todo .text { flex: 1; margin-left: 0.5rem; }
  .done .text { text-decoration: line-through; color: #6e7681; }
  input, button { background: #0d1117; color: #c9d1d9; border: 1px solid #30363d; padding: 0.4rem 0.6rem; border-radius: 4px; font-family: monospace; font-size: 0.85rem; }
  button { background: #21262d; cursor: pointer; }
  button:hover { background: #30363d; }
  button.btn-primary { background: #1f6feb; border-color: #1f6feb; color: white; }
  button.btn-primary:hover { background: #1158c7; }
  button.btn-small { padding: 0.15rem 0.4rem; font-size: 0.75rem; }
  .empty { color: #484f58; font-style: italic; padding: 1rem 0; }
  form { margin: 0; display: inline; }
</style></head><body>
<h1>hyper-rooms (todos)</h1>

<div class="card">
  <h2>Room</h2>
  <div class="row"><span class="label">key</span><span class="value">${escapeHtml(room.keyHex || '')}</span></div>
  <div class="row"><span class="label">localKey</span><span class="value">${escapeHtml(room.localKeyHex || '')}</span></div>
  <div class="row"><span class="label">writable</span><span class="value">${room.writable}</span></div>
  <div class="row"><span class="label">writers</span><span class="value">${writers.length}</span></div>
</div>

<div class="card">
  <h2>Add todo</h2>
  <form action="/add" method="POST">
    <input name="text" placeholder="What needs doing?" size="40" required>
    <button class="btn-primary" type="submit"${room.writable ? '' : ' disabled'}>Add</button>
  </form>
  ${room.writable ? '' : '<div class="empty" style="margin-top:0.5rem">Room is not writable from this peer.</div>'}
</div>

<div class="card">
  <h2>Todos (${todos.length})</h2>
  ${todos.length === 0 ? '<div class="empty">(none yet)</div>' : todos.map(t => `
  <div class="todo${t.done ? ' done' : ''}">
    <form action="/check" method="POST">
      <input type="hidden" name="id" value="${t.id}">
      <button class="btn-small" type="submit"${t.done ? ' disabled' : ''}>${t.done ? '✓' : '○'}</button>
    </form>
    <span class="text">#${t.id} ${escapeHtml(t.text)}</span>
    <form action="/del" method="POST">
      <input type="hidden" name="id" value="${t.id}">
      <button class="btn-small" type="submit">×</button>
    </form>
  </div>`).join('')}
</div>

<div class="card">
  <h2>Writers</h2>
  ${writers.length === 0 ? '<div class="empty">(none)</div>' : writers.map(w => `
  <div class="row"><span class="label">${escapeHtml(w.userId)} <small>(${escapeHtml(w.role)})</small></span><span class="value">${escapeHtml((w.writerKey || '').slice(0, 16))}...</span></div>`).join('')}
</div>

</body></html>`
}

httpServer.listen(port, () => {
  console.log('hyper-rooms todos demo:')
  console.log('  http://localhost:%d', port)
  console.log('  storage: %s', storagePath)
  console.log('  room key: %s', room.keyHex)
})