#!/usr/bin/env node
/**
* Mini web UI example for hyper-overlay
*
* Usage:
* node examples/web.js [--port 9092] [--storage <dir>]
*
* Starts an overlay instance with HTTP dashboard for browsing,
* uploading, and downloading files.
*/
import http from 'http'
import fs from 'fs'
import Corestore from 'corestore'
import { HyperOverlay } from '../index.js'
const args = process.argv.slice(2)
function getFlag (name) {
const i = args.indexOf('--' + name)
return i !== -1 && args[i + 1] ? args[i + 1] : null
}
function fmt (b) {
if (b < 1024) return b + ' B'
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'
return (b / 1048576).toFixed(1) + ' MB'
}
const port = parseInt(getFlag('port')) || 9092
// NOTE: distinct default from examples/cli.js — they don't share state.
// RocksDB single-writer lock; see cli.js header for the rationale.
const storageDir = getFlag('storage') || './.overlay-web'
const store = new Corestore(storageDir)
const overlay = new HyperOverlay(store, { namespace: 'example' })
function collectBody (req, max) {
return new Promise((resolve, reject) => {
const parts = []
let size = 0
req.on('data', chunk => {
size += chunk.length
if (size > max) { req.destroy(); reject(new Error('Too large')) }
parts.push(chunk)
})
req.on('end', () => resolve(Buffer.concat(parts)))
req.on('error', reject)
})
}
const httpServer = http.createServer(async (req, res) => {
const url = req.url.split('?')[0]
const params = new URLSearchParams((req.url.split('?')[1] || ''))
try {
if (url === '/api/files') {
const dir = params.get('dir') || ''
const files = await overlay.list(dir)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(files))
return
}
if (url === '/api/stats') {
const s = await overlay.stats()
const files = await overlay.list()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ...s, fileCount: files.length, totalSize: files.reduce((sum, f) => sum + f.size, 0) }))
return
}
if (url === '/api/upload' && req.method === 'POST') {
let overlayPath = params.get('path')
if (!overlayPath) { res.writeHead(400); res.end('Missing ?path='); return }
// Ensure path starts with / and doesn't end with /
if (!overlayPath.startsWith('/')) overlayPath = '/' + overlayPath
if (overlayPath.endsWith('/')) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Path must be a file, not a directory (no trailing /)' }))
return
}
const data = await collectBody(req, 50 * 1024 * 1024)
const result = await overlay.put(overlayPath, data)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(result))
return
}
if (url === '/api/get') {
const overlayPath = params.get('path')
if (!overlayPath) { res.writeHead(400); res.end('Missing ?path='); return }
const data = await overlay.get(overlayPath)
if (!data) { res.writeHead(404); res.end('Not found'); return }
const basename = overlayPath.split('/').pop() || 'download'
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': data.length,
'Content-Disposition': 'attachment; filename="' + basename + '"'
})
res.end(data)
return
}
if (url === '/api/del' && req.method === 'POST') {
const overlayPath = params.get('path')
if (!overlayPath) { res.writeHead(400); res.end('Missing ?path='); return }
await overlay.del(overlayPath)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
return
}
// Dashboard
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(renderPage())
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
})
function renderPage () {
return `<!DOCTYPE html>
<html><head><title>hyper-overlay</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 { font-size: 0.75rem; text-transform: uppercase; color: #8b949e; margin: 0 0 0.5rem; }
.row { display: flex; justify-content: space-between; padding: 0.3rem 0; }
.label { color: #8b949e; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th { text-align: left; color: #8b949e; padding: 0.3rem 0.5rem; border-bottom: 1px solid #30363d; }
td { padding: 0.3rem 0.5rem; border-bottom: 1px solid #21262d; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.btn { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; padding: 0.3rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.8rem; }
.btn:hover { background: #30363d; }
.btn-danger { border-color: #f8514966; color: #f85149; }
.upload { margin: 0.5rem 0; }
input[type=text] { background: #0d1117; color: #c9d1d9; border: 1px solid #30363d; padding: 0.3rem 0.5rem; border-radius: 4px; font-family: monospace; }
</style></head><body>
<h1>hyper-overlay</h1>
<div class="card">
<h2>Stats</h2>
<div class="row"><span class="label">Files</span><span id="files">-</span></div>
<div class="row"><span class="label">Chunks</span><span id="chunks">-</span></div>
<div class="row"><span class="label">Manifests</span><span id="manifests">-</span></div>
<div class="row"><span class="label">Total size</span><span id="size">-</span></div>
</div>
<div class="card">
<h2>Upload</h2>
<div class="upload">
<input type="file" id="ufile">
<input type="text" id="upath" placeholder="auto-filled from filename" size="30">
<button class="btn" onclick="upload()">Upload</button>
<span id="ustatus" style="color:#8b949e;font-size:0.8rem"></span>
</div>
</div>
<div class="card">
<h2>Files</h2>
<table><thead><tr><th>Path</th><th>Size</th><th>Chunks</th><th></th></tr></thead>
<tbody id="ftable"></tbody></table>
</div>
<script>
const $ = id => document.getElementById(id)
function fmt(b) {
if (b < 1024) return b + ' B'
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'
return (b / 1048576).toFixed(1) + ' MB'
}
function refresh() {
fetch('/api/stats').then(r => r.json()).then(d => {
$('files').textContent = d.fileCount || 0
$('chunks').textContent = d.chunkCount || 0
$('manifests').textContent = d.manifestCount || 0
$('size').textContent = fmt(d.totalSize || 0)
})
fetch('/api/files').then(r => r.json()).then(files => {
$('ftable').innerHTML = files.map(f =>
'<tr><td><a href="/api/get?path=' + encodeURIComponent(f.path) + '">' + f.path + '</a></td>' +
'<td>' + fmt(f.size) + '</td><td>' + f.chunkCount + '</td>' +
'<td><button class="btn btn-danger" onclick="del(\\'' + f.path.replace(/'/g, "\\\\'") + '\\')">del</button></td></tr>'
).join('') || '<tr><td colspan="4" style="color:#484f58">(empty)</td></tr>'
})
}
// Auto-fill path when file is selected
$('ufile').addEventListener('change', function() {
const f = this.files[0]
if (f) $('upath').value = '/demo/' + f.name
})
function upload() {
const f = $('ufile').files[0]
if (!f) { $('ustatus').textContent = 'select a file first'; return }
let p = $('upath').value || ('/demo/' + f.name)
if (!p.startsWith('/')) p = '/' + p
if (p.endsWith('/')) { $('ustatus').textContent = 'path must be a file, not a directory'; return }
$('ustatus').textContent = 'uploading...'
f.arrayBuffer().then(buf => fetch('/api/upload?path=' + encodeURIComponent(p), {
method: 'POST', body: new Uint8Array(buf)
})).then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json() }).then(d => {
$('ustatus').textContent = 'done (' + d.chunkCount + ' chunks)'
$('upath').value = ''
$('ufile').value = ''
refresh()
}).catch(e => { $('ustatus').textContent = 'error: ' + e.message })
}
function del(p) {
fetch('/api/del?path=' + encodeURIComponent(p), { method: 'POST' }).then(refresh)
}
refresh()
setInterval(refresh, 5000)
</script></body></html>`
}
overlay.ready().then(() => {
httpServer.listen(port, () => {
console.log('Storage: %s', storageDir)
console.log('Web UI: http://localhost:%d', port)
})
}).catch((err) => { console.error(err.message); process.exit(1) })