OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay / examples / web.js

232 lines · javascript ref:
#!/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) })