#!/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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
}
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)
})