| … | … | @@ -1,288 +0,0 @@ |
| 1 | #!/usr/bin/env bare | |
| 2 | /** | |
| 3 | * hyper-deploy CLI — P2P release distribution | |
| 4 | * | |
| 5 | * Commands: | |
| 6 | * serve <dir> Serve a directory to peers | |
| 7 | * pull <topic> [dest] Pull files from a server | |
| 8 | * info <topic> Show remote manifest without pulling | |
| 9 | * help Show usage | |
| 10 | * version Show version | |
| 11 | */ | |
| 12 | ||
| 13 | await import('bare-node-runtime/global') | |
| 14 | ||
| 15 | import fs from 'bare-fs' | |
| 16 | import path from 'bare-path' | |
| 17 | import { DeployServer, DeployClient } from '../index.js' | |
| 18 | import { startWeb } from '../lib/web.js' | |
| 19 | import { diffManifest } from '../lib/scanner.js' | |
| 20 | ||
| 21 | const VERSION = '0.1.0' | |
| 22 | ||
| 23 | function fmt (b) { | |
| 24 | if (b < 1024) return b + ' B' | |
| 25 | if (b < 1048576) return (b / 1024).toFixed(1) + ' KB' | |
| 26 | if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB' | |
| 27 | return (b / 1073741824).toFixed(2) + ' GB' | |
| 28 | } | |
| 29 | ||
| 30 | function parseFlags (args) { | |
| 31 | const flags = {} | |
| 32 | const positional = [] | |
| 33 | for (let i = 0; i < args.length; i++) { | |
| 34 | if (args[i].startsWith('--')) { | |
| 35 | const key = args[i].slice(2) | |
| 36 | if (key === 'no-web' || key === 'no-verify' || key === 'dry-run' || key === 'verify') { | |
| 37 | flags[key] = true | |
| 38 | } else if (i + 1 < args.length && !args[i + 1].startsWith('--')) { | |
| 39 | flags[key] = args[++i] | |
| 40 | } else { | |
| 41 | flags[key] = true | |
| 42 | } | |
| 43 | } else { | |
| 44 | positional.push(args[i]) | |
| 45 | } | |
| 46 | } | |
| 47 | return { flags, positional } | |
| 48 | } | |
| 49 | ||
| 50 | const args = process.argv.slice(2) | |
| 51 | const command = args[0] | |
| 52 | ||
| 53 | if (!command || command === 'help' || command === '--help') { | |
| 54 | console.log(`hyper-deploy v${VERSION} — P2P release distribution | |
| 55 | ||
| 56 | Commands: | |
| 57 | serve <dir> Index and serve a directory to peers | |
| 58 | pull <topic> [dest] Pull files from a server | |
| 59 | info <topic> Show remote manifest without pulling | |
| 60 | version Show version | |
| 61 | ||
| 62 | Serve options: | |
| 63 | --topic <hex> Use a specific topic (default: random) | |
| 64 | --web-port <port> Web UI port (default: 9090) | |
| 65 | --no-web Disable web UI | |
| 66 | ||
| 67 | Pull options: | |
| 68 | --timeout <ms> Connection timeout (default: 30000) | |
| 69 | --no-verify Skip hash verification on received files | |
| 70 | --dry-run Show what would transfer without doing it | |
| 71 | ||
| 72 | Examples: | |
| 73 | hyper-deploy serve ./build/release | |
| 74 | hyper-deploy pull abc123...def456 ./my-app | |
| 75 | hyper-deploy pull abc123...def456 ./my-app --dry-run | |
| 76 | hyper-deploy info abc123...def456 | |
| 77 | `) | |
| 78 | process.exit(0) | |
| 79 | } | |
| 80 | ||
| 81 | if (command === 'version' || command === '--version') { | |
| 82 | console.log('hyper-deploy v' + VERSION) | |
| 83 | process.exit(0) | |
| 84 | } | |
| 85 | ||
| 86 | // ─── SERVE ─────────────────────────────────────────────────── | |
| 87 | ||
| 88 | if (command === 'serve') { | |
| 89 | const { flags, positional } = parseFlags(args.slice(1)) | |
| 90 | const dir = positional[0] | |
| 91 | ||
| 92 | if (!dir) { | |
| 93 | console.error('Usage: hyper-deploy serve <dir>') | |
| 94 | process.exit(1) | |
| 95 | } | |
| 96 | ||
| 97 | if (!fs.existsSync(dir)) { | |
| 98 | console.error('Directory not found: ' + dir) | |
| 99 | process.exit(1) | |
| 100 | } | |
| 101 | ||
| 102 | const topic = flags.topic ? Buffer.from(flags.topic, 'hex') : undefined | |
| 103 | ||
| 104 | const server = new DeployServer(dir, { topic }) | |
| 105 | ||
| 106 | server.on('index-progress', (i, total) => { | |
| 107 | if (i % 2000 === 0) console.log(` indexing ${i}/${total}...`) | |
| 108 | }) | |
| 109 | ||
| 110 | server.on('indexed', (count, size) => { | |
| 111 | console.log(`Indexed: ${count} files, ${fmt(size)}`) | |
| 112 | }) | |
| 113 | ||
| 114 | server.on('ready', () => { | |
| 115 | console.log(`\nServing on topic:`) | |
| 116 | console.log(` ${server.topicHex}`) | |
| 117 | console.log(`\nPull with:`) | |
| 118 | console.log(` hyper-deploy pull ${server.topicHex} <dest-dir>`) | |
| 119 | console.log(`\nWaiting for peers...`) | |
| 120 | }) | |
| 121 | ||
| 122 | server.on('peer', ({ key, state }) => { | |
| 123 | if (state === 'connected') console.log(`\nPeer ${key}: connected`) | |
| 124 | else if (state === 'transferring') console.log(` Peer ${key}: transferring...`) | |
| 125 | else if (state === 'done') console.log(` Peer ${key}: done`) | |
| 126 | else if (state === 'disconnected') console.log(` Peer ${key}: disconnected`) | |
| 127 | }) | |
| 128 | ||
| 129 | server.on('progress', (p) => { | |
| 130 | if (p.filesServed && p.filesServed % 500 === 0) { | |
| 131 | console.log(` ${p.filesServed} files, ${fmt(p.bytesServed)}`) | |
| 132 | } | |
| 133 | }) | |
| 134 | ||
| 135 | server.on('error', (err) => { | |
| 136 | console.error('Error:', err.message) | |
| 137 | }) | |
| 138 | ||
| 139 | await server.ready() | |
| 140 | ||
| 141 | if (!flags['no-web']) { | |
| 142 | const webPort = parseInt(flags['web-port']) || 9090 | |
| 143 | const { port } = startWeb(server, { port: webPort }) | |
| 144 | console.log(`\nWeb UI: http://localhost:${port}`) | |
| 145 | } | |
| 146 | } | |
| 147 | ||
| 148 | // ─── PULL ──────────────────────────────────────────────────── | |
| 149 | ||
| 150 | else if (command === 'pull') { | |
| 151 | const { flags, positional } = parseFlags(args.slice(1)) | |
| 152 | const topicHex = positional[0] | |
| 153 | const dest = positional[1] || './deploy-output' | |
| 154 | ||
| 155 | if (!topicHex || topicHex.length !== 64) { | |
| 156 | console.error('Usage: hyper-deploy pull <64-char-topic-hex> [dest-dir]') | |
| 157 | process.exit(1) | |
| 158 | } | |
| 159 | ||
| 160 | const timeout = parseInt(flags.timeout) || 30000 | |
| 161 | const verify = !flags['no-verify'] | |
| 162 | const dryRun = !!flags['dry-run'] | |
| 163 | ||
| 164 | console.log(`Pulling to: ${path.resolve(dest)}`) | |
| 165 | console.log(`Connecting to: ${topicHex.slice(0, 16)}...`) | |
| 166 | if (dryRun) console.log('(dry run — no files will be transferred)') | |
| 167 | ||
| 168 | const startTime = Date.now() | |
| 169 | const client = new DeployClient(topicHex, dest, { timeout, verify }) | |
| 170 | ||
| 171 | client.on('connected', (key) => { | |
| 172 | console.log(`Connected: ${key} (${Date.now() - startTime}ms)`) | |
| 173 | }) | |
| 174 | ||
| 175 | client.on('manifest', (count) => { | |
| 176 | console.log(`Manifest: ${count} files`) | |
| 177 | }) | |
| 178 | ||
| 179 | client.on('diff', (needed, skipped) => { | |
| 180 | console.log(`Need: ${needed}, already have: ${skipped}`) | |
| 181 | ||
| 182 | if (dryRun) { | |
| 183 | console.log('\n(dry run complete — would transfer ' + needed + ' files)') | |
| 184 | client.close().then(() => process.exit(0)) | |
| 185 | } | |
| 186 | }) | |
| 187 | ||
| 188 | client.on('up-to-date', () => { | |
| 189 | console.log('Already up to date!') | |
| 190 | }) | |
| 191 | ||
| 192 | client.on('progress', (p) => { | |
| 193 | const total = p.filesTransferred + p.filesSkipped | |
| 194 | if (p.filesTransferred % 500 === 0 || total === p.totalFiles) { | |
| 195 | const elapsed = (Date.now() - startTime) / 1000 | |
| 196 | const pct = (total / p.totalFiles * 100).toFixed(0) | |
| 197 | console.log(` ${pct}% — ${p.filesTransferred} transferred, ${fmt(p.bytesReceived)}, ${elapsed.toFixed(0)}s`) | |
| 198 | } | |
| 199 | }) | |
| 200 | ||
| 201 | client.on('error', (err) => { | |
| 202 | console.error('Error:', err.message) | |
| 203 | }) | |
| 204 | ||
| 205 | try { | |
| 206 | await client.ready() | |
| 207 | ||
| 208 | if (dryRun) { | |
| 209 | // Wait for diff event to fire then exit (handled in diff listener above) | |
| 210 | await new Promise(r => setTimeout(r, 60000)) | |
| 211 | } else { | |
| 212 | const result = await client.pull() | |
| 213 | const speed = result.bytesReceived > 0 ? (result.bytesReceived / result.elapsed * 1000 / 1024 / 1024).toFixed(1) : '0' | |
| 214 | console.log(`\nComplete!`) | |
| 215 | console.log(` Files: ${result.filesTransferred} transferred, ${result.filesSkipped} skipped`) | |
| 216 | console.log(` Data: ${fmt(result.bytesReceived)}`) | |
| 217 | console.log(` Time: ${(result.elapsed / 1000).toFixed(1)}s (${speed} MB/s)`) | |
| 218 | if (result.errors.length > 0) { | |
| 219 | console.log(` Errors: ${result.errors.length}`) | |
| 220 | for (const e of result.errors) console.log(` ${e.path}: ${e.message}`) | |
| 221 | } | |
| 222 | } | |
| 223 | } catch (err) { | |
| 224 | console.error('\nFailed:', err.message) | |
| 225 | process.exit(1) | |
| 226 | } | |
| 227 | ||
| 228 | // process.exit after close — libudx has a double-free bug in io_uring | |
| 229 | // teardown that crashes when swarm.destroy() runs after real network transfers. | |
| 230 | await client.close() | |
| 231 | process.exit(0) | |
| 232 | } | |
| 233 | ||
| 234 | // ─── INFO ──────────────────────────────────────────────────── | |
| 235 | ||
| 236 | else if (command === 'info') { | |
| 237 | const topicHex = args[1] | |
| 238 | ||
| 239 | if (!topicHex || topicHex.length !== 64) { | |
| 240 | console.error('Usage: hyper-deploy info <64-char-topic-hex>') | |
| 241 | process.exit(1) | |
| 242 | } | |
| 243 | ||
| 244 | console.log(`Connecting to: ${topicHex.slice(0, 16)}...`) | |
| 245 | ||
| 246 | const client = new DeployClient(topicHex, '/tmp/hyper-deploy-info-dummy', { timeout: 15000 }) | |
| 247 | ||
| 248 | client.on('connected', (key) => { | |
| 249 | console.log(`Connected: ${key}`) | |
| 250 | }) | |
| 251 | ||
| 252 | client.on('manifest', (count) => { | |
| 253 | // We have the manifest — print summary and exit | |
| 254 | }) | |
| 255 | ||
| 256 | client.on('diff', (needed, skipped) => { | |
| 257 | console.log(`\nRemote has ${needed + skipped} files`) | |
| 258 | console.log(` Would transfer: ${needed}`) | |
| 259 | console.log(` Already local: ${skipped}`) | |
| 260 | ||
| 261 | client.close().then(() => process.exit(0)) | |
| 262 | }) | |
| 263 | ||
| 264 | client.on('up-to-date', () => { | |
| 265 | console.log('Already up to date!') | |
| 266 | client.close().then(() => process.exit(0)) | |
| 267 | }) | |
| 268 | ||
| 269 | try { | |
| 270 | await client.ready() | |
| 271 | await client.pull() | |
| 272 | } catch (err) { | |
| 273 | if (err.message.includes('closed during pull')) { | |
| 274 | // Expected — we close after getting the manifest | |
| 275 | } else { | |
| 276 | console.error('Failed:', err.message) | |
| 277 | process.exit(1) | |
| 278 | } | |
| 279 | } | |
| 280 | } | |
| 281 | ||
| 282 | // ─── UNKNOWN ───────────────────────────────────────────────── | |
| 283 | ||
| 284 | else { | |
| 285 | console.error('Unknown command: ' + command) | |
| 286 | console.error('Run: hyper-deploy help') | |
| 287 | process.exit(1) | |
| 288 | } |
| … | … | @@ -0,0 +1,114 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | /** | |
| 3 | * Mini CLI example for hyper-deploy | |
| 4 | * | |
| 5 | * Usage: | |
| 6 | * node examples/cli.js serve <dir> [--topic <hex>] | |
| 7 | * node examples/cli.js pull <topic> [dest] | |
| 8 | * node examples/cli.js info <topic> | |
| 9 | */ | |
| 10 | ||
| 11 | import { DeployServer, DeployClient } from '../index.js' | |
| 12 | ||
| 13 | function fmt (b) { | |
| 14 | if (b < 1024) return b + ' B' | |
| 15 | if (b < 1048576) return (b / 1024).toFixed(1) + ' KB' | |
| 16 | if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB' | |
| 17 | return (b / 1073741824).toFixed(2) + ' GB' | |
| 18 | } | |
| 19 | ||
| 20 | const args = process.argv.slice(2) | |
| 21 | const cmd = args[0] | |
| 22 | ||
| 23 | if (cmd === 'serve') { | |
| 24 | const dir = args[1] | |
| 25 | if (!dir) { console.error('Usage: cli.js serve <dir>'); process.exit(1) } | |
| 26 | ||
| 27 | let topic | |
| 28 | const ti = args.indexOf('--topic') | |
| 29 | if (ti !== -1 && args[ti + 1]) topic = Buffer.from(args[ti + 1], 'hex') | |
| 30 | ||
| 31 | const server = new DeployServer(dir, { topic }) | |
| 32 | ||
| 33 | server.on('indexed', (count, size) => { | |
| 34 | console.log('Indexed: %d files, %s', count, fmt(size)) | |
| 35 | }) | |
| 36 | ||
| 37 | server.on('ready', () => { | |
| 38 | console.log('Topic: %s', server.topicHex) | |
| 39 | console.log('Pull: node examples/cli.js pull %s ./dest', server.topicHex) | |
| 40 | console.log('Waiting for peers...') | |
| 41 | }) | |
| 42 | ||
| 43 | server.on('peer', ({ key, state }) => console.log('Peer %s: %s', key, state)) | |
| 44 | ||
| 45 | server.on('progress', (p) => { | |
| 46 | if (p.file) console.log(' sent %s (%d files, %s)', p.file, p.filesServed, fmt(p.bytesServed)) | |
| 47 | }) | |
| 48 | ||
| 49 | server.ready().catch((err) => { console.error(err.message); process.exit(1) }) | |
| 50 | } else if (cmd === 'pull') { | |
| 51 | const topicHex = args[1] | |
| 52 | const dest = args[2] || './deploy-output' | |
| 53 | if (!topicHex || topicHex.length !== 64) { | |
| 54 | console.error('Usage: cli.js pull <64-char-hex> [dest]') | |
| 55 | process.exit(1) | |
| 56 | } | |
| 57 | ||
| 58 | const client = new DeployClient(topicHex, dest) | |
| 59 | const t0 = Date.now() | |
| 60 | ||
| 61 | client.on('connected', (key) => console.log('Connected: %s (%dms)', key, Date.now() - t0)) | |
| 62 | client.on('manifest', (n) => console.log('Manifest: %d files', n)) | |
| 63 | client.on('diff', (needed, skipped) => console.log('Need: %d, skip: %d', needed, skipped)) | |
| 64 | client.on('up-to-date', () => console.log('Up to date')) | |
| 65 | ||
| 66 | client.on('progress', (p) => { | |
| 67 | const total = p.filesTransferred + p.filesSkipped | |
| 68 | if (p.filesTransferred % 100 === 0 || total === p.totalFiles) { | |
| 69 | console.log(' %d/%d transferred, %s', p.filesTransferred, p.totalFiles, fmt(p.bytesReceived)) | |
| 70 | } | |
| 71 | }) | |
| 72 | ||
| 73 | client.ready() | |
| 74 | .then(() => client.pull()) | |
| 75 | .then((stats) => { | |
| 76 | const speed = stats.bytesReceived > 0 ? (stats.bytesReceived / stats.elapsed * 1000 / 1024 / 1024).toFixed(1) : '0' | |
| 77 | console.log('Done: %d files, %s, %ss (%s MB/s)', | |
| 78 | stats.filesTransferred, fmt(stats.bytesReceived), | |
| 79 | (stats.elapsed / 1000).toFixed(1), speed) | |
| 80 | if (stats.errors.length) console.log('Errors:', stats.errors) | |
| 81 | return client.close() | |
| 82 | }) | |
| 83 | .then(() => process.exit(0)) | |
| 84 | .catch((err) => { console.error(err.message); process.exit(1) }) | |
| 85 | } else if (cmd === 'info') { | |
| 86 | const topicHex = args[1] | |
| 87 | if (!topicHex || topicHex.length !== 64) { | |
| 88 | console.error('Usage: cli.js info <64-char-hex>') | |
| 89 | process.exit(1) | |
| 90 | } | |
| 91 | ||
| 92 | const client = new DeployClient(topicHex, '/tmp/hyper-deploy-info', { timeout: 15000 }) | |
| 93 | ||
| 94 | client.on('connected', (key) => console.log('Connected: %s', key)) | |
| 95 | client.on('manifest', (n) => console.log('Files: %d', n)) | |
| 96 | client.on('diff', (needed, skipped) => { | |
| 97 | console.log('Would transfer: %d, already local: %d', needed, skipped) | |
| 98 | client.close().then(() => process.exit(0)) | |
| 99 | }) | |
| 100 | ||
| 101 | client.ready() | |
| 102 | .then(() => client.pull()) | |
| 103 | .catch((err) => { | |
| 104 | if (!err.message.includes('closed during pull')) { | |
| 105 | console.error(err.message) | |
| 106 | process.exit(1) | |
| 107 | } | |
| 108 | }) | |
| 109 | } else { | |
| 110 | console.log('hyper-deploy example CLI') | |
| 111 | console.log(' serve <dir> [--topic <hex>] Serve a directory') | |
| 112 | console.log(' pull <topic> [dest] Pull files from server') | |
| 113 | console.log(' info <topic> Show remote manifest') | |
| 114 | } |
| … | … | @@ -0,0 +1,132 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | /** | |
| 3 | * Mini web UI example for hyper-deploy | |
| 4 | * | |
| 5 | * Usage: | |
| 6 | * node examples/web.js <dir> [--port 9090] [--topic <hex>] | |
| 7 | * | |
| 8 | * Starts a deploy server and an HTTP dashboard showing live status. | |
| 9 | */ | |
| 10 | ||
| 11 | import http from 'http' | |
| 12 | import { DeployServer } from '../index.js' | |
| 13 | ||
| 14 | const args = process.argv.slice(2) | |
| 15 | const dir = args[0] | |
| 16 | if (!dir) { console.error('Usage: web.js <dir> [--port N]'); process.exit(1) } | |
| 17 | ||
| 18 | let port = 9090 | |
| 19 | let topic | |
| 20 | for (let i = 1; i < args.length; i++) { | |
| 21 | if (args[i] === '--port' && args[i + 1]) port = parseInt(args[++i]) | |
| 22 | if (args[i] === '--topic' && args[i + 1]) topic = Buffer.from(args[++i], 'hex') | |
| 23 | } | |
| 24 | ||
| 25 | function fmt (b) { | |
| 26 | if (b < 1024) return b + ' B' | |
| 27 | if (b < 1048576) return (b / 1024).toFixed(1) + ' KB' | |
| 28 | if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB' | |
| 29 | return (b / 1073741824).toFixed(2) + ' GB' | |
| 30 | } | |
| 31 | ||
| 32 | const server = new DeployServer(dir, { topic }) | |
| 33 | const sseClients = new Set() | |
| 34 | ||
| 35 | function broadcast (event, data) { | |
| 36 | const msg = 'event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n' | |
| 37 | for (const res of sseClients) { | |
| 38 | try { res.write(msg) } catch { sseClients.delete(res) } | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | server.on('peer', (info) => broadcast('peer', info)) | |
| 43 | server.on('progress', (info) => broadcast('progress', info)) | |
| 44 | ||
| 45 | function getStatus () { | |
| 46 | return { | |
| 47 | dir: server.dir, | |
| 48 | topic: server.topicHex, | |
| 49 | files: server.manifest ? server.manifest.files.length : 0, | |
| 50 | totalSize: server.manifest ? fmt(server.manifest.totalSize) : '0', | |
| 51 | connections: server.connections, | |
| 52 | filesServed: server.stats.filesServed, | |
| 53 | bytesServed: fmt(server.stats.bytesServed) | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | const httpServer = http.createServer((req, res) => { | |
| 58 | const url = req.url.split('?')[0] | |
| 59 | ||
| 60 | if (url === '/api/status') { | |
| 61 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 62 | res.end(JSON.stringify(getStatus())) | |
| 63 | return | |
| 64 | } | |
| 65 | ||
| 66 | if (url === '/events') { | |
| 67 | res.writeHead(200, { | |
| 68 | 'Content-Type': 'text/event-stream', | |
| 69 | 'Cache-Control': 'no-cache', | |
| 70 | Connection: 'keep-alive' | |
| 71 | }) | |
| 72 | res.write('data: ' + JSON.stringify(getStatus()) + '\n\n') | |
| 73 | sseClients.add(res) | |
| 74 | req.on('close', () => sseClients.delete(res)) | |
| 75 | return | |
| 76 | } | |
| 77 | ||
| 78 | res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) | |
| 79 | res.end(`<!DOCTYPE html> | |
| 80 | <html><head><title>hyper-deploy</title> | |
| 81 | <style> | |
| 82 | body { font-family: monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 640px; margin: 0 auto; } | |
| 83 | h1 { color: #58a6ff; } | |
| 84 | .card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1rem; margin: 1rem 0; } | |
| 85 | .row { display: flex; justify-content: space-between; padding: 0.3rem 0; } | |
| 86 | .label { color: #8b949e; } | |
| 87 | .topic { font-size: 0.75rem; word-break: break-all; color: #79c0ff; } | |
| 88 | #log { max-height: 300px; overflow-y: auto; font-size: 0.8rem; color: #8b949e; } | |
| 89 | #log div { padding: 2px 0; border-bottom: 1px solid #21262d; } | |
| 90 | </style></head><body> | |
| 91 | <h1>hyper-deploy</h1> | |
| 92 | <div class="card"> | |
| 93 | <div class="topic" id="topic">...</div> | |
| 94 | </div> | |
| 95 | <div class="card"> | |
| 96 | <div class="row"><span class="label">Files</span><span id="files">-</span></div> | |
| 97 | <div class="row"><span class="label">Size</span><span id="size">-</span></div> | |
| 98 | <div class="row"><span class="label">Peers</span><span id="peers">0</span></div> | |
| 99 | <div class="row"><span class="label">Served</span><span id="served">0 files</span></div> | |
| 100 | <div class="row"><span class="label">Bytes</span><span id="bytes">0 B</span></div> | |
| 101 | </div> | |
| 102 | <div class="card"><h3 style="color:#8b949e;margin:0 0 0.5rem">Log</h3><div id="log"></div></div> | |
| 103 | <script> | |
| 104 | const $ = id => document.getElementById(id) | |
| 105 | function log(msg) { | |
| 106 | const d = document.createElement('div'); d.textContent = msg | |
| 107 | $('log').prepend(d) | |
| 108 | while ($('log').children.length > 100) $('log').lastChild.remove() | |
| 109 | } | |
| 110 | function update(d) { | |
| 111 | if (d.topic) $('topic').textContent = d.topic | |
| 112 | if (d.files !== undefined) $('files').textContent = d.files | |
| 113 | if (d.totalSize) $('size').textContent = d.totalSize | |
| 114 | if (d.connections !== undefined) $('peers').textContent = d.connections | |
| 115 | if (d.filesServed !== undefined) $('served').textContent = d.filesServed + ' files' | |
| 116 | if (d.bytesServed) $('bytes').textContent = d.bytesServed | |
| 117 | } | |
| 118 | const es = new EventSource('/events') | |
| 119 | es.onmessage = e => update(JSON.parse(e.data)) | |
| 120 | es.addEventListener('peer', e => { const d = JSON.parse(e.data); log('Peer ' + d.key + ': ' + d.state) }) | |
| 121 | es.addEventListener('progress', e => { const d = JSON.parse(e.data); if (d.file) log('sent ' + d.file) }) | |
| 122 | </script></body></html>`) | |
| 123 | }) | |
| 124 | ||
| 125 | server.ready().then(() => { | |
| 126 | httpServer.listen(port, () => { | |
| 127 | console.log('Serving: %s', server.dir) | |
| 128 | console.log('Topic: %s', server.topicHex) | |
| 129 | console.log('Files: %d (%s)', server.manifest.files.length, fmt(server.manifest.totalSize)) | |
| 130 | console.log('Web UI: http://localhost:%d', port) | |
| 131 | }) | |
| 132 | }).catch((err) => { console.error(err.message); process.exit(1) }) |
| … | … | @@ -1,12 +1,12 @@ |
| 1 | 1 | /** |
| 2 | * hyper-deploy — P2P release distribution | |
| 2 | * hyper-deploy — P2P release distribution built on hyper-overlay | |
| 3 | 3 | * |
| 4 | 4 | * Serves build artifacts from a directory to peers over Hyperswarm. |
| 5 | * File-level dedup: client compares content hashes, only pulls what changed. | |
| 6 | * Backpressure via Protomux ondrain. Hash verification on received files. | |
| 5 | * File-level dedup via content hashing. Files >= 1MB get FastCDC chunking | |
| 6 | * for resumable transfers. Smaller files transfer as whole blobs. | |
| 7 | 7 | * |
| 8 | 8 | * Usage: |
| 9 | * const { DeployServer, DeployClient } = require('hyper-deploy') | |
| 9 | * import { DeployServer, DeployClient } from 'hyper-deploy' | |
| 10 | 10 | * |
| 11 | 11 | * const server = new DeployServer(dir) |
| 12 | 12 | * await server.ready() |
| … | … | @@ -16,221 +16,292 @@ |
| 16 | 16 | * const result = await client.pull() |
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | const Hyperswarm = require('hyperswarm'); | |
| 20 | const crypto = require('hypercore-crypto'); | |
| 21 | const fs = require('fs'); | |
| 22 | const path = require('path'); | |
| 23 | const ReadyResource = require('ready-resource'); | |
| 24 | const safetyCatch = require('safety-catch'); | |
| 19 | import Hyperswarm from 'hyperswarm' | |
| 20 | import Corestore from 'corestore' | |
| 21 | import crypto from 'hypercore-crypto' | |
| 22 | import fs from '#fs' | |
| 23 | import path from '#path' | |
| 24 | import os from '#os' | |
| 25 | import ReadyResource from 'ready-resource' | |
| 26 | import safetyCatch from 'safety-catch' | |
| 27 | ||
| 28 | import { FileIndex } from 'hyper-overlay/file-index' | |
| 29 | import { TransferManager } from 'hyper-overlay/transfer' | |
| 30 | import { hashChunk } from 'hyper-overlay/chunker' | |
| 31 | import { attachProtocol } from './lib/protocol.js' | |
| 32 | ||
| 33 | const CHUNK_THRESHOLD = 1048576 // 1 MB — files below this transfer as whole blobs | |
| 34 | ||
| 35 | // ── Helpers ────────────────────────────────────────────────── | |
| 36 | ||
| 37 | function walkDir (dir, prefix) { | |
| 38 | prefix = prefix || '' | |
| 39 | const results = [] | |
| 40 | for (const entry of fs.readdirSync(dir)) { | |
| 41 | const full = path.join(dir, entry) | |
| 42 | const rel = prefix ? prefix + '/' + entry : entry | |
| 43 | try { | |
| 44 | const stat = fs.statSync(full) | |
| 45 | if (stat.isDirectory()) results.push(...walkDir(full, rel)) | |
| 46 | else if (stat.isFile()) results.push({ path: rel, size: stat.size }) | |
| 47 | } catch {} | |
| 48 | } | |
| 49 | return results | |
| 50 | } | |
| 25 | 51 | |
| 26 | const { buildManifest, diffManifest, readChunk, hashFile } = require('./lib/scanner'); | |
| 27 | const { attachProtocol, PROTOCOL_VERSION, CHUNK_SIZE } = require('./lib/protocol'); | |
| 52 | function hashFile (filePath) { | |
| 53 | return hashChunk(fs.readFileSync(filePath)) | |
| 54 | } | |
| 28 | 55 | |
| 29 | // ─── DeployServer ──────────────────────────────────────────── | |
| 56 | // ── DeployServer ───────────────────────────────────────────── | |
| 30 | 57 | |
| 31 | class DeployServer extends ReadyResource { | |
| 58 | export class DeployServer extends ReadyResource { | |
| 32 | 59 | constructor (dir, opts) { |
| 33 | super(); | |
| 34 | opts = opts || {}; | |
| 35 | ||
| 36 | this.dir = path.resolve(dir); | |
| 37 | this.topic = opts.topic || crypto.randomBytes(32); | |
| 38 | this.swarm = opts.swarm || null; | |
| 39 | this._ownSwarm = !this.swarm; | |
| 40 | this.manifest = null; | |
| 41 | this._manifestHash = null; | |
| 42 | this._discovery = null; | |
| 43 | this._sessions = new Set(); | |
| 44 | ||
| 45 | this.stats = { totalConnections: 0, filesServed: 0, bytesServed: 0 }; | |
| 46 | ||
| 47 | this.ready().catch(safetyCatch); | |
| 60 | super() | |
| 61 | opts = opts || {} | |
| 62 | ||
| 63 | this.dir = path.resolve(dir) | |
| 64 | this.topic = opts.topic || crypto.randomBytes(32) | |
| 65 | this.swarm = opts.swarm || null | |
| 66 | this._ownSwarm = !this.swarm | |
| 67 | this._corestore = opts.corestore || null | |
| 68 | this._ownStore = !this._corestore | |
| 69 | this._fileIndex = null | |
| 70 | this._transferManager = null | |
| 71 | this._discovery = null | |
| 72 | this._sessions = new Set() | |
| 73 | ||
| 74 | this.manifest = null | |
| 75 | this.stats = { totalConnections: 0, filesServed: 0, bytesServed: 0 } | |
| 76 | ||
| 77 | this.ready().catch(safetyCatch) | |
| 48 | 78 | } |
| 49 | 79 | |
| 50 | 80 | async _open () { |
| 51 | 81 | if (!fs.existsSync(this.dir)) { |
| 52 | throw new Error('Directory not found: ' + this.dir); | |
| 82 | throw new Error('Directory not found: ' + this.dir) | |
| 83 | } | |
| 84 | ||
| 85 | // Setup corestore + overlay primitives | |
| 86 | if (!this._corestore) { | |
| 87 | const tmp = path.join(os.tmpdir(), 'hyper-deploy-srv-' + Date.now()) | |
| 88 | this._corestore = new Corestore(tmp) | |
| 53 | 89 | } |
| 54 | 90 | |
| 55 | await this._scan(); | |
| 91 | this._fileIndex = new FileIndex(this._corestore) | |
| 92 | await this._fileIndex.ready() | |
| 93 | this._transferManager = new TransferManager(this._fileIndex) | |
| 94 | ||
| 95 | // Index directory | |
| 96 | await this._scan() | |
| 56 | 97 | |
| 57 | if (!this.swarm) this.swarm = new Hyperswarm(); | |
| 58 | this.swarm.on('connection', (conn) => this._onConnection(conn)); | |
| 98 | // Start swarm | |
| 99 | if (!this.swarm) this.swarm = new Hyperswarm() | |
| 100 | this.swarm.on('connection', (conn) => this._onConnection(conn)) | |
| 59 | 101 | |
| 60 | this._discovery = this.swarm.join(this.topic, { server: true, client: false }); | |
| 61 | await this._discovery.flushed(); | |
| 102 | this._discovery = this.swarm.join(this.topic, { server: true, client: false }) | |
| 103 | await this._discovery.flushed() | |
| 62 | 104 | |
| 63 | this.emit('ready'); | |
| 105 | this.emit('ready') | |
| 64 | 106 | } |
| 65 | 107 | |
| 66 | 108 | async _close () { |
| 67 | 109 | for (const session of this._sessions) { |
| 68 | session.destroyed = true; | |
| 110 | session.destroyed = true | |
| 69 | 111 | } |
| 70 | this._sessions.clear(); | |
| 112 | this._sessions.clear() | |
| 71 | 113 | |
| 72 | 114 | if (this._discovery) { |
| 73 | await this._discovery.destroy(); | |
| 74 | this._discovery = null; | |
| 115 | await this._discovery.destroy() | |
| 116 | this._discovery = null | |
| 75 | 117 | } |
| 76 | 118 | if (this._ownSwarm && this.swarm) { |
| 77 | await this.swarm.destroy(); | |
| 78 | this.swarm = null; | |
| 119 | await this.swarm.destroy() | |
| 120 | this.swarm = null | |
| 79 | 121 | } |
| 122 | if (this._fileIndex) await this._fileIndex.close() | |
| 123 | if (this._ownStore && this._corestore) await this._corestore.close() | |
| 80 | 124 | } |
| 81 | 125 | |
| 82 | 126 | get topicHex () { |
| 83 | return this.topic.toString('hex'); | |
| 127 | return this.topic.toString('hex') | |
| 84 | 128 | } |
| 85 | 129 | |
| 86 | 130 | get connections () { |
| 87 | return this._sessions.size; | |
| 131 | return this._sessions.size | |
| 88 | 132 | } |
| 89 | 133 | |
| 90 | 134 | async rescan () { |
| 91 | await this._scan(); | |
| 92 | return { files: this.manifest.files.length, totalSize: this.manifest.totalSize }; | |
| 135 | await this._scan() | |
| 136 | return { files: this.manifest.files.length, totalSize: this.manifest.totalSize } | |
| 93 | 137 | } |
| 94 | 138 | |
| 95 | 139 | async _scan () { |
| 96 | this.manifest = buildManifest(this.dir, { | |
| 97 | onProgress: (i, total) => { | |
| 98 | if (i % 1000 === 0) this.emit('index-progress', i, total); | |
| 140 | const files = walkDir(this.dir) | |
| 141 | let totalSize = 0 | |
| 142 | ||
| 143 | for (let i = 0; i < files.length; i++) { | |
| 144 | const f = files[i] | |
| 145 | const fullPath = path.join(this.dir, f.path) | |
| 146 | const contentHash = hashFile(fullPath) | |
| 147 | ||
| 148 | await this._fileIndex.putFile(f.path, { | |
| 149 | contentHash, | |
| 150 | size: f.size, | |
| 151 | mtime: fs.statSync(fullPath).mtimeMs | |
| 152 | }) | |
| 153 | ||
| 154 | // For large files, prepare chunk map via TransferManager | |
| 155 | if (f.size >= CHUNK_THRESHOLD) { | |
| 156 | await this._transferManager.prepareFile(fullPath, f.path) | |
| 99 | 157 | } |
| 100 | }); | |
| 101 | 158 | |
| 102 | const manifestJson = JSON.stringify(this.manifest.files.map(f => f.path + ':' + f.hash)); | |
| 103 | this._manifestHash = crypto.data(Buffer.from(manifestJson)).toString('hex'); | |
| 159 | totalSize += f.size | |
| 160 | if ((i + 1) % 1000 === 0) this.emit('index-progress', i + 1, files.length) | |
| 161 | } | |
| 162 | ||
| 163 | this.manifest = { | |
| 164 | files: files.map(f => ({ | |
| 165 | path: f.path, | |
| 166 | size: f.size, | |
| 167 | hash: null // populated lazily from fileIndex | |
| 168 | })), | |
| 169 | totalSize | |
| 170 | } | |
| 171 | ||
| 172 | // Populate hashes from the index | |
| 173 | for (const f of this.manifest.files) { | |
| 174 | const meta = await this._fileIndex.getFile(f.path) | |
| 175 | if (meta) f.hash = meta.contentHash | |
| 176 | } | |
| 104 | 177 | |
| 105 | this.emit('indexed', this.manifest.files.length, this.manifest.totalSize); | |
| 178 | this.emit('indexed', this.manifest.files.length, totalSize) | |
| 106 | 179 | } |
| 107 | 180 | |
| 108 | 181 | _onConnection (conn) { |
| 109 | 182 | const peerKey = conn.remotePublicKey |
| 110 | 183 | ? conn.remotePublicKey.toString('hex').slice(0, 12) |
| 111 | : 'unknown'; | |
| 184 | : 'unknown' | |
| 112 | 185 | |
| 113 | this.stats.totalConnections++; | |
| 186 | this.stats.totalConnections++ | |
| 114 | 187 | |
| 115 | const proto = attachProtocol(conn); | |
| 188 | const proto = attachProtocol(conn) | |
| 116 | 189 | if (!proto) { |
| 117 | this.emit('error', new Error('Failed to attach protocol for peer ' + peerKey)); | |
| 118 | return; | |
| 190 | this.emit('error', new Error('Failed to attach protocol for peer ' + peerKey)) | |
| 191 | return | |
| 119 | 192 | } |
| 120 | 193 | |
| 121 | const session = { peerKey, destroyed: false, filesServed: 0, bytesServed: 0 }; | |
| 122 | this._sessions.add(session); | |
| 123 | this.emit('peer', { key: peerKey, state: 'connected' }); | |
| 194 | const session = { peerKey, destroyed: false, filesServed: 0, bytesServed: 0 } | |
| 195 | this._sessions.add(session) | |
| 196 | this.emit('peer', { key: peerKey, state: 'connected' }) | |
| 124 | 197 | |
| 125 | 198 | conn.on('close', () => { |
| 126 | this._sessions.delete(session); | |
| 127 | this.emit('peer', { key: peerKey, state: 'disconnected' }); | |
| 128 | }); | |
| 199 | this._sessions.delete(session) | |
| 200 | this.emit('peer', { key: peerKey, state: 'disconnected' }) | |
| 201 | }) | |
| 129 | 202 | |
| 130 | 203 | conn.on('error', (err) => { |
| 131 | this._sessions.delete(session); | |
| 132 | this.emit('error', err); | |
| 133 | }); | |
| 134 | ||
| 135 | proto.sendMeta({ | |
| 136 | type: 'manifest', | |
| 137 | version: PROTOCOL_VERSION, | |
| 138 | files: this.manifest.files, | |
| 139 | manifestHash: this._manifestHash | |
| 140 | }).catch(safetyCatch); | |
| 141 | ||
| 142 | proto.metaMsg.onmessage = async (raw) => { | |
| 143 | if (session.destroyed) return; | |
| 144 | ||
| 145 | let msg; | |
| 146 | try { msg = proto.parseMeta(raw); } catch { return; } | |
| 147 | ||
| 148 | if (msg.type === 'up-to-date') { | |
| 149 | this.emit('peer', { key: peerKey, state: 'done' }); | |
| 150 | return; | |
| 204 | this._sessions.delete(session) | |
| 205 | this.emit('error', err) | |
| 206 | }) | |
| 207 | ||
| 208 | // Send file list | |
| 209 | const fileList = this.manifest.files.map(f => ({ | |
| 210 | path: f.path, contentHash: f.hash, size: f.size | |
| 211 | })) | |
| 212 | proto.sendFileList(fileList).catch(safetyCatch) | |
| 213 | ||
| 214 | // Handle file requests | |
| 215 | proto.onFileRequest(async (req) => { | |
| 216 | if (session.destroyed) return | |
| 217 | ||
| 218 | this.emit('peer', { key: peerKey, state: 'transferring' }) | |
| 219 | ||
| 220 | const fullPath = path.join(this.dir, req.path) | |
| 221 | let stat | |
| 222 | try { stat = fs.statSync(fullPath) } catch { | |
| 223 | await proto.sendControl({ type: 'error', message: 'File not found', path: req.path }) | |
| 224 | return | |
| 151 | 225 | } |
| 152 | 226 | |
| 153 | if (msg.type === 'requests') { | |
| 154 | this.emit('peer', { key: peerKey, state: 'transferring' }); | |
| 155 | ||
| 156 | const requested = new Set(msg.paths); | |
| 157 | this.emit('progress', { | |
| 158 | peer: peerKey, | |
| 159 | requested: requested.size, | |
| 160 | skipped: this.manifest.files.length - requested.size | |
| 161 | }); | |
| 162 | ||
| 163 | for (const f of this.manifest.files) { | |
| 164 | if (session.destroyed) break; | |
| 165 | if (!requested.has(f.path)) continue; | |
| 166 | ||
| 167 | const fullPath = path.join(this.dir, f.path); | |
| 168 | let stat; | |
| 169 | try { stat = fs.statSync(fullPath); } catch { | |
| 170 | await proto.sendMeta({ type: 'error', message: 'File not found', path: f.path }); | |
| 171 | continue; | |
| 172 | } | |
| 173 | ||
| 174 | await proto.sendMeta({ type: 'file-start', path: f.path, size: stat.size, hash: f.hash }); | |
| 175 | ||
| 176 | let fd; | |
| 177 | try { | |
| 178 | fd = fs.openSync(fullPath, 'r'); | |
| 179 | for (let off = 0; off < stat.size; off += CHUNK_SIZE) { | |
| 180 | if (session.destroyed) break; | |
| 181 | const len = Math.min(CHUNK_SIZE, stat.size - off); | |
| 182 | const buf = Buffer.alloc(len); | |
| 183 | fs.readSync(fd, buf, 0, len, off); | |
| 184 | await proto.sendData(buf); | |
| 185 | session.bytesServed += len; | |
| 186 | this.stats.bytesServed += len; | |
| 187 | } | |
| 188 | } finally { | |
| 189 | if (fd !== undefined) try { fs.closeSync(fd); } catch {} | |
| 190 | } | |
| 191 | ||
| 192 | await proto.sendMeta({ type: 'file-end', path: f.path }); | |
| 227 | if (stat.size >= CHUNK_THRESHOLD) { | |
| 228 | // Large file: use TransferManager chunking | |
| 229 | const prepared = await this._transferManager.prepareFile(fullPath, req.path) | |
| 230 | if (!prepared) { | |
| 231 | await proto.sendControl({ type: 'error', message: 'Prepare failed', path: req.path }) | |
| 232 | return | |
| 233 | } | |
| 193 | 234 | |
| 194 | session.filesServed++; | |
| 195 | this.stats.filesServed++; | |
| 196 | this.emit('progress', { | |
| 197 | peer: peerKey, | |
| 198 | file: f.path, | |
| 199 | filesServed: session.filesServed, | |
| 200 | bytesServed: session.bytesServed | |
| 201 | }); | |
| 235 | await proto.sendChunkInfo({ | |
| 236 | path: req.path, | |
| 237 | size: prepared.size, | |
| 238 | chunks: prepared.chunks | |
| 239 | }) | |
| 240 | ||
| 241 | for (let i = 0; i < prepared.chunks.length; i++) { | |
| 242 | if (session.destroyed) break | |
| 243 | const bytes = this._transferManager.readChunk(fullPath, prepared.chunks[i].offset, prepared.chunks[i].length) | |
| 244 | if (!bytes) continue | |
| 245 | await proto.sendChunkData(i, bytes) | |
| 246 | session.bytesServed += bytes.length | |
| 247 | this.stats.bytesServed += bytes.length | |
| 202 | 248 | } |
| 249 | } else { | |
| 250 | // Small file: send as single whole-file blob | |
| 251 | await proto.sendChunkInfo({ | |
| 252 | path: req.path, | |
| 253 | size: stat.size, | |
| 254 | chunks: [{ hash: hashFile(fullPath), offset: 0, length: stat.size }] | |
| 255 | }) | |
| 256 | ||
| 257 | const data = fs.readFileSync(fullPath) | |
| 258 | await proto.sendChunkData(0, data) | |
| 259 | session.bytesServed += data.length | |
| 260 | this.stats.bytesServed += data.length | |
| 261 | } | |
| 203 | 262 | |
| 204 | await proto.sendMeta({ | |
| 205 | type: 'transfer-complete', | |
| 206 | filesServed: session.filesServed, | |
| 207 | bytesServed: session.bytesServed | |
| 208 | }); | |
| 263 | await proto.sendControl({ type: 'file-end', path: req.path }) | |
| 209 | 264 | |
| 210 | this.emit('peer', { key: peerKey, state: 'done' }); | |
| 211 | } | |
| 265 | session.filesServed++ | |
| 266 | this.stats.filesServed++ | |
| 267 | this.emit('progress', { | |
| 268 | peer: peerKey, | |
| 269 | file: req.path, | |
| 270 | filesServed: session.filesServed, | |
| 271 | bytesServed: session.bytesServed | |
| 272 | }) | |
| 273 | }) | |
| 212 | 274 | |
| 213 | if (msg.type === 'done') { | |
| 214 | this._sessions.delete(session); | |
| 275 | // Handle control messages | |
| 276 | proto.onControl((msg) => { | |
| 277 | if (msg.type === 'up-to-date') { | |
| 278 | this.emit('peer', { key: peerKey, state: 'done' }) | |
| 279 | } else if (msg.type === 'done') { | |
| 280 | this.emit('peer', { key: peerKey, state: 'done' }) | |
| 281 | this._sessions.delete(session) | |
| 215 | 282 | } |
| 216 | }; | |
| 283 | }) | |
| 217 | 284 | } |
| 218 | 285 | } |
| 219 | 286 | |
| 220 | // ─── DeployClient ──────────────────────────────────────────── | |
| 287 | // ── DeployClient ───────────────────────────────────────────── | |
| 221 | 288 | |
| 222 | class DeployClient extends ReadyResource { | |
| 289 | export class DeployClient extends ReadyResource { | |
| 223 | 290 | constructor (topic, destDir, opts) { |
| 224 | super(); | |
| 225 | opts = opts || {}; | |
| 226 | ||
| 227 | this.topic = typeof topic === 'string' ? Buffer.from(topic, 'hex') : topic; | |
| 228 | this.destDir = path.resolve(destDir); | |
| 229 | this.swarm = opts.swarm || null; | |
| 230 | this._ownSwarm = !this.swarm; | |
| 231 | this._timeout = opts.timeout !== undefined ? opts.timeout : 30000; | |
| 232 | this._verify = opts.verify !== false; | |
| 233 | this.connected = false; | |
| 291 | super() | |
| 292 | opts = opts || {} | |
| 293 | ||
| 294 | this.topic = typeof topic === 'string' ? Buffer.from(topic, 'hex') : topic | |
| 295 | this.destDir = path.resolve(destDir) | |
| 296 | this.swarm = opts.swarm || null | |
| 297 | this._ownSwarm = !this.swarm | |
| 298 | this._corestore = opts.corestore || null | |
| 299 | this._ownStore = !this._corestore | |
| 300 | this._fileIndex = null | |
| 301 | this._transferManager = null | |
| 302 | this._timeout = opts.timeout !== undefined ? opts.timeout : 30000 | |
| 303 | this._verify = opts.verify !== false | |
| 304 | this.connected = false | |
| 234 | 305 | |
| 235 | 306 | this.stats = { |
| 236 | 307 | filesTransferred: 0, |
| … | … | @@ -239,236 +310,261 @@ class DeployClient extends ReadyResource { |
| 239 | 310 | totalFiles: 0, |
| 240 | 311 | elapsed: 0, |
| 241 | 312 | errors: [] |
| 242 | }; | |
| 243 | ||
| 244 | this._pullResolve = null; | |
| 245 | this._pullReject = null; | |
| 246 | this._startTime = null; | |
| 247 | this._currentFd = null; | |
| 248 | this._currentPath = null; | |
| 249 | this._currentHash = null; | |
| 250 | this._currentWritten = 0; | |
| 251 | this._connectTimer = null; | |
| 252 | ||
| 253 | this.ready().catch(safetyCatch); | |
| 313 | } | |
| 314 | ||
| 315 | this._pullResolve = null | |
| 316 | this._pullReject = null | |
| 317 | this._startTime = null | |
| 318 | this._connectTimer = null | |
| 319 | this._completed = false | |
| 320 | this._completedStats = null | |
| 321 | ||
| 322 | this.ready().catch(safetyCatch) | |
| 254 | 323 | } |
| 255 | 324 | |
| 256 | 325 | async _open () { |
| 257 | fs.mkdirSync(this.destDir, { recursive: true }); | |
| 326 | fs.mkdirSync(this.destDir, { recursive: true }) | |
| 258 | 327 | |
| 259 | if (!this.swarm) this.swarm = new Hyperswarm(); | |
| 260 | this.swarm.on('connection', (conn) => this._onConnection(conn)); | |
| 328 | // Setup corestore + overlay primitives | |
| 329 | if (!this._corestore) { | |
| 330 | const tmp = path.join(os.tmpdir(), 'hyper-deploy-cli-' + Date.now()) | |
| 331 | this._corestore = new Corestore(tmp) | |
| 332 | } | |
| 333 | ||
| 334 | this._fileIndex = new FileIndex(this._corestore) | |
| 335 | await this._fileIndex.ready() | |
| 336 | this._transferManager = new TransferManager(this._fileIndex) | |
| 337 | ||
| 338 | // Start swarm | |
| 339 | if (!this.swarm) this.swarm = new Hyperswarm() | |
| 340 | this.swarm.on('connection', (conn) => this._onConnection(conn)) | |
| 261 | 341 | |
| 262 | this.swarm.join(this.topic, { server: false, client: true }); | |
| 263 | await this.swarm.flush(); | |
| 342 | this.swarm.join(this.topic, { server: false, client: true }) | |
| 343 | await this.swarm.flush() | |
| 264 | 344 | } |
| 265 | 345 | |
| 266 | 346 | async _close () { |
| 267 | if (this._currentFd !== null) { | |
| 268 | try { fs.closeSync(this._currentFd); } catch {} | |
| 269 | this._currentFd = null; | |
| 270 | } | |
| 271 | ||
| 272 | 347 | if (this._connectTimer) { |
| 273 | clearTimeout(this._connectTimer); | |
| 274 | this._connectTimer = null; | |
| 348 | clearTimeout(this._connectTimer) | |
| 349 | this._connectTimer = null | |
| 275 | 350 | } |
| 276 | 351 | |
| 277 | 352 | if (this._pullReject) { |
| 278 | this._pullReject(new Error('Client closed during pull')); | |
| 279 | this._pullReject = null; | |
| 280 | this._pullResolve = null; | |
| 353 | this._pullReject(new Error('Client closed during pull')) | |
| 354 | this._pullReject = null | |
| 355 | this._pullResolve = null | |
| 281 | 356 | } |
| 282 | 357 | |
| 358 | if (this._fileIndex) await this._fileIndex.close() | |
| 359 | if (this._ownStore && this._corestore) await this._corestore.close() | |
| 360 | ||
| 283 | 361 | if (this._ownSwarm && this.swarm) { |
| 284 | await this.swarm.destroy(); | |
| 285 | this.swarm = null; | |
| 362 | await this.swarm.destroy() | |
| 363 | this.swarm = null | |
| 286 | 364 | } |
| 287 | 365 | } |
| 288 | 366 | |
| 289 | 367 | pull () { |
| 290 | if (this._pullResolve) return Promise.reject(new Error('Pull already in progress')); | |
| 368 | // Already completed (transfer happened during ready/connection) | |
| 369 | if (this._completed) return Promise.resolve(this._completedStats) | |
| 370 | if (this._pullResolve) return Promise.reject(new Error('Pull already in progress')) | |
| 291 | 371 | |
| 292 | this._startTime = Date.now(); | |
| 372 | if (!this._startTime) this._startTime = Date.now() | |
| 293 | 373 | |
| 294 | 374 | return new Promise((resolve, reject) => { |
| 295 | this._pullResolve = resolve; | |
| 296 | this._pullReject = reject; | |
| 375 | this._pullResolve = resolve | |
| 376 | this._pullReject = reject | |
| 297 | 377 | |
| 298 | 378 | if (this._timeout > 0 && !this.connected) { |
| 299 | 379 | this._connectTimer = setTimeout(() => { |
| 300 | 380 | if (!this.connected && this._pullReject) { |
| 301 | this._pullReject(new Error('Connection timeout after ' + this._timeout + 'ms')); | |
| 302 | this._pullReject = null; | |
| 303 | this._pullResolve = null; | |
| 381 | this._pullReject(new Error('Connection timeout after ' + this._timeout + 'ms')) | |
| 382 | this._pullReject = null | |
| 383 | this._pullResolve = null | |
| 304 | 384 | } |
| 305 | }, this._timeout); | |
| 385 | }, this._timeout) | |
| 306 | 386 | } |
| 307 | }); | |
| 387 | }) | |
| 308 | 388 | } |
| 309 | 389 | |
| 310 | 390 | _finish () { |
| 311 | this.stats.elapsed = Date.now() - this._startTime; | |
| 391 | if (this._completed) return | |
| 392 | this.stats.elapsed = Date.now() - (this._startTime || Date.now()) | |
| 393 | this._completed = true | |
| 394 | this._completedStats = { ...this.stats } | |
| 395 | ||
| 312 | 396 | if (this._pullResolve) { |
| 313 | const resolve = this._pullResolve; | |
| 314 | this._pullResolve = null; | |
| 315 | this._pullReject = null; | |
| 316 | resolve({ ...this.stats }); | |
| 397 | const resolve = this._pullResolve | |
| 398 | this._pullResolve = null | |
| 399 | this._pullReject = null | |
| 400 | resolve(this._completedStats) | |
| 317 | 401 | } |
| 318 | this.emit('complete', { ...this.stats }); | |
| 402 | this.emit('complete', this._completedStats) | |
| 319 | 403 | } |
| 320 | 404 | |
| 321 | 405 | _fail (err) { |
| 322 | this.stats.elapsed = Date.now() - (this._startTime || Date.now()); | |
| 323 | this.emit('error', err); | |
| 406 | this.stats.elapsed = Date.now() - (this._startTime || Date.now()) | |
| 407 | this.emit('error', err) | |
| 324 | 408 | if (this._pullReject) { |
| 325 | const reject = this._pullReject; | |
| 326 | this._pullReject = null; | |
| 327 | this._pullResolve = null; | |
| 328 | reject(err); | |
| 409 | const reject = this._pullReject | |
| 410 | this._pullReject = null | |
| 411 | this._pullResolve = null | |
| 412 | reject(err) | |
| 329 | 413 | } |
| 330 | 414 | } |
| 331 | 415 | |
| 332 | 416 | _onConnection (conn) { |
| 333 | 417 | if (this._connectTimer) { |
| 334 | clearTimeout(this._connectTimer); | |
| 335 | this._connectTimer = null; | |
| 418 | clearTimeout(this._connectTimer) | |
| 419 | this._connectTimer = null | |
| 336 | 420 | } |
| 337 | 421 | |
| 338 | this.connected = true; | |
| 422 | this.connected = true | |
| 423 | if (!this._startTime) this._startTime = Date.now() | |
| 339 | 424 | const peerKey = conn.remotePublicKey |
| 340 | 425 | ? conn.remotePublicKey.toString('hex').slice(0, 12) |
| 341 | : 'unknown'; | |
| 342 | this.emit('connected', peerKey); | |
| 426 | : 'unknown' | |
| 427 | this.emit('connected', peerKey) | |
| 343 | 428 | |
| 344 | 429 | conn.on('close', () => { |
| 345 | this.connected = false; | |
| 430 | this.connected = false | |
| 346 | 431 | if (this._pullReject && this.stats.filesTransferred + this.stats.filesSkipped < this.stats.totalFiles) { |
| 347 | this._fail(new Error('Connection lost during transfer')); | |
| 432 | this._fail(new Error('Connection lost during transfer')) | |
| 348 | 433 | } |
| 349 | }); | |
| 434 | }) | |
| 350 | 435 | |
| 351 | 436 | conn.on('error', (err) => { |
| 352 | this.connected = false; | |
| 353 | this._fail(err); | |
| 354 | }); | |
| 437 | this.connected = false | |
| 438 | this._fail(err) | |
| 439 | }) | |
| 355 | 440 | |
| 356 | const proto = attachProtocol(conn); | |
| 441 | const proto = attachProtocol(conn) | |
| 357 | 442 | if (!proto) { |
| 358 | this._fail(new Error('Failed to attach protocol')); | |
| 359 | return; | |
| 443 | this._fail(new Error('Failed to attach protocol')) | |
| 444 | return | |
| 360 | 445 | } |
| 361 | 446 | |
| 362 | proto.metaMsg.onmessage = (raw) => { | |
| 363 | let msg; | |
| 364 | try { msg = proto.parseMeta(raw); } catch { | |
| 365 | this._fail(new Error('Invalid protocol message')); | |
| 366 | return; | |
| 367 | } | |
| 447 | // State for current file transfer | |
| 448 | let pendingFiles = [] | |
| 449 | let currentFileIdx = 0 | |
| 450 | let currentPath = null | |
| 368 | 451 | |
| 369 | if (msg.type === 'manifest') this._handleManifest(msg, proto); | |
| 370 | else if (msg.type === 'file-start') this._handleFileStart(msg); | |
| 371 | else if (msg.type === 'file-end') this._handleFileEnd(msg); | |
| 372 | else if (msg.type === 'transfer-complete') this._handleTransferComplete(msg, proto); | |
| 373 | else if (msg.type === 'error') { | |
| 374 | this.stats.errors.push({ path: msg.path, message: msg.message }); | |
| 375 | this.emit('error', new Error(msg.message)); | |
| 376 | } | |
| 377 | }; | |
| 452 | // Receive file list from server, diff against local | |
| 453 | proto.onFileList((files) => { | |
| 454 | this.stats.totalFiles = files.length | |
| 455 | this.emit('manifest', files.length) | |
| 378 | 456 | |
| 379 | proto.dataMsg.onmessage = (chunk) => { | |
| 380 | if (this._currentFd !== null) { | |
| 457 | // Diff: which files do we need? | |
| 458 | const needed = [] | |
| 459 | let skipped = 0 | |
| 460 | ||
| 461 | for (const f of files) { | |
| 462 | const localPath = path.join(this.destDir, f.path) | |
| 381 | 463 | try { |
| 382 | fs.writeSync(this._currentFd, chunk, 0, chunk.length, this._currentWritten); | |
| 383 | this._currentWritten += chunk.length; | |
| 384 | this.stats.bytesReceived += chunk.length; | |
| 385 | } catch (err) { | |
| 386 | this._fail(new Error('Write failed: ' + err.message)); | |
| 387 | } | |
| 464 | const stat = fs.statSync(localPath) | |
| 465 | if (stat.size === f.size) { | |
| 466 | const localHash = hashFile(localPath) | |
| 467 | if (localHash === f.contentHash) { | |
| 468 | skipped++ | |
| 469 | continue | |
| 470 | } | |
| 471 | } | |
| 472 | } catch {} | |
| 473 | needed.push(f) | |
| 388 | 474 | } |
| 389 | }; | |
| 390 | } | |
| 391 | 475 | |
| 392 | _handleManifest (msg, proto) { | |
| 393 | if (msg.version && msg.version !== PROTOCOL_VERSION) { | |
| 394 | this._fail(new Error('Protocol version mismatch: expected ' + PROTOCOL_VERSION + ', got ' + msg.version)); | |
| 395 | return; | |
| 396 | } | |
| 397 | ||
| 398 | this.stats.totalFiles = msg.files.length; | |
| 399 | this.emit('manifest', msg.files.length); | |
| 476 | this.stats.filesSkipped = skipped | |
| 477 | this.emit('diff', needed.length, skipped) | |
| 400 | 478 | |
| 401 | const { needed, skipped } = diffManifest(msg.files, this.destDir); | |
| 402 | this.stats.filesSkipped = skipped; | |
| 403 | this.emit('diff', needed.length, skipped); | |
| 404 | ||
| 405 | if (needed.length === 0) { | |
| 406 | proto.sendMeta({ type: 'up-to-date' }).catch(safetyCatch); | |
| 407 | this.emit('up-to-date'); | |
| 408 | this._finish(); | |
| 409 | return; | |
| 410 | } | |
| 479 | if (needed.length === 0) { | |
| 480 | proto.sendControl({ type: 'up-to-date' }).catch(safetyCatch) | |
| 481 | this.emit('up-to-date') | |
| 482 | this._finish() | |
| 483 | return | |
| 484 | } | |
| 411 | 485 | |
| 412 | proto.sendMeta({ type: 'requests', paths: needed }).catch(safetyCatch); | |
| 413 | } | |
| 486 | pendingFiles = needed | |
| 487 | currentFileIdx = 0 | |
| 488 | ||
| 489 | // Request first file | |
| 490 | proto.sendFileRequest(needed[0].path).catch(safetyCatch) | |
| 491 | }) | |
| 492 | ||
| 493 | // Receive chunk info for a file — setup TransferManager receive | |
| 494 | proto.onChunkInfo((info) => { | |
| 495 | currentPath = info.path | |
| 496 | const targetPath = path.join(this.destDir, info.path) | |
| 497 | this._transferManager.startReceive(targetPath, { | |
| 498 | size: info.size, | |
| 499 | chunks: info.chunks | |
| 500 | }) | |
| 501 | }) | |
| 502 | ||
| 503 | // Receive chunk data | |
| 504 | proto.onChunkData((index, data) => { | |
| 505 | if (!currentPath) return | |
| 506 | const targetPath = path.join(this.destDir, currentPath) | |
| 507 | ||
| 508 | const result = this._transferManager.writeChunk(targetPath, index, data) | |
| 509 | if (!result.ok) { | |
| 510 | this.stats.errors.push({ path: currentPath, message: result.error }) | |
| 511 | this.emit('error', new Error(result.error)) | |
| 512 | } | |
| 414 | 513 | |
| 415 | _handleFileStart (msg) { | |
| 416 | const targetPath = path.resolve(path.join(this.destDir, msg.path)); | |
| 514 | this.stats.bytesReceived += data.length | |
| 515 | }) | |
| 417 | 516 | |
| 418 | if (!targetPath.startsWith(this.destDir + path.sep) && targetPath !== this.destDir) { | |
| 419 | this.stats.errors.push({ path: msg.path, message: 'Path traversal rejected' }); | |
| 420 | this.emit('error', new Error('Path traversal rejected: ' + msg.path)); | |
| 421 | return; | |
| 422 | } | |
| 517 | // Receive control messages (file-end, error, etc.) | |
| 518 | proto.onControl((msg) => { | |
| 519 | if (msg.type === 'file-end') { | |
| 520 | const targetPath = path.join(this.destDir, msg.path) | |
| 423 | 521 | |
| 424 | fs.mkdirSync(path.dirname(targetPath), { recursive: true }); | |
| 522 | if (this._transferManager.isComplete(targetPath)) { | |
| 523 | const fin = this._transferManager.finalize(targetPath) | |
| 524 | if (!fin.ok) { | |
| 525 | this.stats.errors.push({ path: msg.path, message: fin.error }) | |
| 526 | } | |
| 527 | } | |
| 425 | 528 | |
| 426 | this._currentPath = msg.path; | |
| 427 | this._currentHash = msg.hash; | |
| 428 | this._currentFd = fs.openSync(targetPath, 'w'); | |
| 429 | this._currentWritten = 0; | |
| 430 | if (msg.size > 0) fs.ftruncateSync(this._currentFd, msg.size); | |
| 431 | } | |
| 529 | // Post-transfer verification (whole-file hash) | |
| 530 | if (this._verify && pendingFiles[currentFileIdx]) { | |
| 531 | try { | |
| 532 | const actual = hashFile(targetPath) | |
| 533 | const expected = pendingFiles[currentFileIdx].contentHash | |
| 534 | if (actual !== expected) { | |
| 535 | const err = 'Hash mismatch for ' + msg.path | |
| 536 | this.stats.errors.push({ path: msg.path, message: err }) | |
| 537 | this.emit('error', new Error(err)) | |
| 538 | try { fs.unlinkSync(targetPath) } catch {} | |
| 539 | } | |
| 540 | } catch (err) { | |
| 541 | this.stats.errors.push({ path: msg.path, message: err.message }) | |
| 542 | } | |
| 543 | } | |
| 432 | 544 | |
| 433 | _handleFileEnd (msg) { | |
| 434 | if (this._currentFd !== null) { | |
| 435 | fs.closeSync(this._currentFd); | |
| 436 | this._currentFd = null; | |
| 437 | } | |
| 545 | this.stats.filesTransferred++ | |
| 546 | currentPath = null | |
| 438 | 547 | |
| 439 | if (this._verify && this._currentHash) { | |
| 440 | const targetPath = path.join(this.destDir, msg.path); | |
| 441 | try { | |
| 442 | const actual = hashFile(targetPath); | |
| 443 | if (actual !== this._currentHash) { | |
| 444 | const err = 'Hash mismatch for ' + msg.path; | |
| 445 | this.stats.errors.push({ path: msg.path, message: err }); | |
| 446 | this.emit('error', new Error(err)); | |
| 447 | try { fs.unlinkSync(targetPath); } catch {} | |
| 448 | return; | |
| 548 | this.emit('progress', { | |
| 549 | file: msg.path, | |
| 550 | filesTransferred: this.stats.filesTransferred, | |
| 551 | filesSkipped: this.stats.filesSkipped, | |
| 552 | bytesReceived: this.stats.bytesReceived, | |
| 553 | totalFiles: this.stats.totalFiles | |
| 554 | }) | |
| 555 | ||
| 556 | // Request next file or finish | |
| 557 | currentFileIdx++ | |
| 558 | if (currentFileIdx < pendingFiles.length) { | |
| 559 | proto.sendFileRequest(pendingFiles[currentFileIdx].path).catch(safetyCatch) | |
| 560 | } else { | |
| 561 | proto.sendControl({ type: 'done' }).catch(safetyCatch) | |
| 562 | this._finish() | |
| 449 | 563 | } |
| 450 | } catch (err) { | |
| 451 | this.stats.errors.push({ path: msg.path, message: err.message }); | |
| 564 | } else if (msg.type === 'error') { | |
| 565 | this.stats.errors.push({ path: msg.path, message: msg.message }) | |
| 566 | this.emit('error', new Error(msg.message)) | |
| 452 | 567 | } |
| 453 | } | |
| 454 | ||
| 455 | this.stats.filesTransferred++; | |
| 456 | this._currentPath = null; | |
| 457 | this._currentHash = null; | |
| 458 | ||
| 459 | this.emit('progress', { | |
| 460 | file: msg.path, | |
| 461 | filesTransferred: this.stats.filesTransferred, | |
| 462 | filesSkipped: this.stats.filesSkipped, | |
| 463 | bytesReceived: this.stats.bytesReceived, | |
| 464 | totalFiles: this.stats.totalFiles | |
| 465 | }); | |
| 466 | } | |
| 467 | ||
| 468 | _handleTransferComplete (msg, proto) { | |
| 469 | proto.sendMeta({ type: 'done' }).catch(safetyCatch); | |
| 470 | this._finish(); | |
| 568 | }) | |
| 471 | 569 | } |
| 472 | 570 | } |
| 473 | ||
| 474 | module.exports = { DeployServer, DeployClient }; |
| … | … | @@ -1,60 +1,121 @@ |
| 1 | const Protomux = require('protomux'); | |
| 2 | const c = require('compact-encoding'); | |
| 1 | /** | |
| 2 | * Deploy Protocol — Protomux channel for release distribution | |
| 3 | * | |
| 4 | * Messages: | |
| 5 | * 0: fileList (server→client) JSON array of { path, contentHash, size } | |
| 6 | * 1: fileRequest (client→server) JSON { path } | |
| 7 | * 2: chunkInfo (server→client) JSON { path, size, chunks: [{ hash, offset, length }] } | |
| 8 | * 3: chunkData (server→client) 4-byte LE index + raw chunk bytes | |
| 9 | * 4: control (bidirectional) JSON { type, ... } | |
| 10 | * | |
| 11 | * Backpressure: ondrain callback on Protomux channel. | |
| 12 | */ | |
| 3 | 13 | |
| 4 | const PROTOCOL_NAME = 'hyper-deploy'; | |
| 5 | const PROTOCOL_VERSION = 1; | |
| 6 | const CHUNK_SIZE = 65536; | |
| 14 | import Protomux from 'protomux' | |
| 15 | import c from 'compact-encoding' | |
| 7 | 16 | |
| 8 | function attachProtocol (stream) { | |
| 9 | const mux = Protomux.from(stream); | |
| 10 | if (!mux) return null; | |
| 17 | const PROTOCOL_NAME = 'hyper-deploy' | |
| 18 | const PROTOCOL_VERSION = 2 | |
| 11 | 19 | |
| 12 | let drainResolve = null; | |
| 20 | /** | |
| 21 | * Attach the deploy protocol to a stream. | |
| 22 | * | |
| 23 | * @param {Stream} stream — encrypted stream (SecretStream or Hyperswarm connection) | |
| 24 | * @returns {{ channel, msgs, send, onMessage, destroy } | null} | |
| 25 | */ | |
| 26 | export function attachProtocol (stream) { | |
| 27 | const mux = Protomux.from(stream) | |
| 28 | if (!mux) return null | |
| 13 | 29 | |
| 14 | const ch = mux.createChannel({ | |
| 30 | let drainResolve = null | |
| 31 | ||
| 32 | const channel = mux.createChannel({ | |
| 15 | 33 | protocol: PROTOCOL_NAME, |
| 16 | 34 | ondrain () { |
| 17 | 35 | if (drainResolve) { |
| 18 | const r = drainResolve; | |
| 19 | drainResolve = null; | |
| 20 | r(); | |
| 36 | const r = drainResolve | |
| 37 | drainResolve = null | |
| 38 | r() | |
| 21 | 39 | } |
| 22 | 40 | } |
| 23 | }); | |
| 41 | }) | |
| 24 | 42 | |
| 25 | if (!ch) return null; | |
| 43 | if (!channel) return null | |
| 26 | 44 | |
| 27 | const metaMsg = ch.addMessage({ encoding: c.raw }); | |
| 28 | const dataMsg = ch.addMessage({ encoding: c.raw }); | |
| 45 | const fileListMsg = channel.addMessage({ encoding: c.raw }) | |
| 46 | const fileRequestMsg = channel.addMessage({ encoding: c.raw }) | |
| 47 | const chunkInfoMsg = channel.addMessage({ encoding: c.raw }) | |
| 48 | const chunkDataMsg = channel.addMessage({ encoding: c.raw }) | |
| 49 | const controlMsg = channel.addMessage({ encoding: c.raw }) | |
| 29 | 50 | |
| 30 | ch.open(); | |
| 51 | channel.open() | |
| 31 | 52 | |
| 32 | 53 | async function waitDrain () { |
| 33 | await new Promise(r => { drainResolve = r; }); | |
| 54 | await new Promise(r => { drainResolve = r }) | |
| 55 | } | |
| 56 | ||
| 57 | async function sendRaw (msg, buf) { | |
| 58 | if (!msg.send(buf)) await waitDrain() | |
| 34 | 59 | } |
| 35 | 60 | |
| 36 | 61 | return { |
| 37 | ch, | |
| 38 | metaMsg, | |
| 39 | dataMsg, | |
| 40 | mux, | |
| 41 | ||
| 42 | async sendMeta (obj) { | |
| 43 | if (!metaMsg.send(Buffer.from(JSON.stringify(obj)))) { | |
| 44 | await waitDrain(); | |
| 45 | } | |
| 62 | channel, | |
| 63 | version: PROTOCOL_VERSION, | |
| 64 | ||
| 65 | // ── Senders ────────────────────────────────────────────── | |
| 66 | ||
| 67 | async sendFileList (files) { | |
| 68 | await sendRaw(fileListMsg, Buffer.from(JSON.stringify(files))) | |
| 69 | }, | |
| 70 | ||
| 71 | async sendFileRequest (path) { | |
| 72 | await sendRaw(fileRequestMsg, Buffer.from(JSON.stringify({ path }))) | |
| 73 | }, | |
| 74 | ||
| 75 | async sendChunkInfo (info) { | |
| 76 | await sendRaw(chunkInfoMsg, Buffer.from(JSON.stringify(info))) | |
| 77 | }, | |
| 78 | ||
| 79 | async sendChunkData (index, data) { | |
| 80 | const header = Buffer.alloc(4) | |
| 81 | header.writeUInt32LE(index) | |
| 82 | await sendRaw(chunkDataMsg, Buffer.concat([header, data])) | |
| 46 | 83 | }, |
| 47 | 84 | |
| 48 | async sendData (buf) { | |
| 49 | if (!dataMsg.send(buf)) { | |
| 50 | await waitDrain(); | |
| 85 | async sendControl (obj) { | |
| 86 | await sendRaw(controlMsg, Buffer.from(JSON.stringify(obj))) | |
| 87 | }, | |
| 88 | ||
| 89 | // ── Receivers ───────────────────────────────────────────── | |
| 90 | ||
| 91 | onFileList (fn) { | |
| 92 | fileListMsg.onmessage = (raw) => fn(JSON.parse(raw.toString())) | |
| 93 | }, | |
| 94 | ||
| 95 | onFileRequest (fn) { | |
| 96 | fileRequestMsg.onmessage = (raw) => fn(JSON.parse(raw.toString())) | |
| 97 | }, | |
| 98 | ||
| 99 | onChunkInfo (fn) { | |
| 100 | chunkInfoMsg.onmessage = (raw) => fn(JSON.parse(raw.toString())) | |
| 101 | }, | |
| 102 | ||
| 103 | onChunkData (fn) { | |
| 104 | chunkDataMsg.onmessage = (raw) => { | |
| 105 | const index = raw.readUInt32LE(0) | |
| 106 | const data = raw.subarray(4) | |
| 107 | fn(index, data) | |
| 51 | 108 | } |
| 52 | 109 | }, |
| 53 | 110 | |
| 54 | parseMeta (raw) { | |
| 55 | return JSON.parse(raw.toString()); | |
| 111 | onControl (fn) { | |
| 112 | controlMsg.onmessage = (raw) => fn(JSON.parse(raw.toString())) | |
| 113 | }, | |
| 114 | ||
| 115 | destroy () { | |
| 116 | channel.close() | |
| 56 | 117 | } |
| 57 | }; | |
| 118 | } | |
| 58 | 119 | } |
| 59 | 120 | |
| 60 | module.exports = { attachProtocol, PROTOCOL_NAME, PROTOCOL_VERSION, CHUNK_SIZE }; | |
| 121 | export { PROTOCOL_NAME, PROTOCOL_VERSION } |
| … | … | @@ -1,71 +0,0 @@ |
| 1 | const fs = require('fs'); | |
| 2 | const path = require('path'); | |
| 3 | const crypto = require('hypercore-crypto'); | |
| 4 | ||
| 5 | function walkDir (dir, prefix) { | |
| 6 | prefix = prefix || ''; | |
| 7 | const results = []; | |
| 8 | try { | |
| 9 | for (const entry of fs.readdirSync(dir)) { | |
| 10 | const full = path.join(dir, entry); | |
| 11 | const rel = prefix ? prefix + '/' + entry : entry; | |
| 12 | try { | |
| 13 | const stat = fs.statSync(full); | |
| 14 | if (stat.isDirectory()) results.push(...walkDir(full, rel)); | |
| 15 | else if (stat.isFile()) results.push({ path: rel, size: stat.size }); | |
| 16 | } catch {} | |
| 17 | } | |
| 18 | } catch {} | |
| 19 | return results; | |
| 20 | } | |
| 21 | ||
| 22 | function hashFile (filePath) { | |
| 23 | return crypto.data(fs.readFileSync(filePath)).toString('hex'); | |
| 24 | } | |
| 25 | ||
| 26 | function buildManifest (dir, opts) { | |
| 27 | const onProgress = opts && opts.onProgress; | |
| 28 | const files = walkDir(dir); | |
| 29 | let totalSize = 0; | |
| 30 | ||
| 31 | for (let i = 0; i < files.length; i++) { | |
| 32 | const f = files[i]; | |
| 33 | f.hash = hashFile(path.join(dir, f.path)); | |
| 34 | totalSize += f.size; | |
| 35 | if (onProgress) onProgress(i + 1, files.length); | |
| 36 | } | |
| 37 | ||
| 38 | return { files, totalSize }; | |
| 39 | } | |
| 40 | ||
| 41 | function diffManifest (manifest, localDir) { | |
| 42 | const needed = []; | |
| 43 | let skipped = 0; | |
| 44 | ||
| 45 | for (const f of manifest) { | |
| 46 | const localPath = path.join(localDir, f.path); | |
| 47 | try { | |
| 48 | const stat = fs.statSync(localPath); | |
| 49 | if (stat.size === f.size) { | |
| 50 | const localHash = hashFile(localPath); | |
| 51 | if (localHash === f.hash) { | |
| 52 | skipped++; | |
| 53 | continue; | |
| 54 | } | |
| 55 | } | |
| 56 | } catch {} | |
| 57 | needed.push(f.path); | |
| 58 | } | |
| 59 | ||
| 60 | return { needed, skipped }; | |
| 61 | } | |
| 62 | ||
| 63 | function readChunk (filePath, offset, length) { | |
| 64 | const fd = fs.openSync(filePath, 'r'); | |
| 65 | const buf = Buffer.alloc(length); | |
| 66 | fs.readSync(fd, buf, 0, length, offset); | |
| 67 | fs.closeSync(fd); | |
| 68 | return buf; | |
| 69 | } | |
| 70 | ||
| 71 | module.exports = { walkDir, hashFile, buildManifest, diffManifest, readChunk }; |
| … | … | @@ -1,318 +0,0 @@ |
| 1 | /** | |
| 2 | * Web UI for hyper-deploy server status | |
| 3 | * | |
| 4 | * Serves a single-page status dashboard: | |
| 5 | * GET / — HTML page with live updates via SSE | |
| 6 | * GET /api/status — JSON status (files, peers, transfers) | |
| 7 | * GET /events — Server-Sent Events for live updates | |
| 8 | */ | |
| 9 | ||
| 10 | import http from 'bare-http1' | |
| 11 | ||
| 12 | function fmt (b) { | |
| 13 | if (b < 1024) return b + ' B' | |
| 14 | if (b < 1048576) return (b / 1024).toFixed(1) + ' KB' | |
| 15 | if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB' | |
| 16 | return (b / 1073741824).toFixed(2) + ' GB' | |
| 17 | } | |
| 18 | ||
| 19 | /** | |
| 20 | * Start the web UI server. | |
| 21 | * @param {DeployServer} deployServer — the running deploy server instance | |
| 22 | * @param {{ port?: number }} [opts] | |
| 23 | * @returns {{ server, port }} | |
| 24 | */ | |
| 25 | export function startWeb (deployServer, opts) { | |
| 26 | const port = (opts && opts.port) || 9090 | |
| 27 | const sseClients = new Set() | |
| 28 | ||
| 29 | function broadcast (event, data) { | |
| 30 | const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` | |
| 31 | for (const res of sseClients) { | |
| 32 | try { res.write(msg) } catch { sseClients.delete(res) } | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | // Wire deploy server events to SSE broadcasts | |
| 37 | deployServer.on('peer', (info) => broadcast('peer', info)) | |
| 38 | deployServer.on('progress', (info) => { | |
| 39 | broadcast('progress', { | |
| 40 | ...info, | |
| 41 | totalFilesServed: deployServer.stats.filesServed, | |
| 42 | totalBytesServed: deployServer.stats.bytesServed | |
| 43 | }) | |
| 44 | }) | |
| 45 | deployServer.on('error', (err) => broadcast('error', { message: err.message })) | |
| 46 | ||
| 47 | function getStatus () { | |
| 48 | return { | |
| 49 | dir: deployServer.dir, | |
| 50 | topic: deployServer.topicHex, | |
| 51 | files: deployServer.manifest ? deployServer.manifest.files.length : 0, | |
| 52 | totalSize: deployServer.manifest ? deployServer.manifest.totalSize : 0, | |
| 53 | totalSizeFmt: deployServer.manifest ? fmt(deployServer.manifest.totalSize) : '0', | |
| 54 | connections: deployServer.connections, | |
| 55 | stats: { | |
| 56 | totalConnections: deployServer.stats.totalConnections, | |
| 57 | filesServed: deployServer.stats.filesServed, | |
| 58 | bytesServed: deployServer.stats.bytesServed, | |
| 59 | bytesServedFmt: fmt(deployServer.stats.bytesServed) | |
| 60 | } | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | const server = http.createServer(async (req, res) => { | |
| 65 | const url = req.url.split('?')[0] | |
| 66 | ||
| 67 | if (url === '/api/status') { | |
| 68 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 69 | res.end(JSON.stringify(getStatus())) | |
| 70 | return | |
| 71 | } | |
| 72 | ||
| 73 | if (url === '/api/rescan' && req.method === 'POST') { | |
| 74 | try { | |
| 75 | const result = await deployServer.rescan() | |
| 76 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 77 | res.end(JSON.stringify({ ok: true, ...result, ...getStatus() })) | |
| 78 | } catch (err) { | |
| 79 | res.writeHead(500, { 'Content-Type': 'application/json' }) | |
| 80 | res.end(JSON.stringify({ ok: false, error: err.message })) | |
| 81 | } | |
| 82 | return | |
| 83 | } | |
| 84 | ||
| 85 | if (url === '/events') { | |
| 86 | res.writeHead(200, { | |
| 87 | 'Content-Type': 'text/event-stream', | |
| 88 | 'Cache-Control': 'no-cache', | |
| 89 | Connection: 'keep-alive' | |
| 90 | }) | |
| 91 | res.write(`data: ${JSON.stringify(getStatus())}\n\n`) | |
| 92 | sseClients.add(res) | |
| 93 | req.on('close', () => sseClients.delete(res)) | |
| 94 | return | |
| 95 | } | |
| 96 | ||
| 97 | // Serve HTML dashboard | |
| 98 | res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) | |
| 99 | res.end(renderPage()) | |
| 100 | }) | |
| 101 | ||
| 102 | server.listen(port, () => {}) | |
| 103 | ||
| 104 | return { server, port } | |
| 105 | } | |
| 106 | ||
| 107 | function renderPage () { | |
| 108 | return `<!DOCTYPE html> | |
| 109 | <html lang="en"> | |
| 110 | <head> | |
| 111 | <meta charset="UTF-8"> | |
| 112 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 113 | <title>hyper-deploy</title> | |
| 114 | <style> | |
| 115 | * { margin: 0; padding: 0; box-sizing: border-box; } | |
| 116 | body { | |
| 117 | font-family: -apple-system, system-ui, sans-serif; | |
| 118 | background: #0d1117; | |
| 119 | color: #c9d1d9; | |
| 120 | padding: 2rem; | |
| 121 | max-width: 720px; | |
| 122 | margin: 0 auto; | |
| 123 | } | |
| 124 | h1 { color: #58a6ff; font-size: 1.4rem; margin-bottom: 1.5rem; } | |
| 125 | .card { | |
| 126 | background: #161b22; | |
| 127 | border: 1px solid #30363d; | |
| 128 | border-radius: 6px; | |
| 129 | padding: 1rem 1.2rem; | |
| 130 | margin-bottom: 1rem; | |
| 131 | } | |
| 132 | .card h2 { | |
| 133 | font-size: 0.75rem; | |
| 134 | text-transform: uppercase; | |
| 135 | letter-spacing: 0.05em; | |
| 136 | color: #8b949e; | |
| 137 | margin-bottom: 0.6rem; | |
| 138 | } | |
| 139 | .topic { | |
| 140 | font-family: monospace; | |
| 141 | font-size: 0.8rem; | |
| 142 | word-break: break-all; | |
| 143 | color: #79c0ff; | |
| 144 | cursor: pointer; | |
| 145 | padding: 0.5rem; | |
| 146 | background: #0d1117; | |
| 147 | border-radius: 4px; | |
| 148 | position: relative; | |
| 149 | } | |
| 150 | .topic:hover { background: #1a2332; } | |
| 151 | .topic .copied { | |
| 152 | position: absolute; | |
| 153 | right: 0.5rem; | |
| 154 | top: 0.5rem; | |
| 155 | color: #3fb950; | |
| 156 | font-size: 0.7rem; | |
| 157 | display: none; | |
| 158 | } | |
| 159 | .stat-row { | |
| 160 | display: flex; | |
| 161 | justify-content: space-between; | |
| 162 | padding: 0.3rem 0; | |
| 163 | border-bottom: 1px solid #21262d; | |
| 164 | } | |
| 165 | .stat-row:last-child { border-bottom: none; } | |
| 166 | .stat-label { color: #8b949e; } | |
| 167 | .stat-value { color: #e6edf3; font-family: monospace; } | |
| 168 | .peers { | |
| 169 | min-height: 2rem; | |
| 170 | } | |
| 171 | .peer { | |
| 172 | display: inline-block; | |
| 173 | background: #1f6feb22; | |
| 174 | border: 1px solid #1f6feb44; | |
| 175 | color: #58a6ff; | |
| 176 | padding: 0.2rem 0.5rem; | |
| 177 | border-radius: 3px; | |
| 178 | font-family: monospace; | |
| 179 | font-size: 0.75rem; | |
| 180 | margin: 0.2rem; | |
| 181 | } | |
| 182 | .peer.done { border-color: #3fb95044; color: #3fb950; } | |
| 183 | .progress-bar { | |
| 184 | height: 4px; | |
| 185 | background: #21262d; | |
| 186 | border-radius: 2px; | |
| 187 | margin-top: 0.5rem; | |
| 188 | overflow: hidden; | |
| 189 | } | |
| 190 | .progress-fill { | |
| 191 | height: 100%; | |
| 192 | background: #1f6feb; | |
| 193 | border-radius: 2px; | |
| 194 | transition: width 0.3s; | |
| 195 | width: 0%; | |
| 196 | } | |
| 197 | .empty { color: #484f58; font-style: italic; font-size: 0.85rem; } | |
| 198 | .cmd { | |
| 199 | font-family: monospace; | |
| 200 | font-size: 0.8rem; | |
| 201 | color: #c9d1d9; | |
| 202 | background: #0d1117; | |
| 203 | padding: 0.5rem; | |
| 204 | border-radius: 4px; | |
| 205 | margin-top: 0.4rem; | |
| 206 | } | |
| 207 | .rescan-btn { | |
| 208 | background: #21262d; | |
| 209 | color: #c9d1d9; | |
| 210 | border: 1px solid #30363d; | |
| 211 | padding: 0.4rem 0.8rem; | |
| 212 | border-radius: 4px; | |
| 213 | cursor: pointer; | |
| 214 | font-size: 0.8rem; | |
| 215 | float: right; | |
| 216 | margin-top: -0.2rem; | |
| 217 | } | |
| 218 | .rescan-btn:hover { background: #30363d; } | |
| 219 | </style> | |
| 220 | </head> | |
| 221 | <body> | |
| 222 | <h1>hyper-deploy</h1> | |
| 223 | ||
| 224 | <div class="card"> | |
| 225 | <h2>Topic <button class="rescan-btn" onclick="rescan()">Rescan</button></h2> | |
| 226 | <div class="topic" onclick="copyTopic(this)" id="topic">loading...</div> | |
| 227 | <div class="cmd" id="pull-cmd">loading...</div> | |
| 228 | </div> | |
| 229 | ||
| 230 | <div class="card"> | |
| 231 | <h2>Release</h2> | |
| 232 | <div class="stat-row"><span class="stat-label">Directory</span><span class="stat-value" id="dir">-</span></div> | |
| 233 | <div class="stat-row"><span class="stat-label">Files</span><span class="stat-value" id="files">-</span></div> | |
| 234 | <div class="stat-row"><span class="stat-label">Total size</span><span class="stat-value" id="size">-</span></div> | |
| 235 | </div> | |
| 236 | ||
| 237 | <div class="card"> | |
| 238 | <h2>Transfer Stats</h2> | |
| 239 | <div class="stat-row"><span class="stat-label">Connections</span><span class="stat-value" id="connections">0</span></div> | |
| 240 | <div class="stat-row"><span class="stat-label">Files served</span><span class="stat-value" id="served">0</span></div> | |
| 241 | <div class="stat-row"><span class="stat-label">Data served</span><span class="stat-value" id="bytes">0 B</span></div> | |
| 242 | <div class="progress-bar"><div class="progress-fill" id="progress"></div></div> | |
| 243 | </div> | |
| 244 | ||
| 245 | <div class="card"> | |
| 246 | <h2>Peers</h2> | |
| 247 | <div class="peers" id="peers"><span class="empty">No peers connected</span></div> | |
| 248 | </div> | |
| 249 | ||
| 250 | <script> | |
| 251 | const $ = id => document.getElementById(id) | |
| 252 | const peers = new Map() | |
| 253 | let totalFiles = 0 | |
| 254 | ||
| 255 | function update (data) { | |
| 256 | if (data.topic) { | |
| 257 | $('topic').textContent = data.topic | |
| 258 | $('pull-cmd').textContent = 'hyper-deploy pull ' + data.topic + ' <dest>' | |
| 259 | } | |
| 260 | if (data.dir) $('dir').textContent = data.dir | |
| 261 | if (data.files) { $('files').textContent = data.files; totalFiles = data.files } | |
| 262 | if (data.totalSizeFmt) $('size').textContent = data.totalSizeFmt | |
| 263 | if (data.stats) { | |
| 264 | $('connections').textContent = data.stats.connections | |
| 265 | $('served').textContent = data.stats.filesServed | |
| 266 | $('bytes').textContent = data.stats.bytesServedFmt | |
| 267 | if (totalFiles > 0) { | |
| 268 | $('progress').style.width = Math.min(100, data.stats.filesServed / totalFiles * 100) + '%' | |
| 269 | } | |
| 270 | } | |
| 271 | } | |
| 272 | ||
| 273 | function addPeer (key, type) { | |
| 274 | peers.set(key, type) | |
| 275 | renderPeers() | |
| 276 | } | |
| 277 | ||
| 278 | function renderPeers () { | |
| 279 | if (peers.size === 0) { | |
| 280 | $('peers').innerHTML = '<span class="empty">No peers connected</span>' | |
| 281 | return | |
| 282 | } | |
| 283 | $('peers').innerHTML = Array.from(peers.entries()) | |
| 284 | .map(([k, t]) => '<span class="peer' + (t === 'done' ? ' done' : '') + '">' + k + '</span>') | |
| 285 | .join('') | |
| 286 | } | |
| 287 | ||
| 288 | function copyTopic (el) { | |
| 289 | navigator.clipboard.writeText(el.textContent).then(() => { | |
| 290 | const copied = el.querySelector('.copied') | |
| 291 | if (copied) { copied.style.display = 'inline'; setTimeout(() => copied.style.display = 'none', 1500) } | |
| 292 | }) | |
| 293 | } | |
| 294 | ||
| 295 | function rescan () { | |
| 296 | fetch('/api/rescan', { method: 'POST' }).then(r => r.json()).then(update) | |
| 297 | } | |
| 298 | ||
| 299 | // SSE for live updates | |
| 300 | const es = new EventSource('/events') | |
| 301 | es.onmessage = (e) => update(JSON.parse(e.data)) | |
| 302 | es.addEventListener('peer', (e) => { | |
| 303 | const d = JSON.parse(e.data) | |
| 304 | addPeer(d.key, d.type) | |
| 305 | }) | |
| 306 | es.addEventListener('progress', (e) => { | |
| 307 | const d = JSON.parse(e.data) | |
| 308 | $('served').textContent = d.filesServed | |
| 309 | if (totalFiles > 0) $('progress').style.width = Math.min(100, d.filesServed / totalFiles * 100) + '%' | |
| 310 | }) | |
| 311 | es.addEventListener('complete', (e) => { | |
| 312 | const d = JSON.parse(e.data) | |
| 313 | $('progress').style.width = '100%' | |
| 314 | }) | |
| 315 | </script> | |
| 316 | </body> | |
| 317 | </html>` | |
| 318 | } |
| … | … | @@ -1,14 +1,16 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-deploy", |
| 3 | "version": "0.2.0", | |
| 3 | "version": "0.3.0", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "hyper-deploy", |
| 9 | "version": "0.2.0", | |
| 9 | "version": "0.3.0", | |
| 10 | 10 | "dependencies": { |
| 11 | 11 | "compact-encoding": "^2.0.0", |
| 12 | "corestore": "^7.0.0", | |
| 13 | "hyper-overlay": "file:../hyper-overlay", | |
| 12 | 14 | "hypercore-crypto": "^3.0.0", |
| 13 | 15 | "hyperswarm": "^4.0.0", |
| 14 | 16 | "protomux": "^3.0.0", |
| … | … | @@ -17,13 +19,11 @@ |
| 17 | 19 | }, |
| 18 | 20 | "devDependencies": { |
| 19 | 21 | "@hyperswarm/secret-stream": "^6.0.0", |
| 20 | "brittle": "^3.0.0", | |
| 21 | "corestore": "^7.0.0" | |
| 22 | "brittle": "^3.0.0" | |
| 22 | 23 | } |
| 23 | 24 | }, |
| 24 | 25 | "../hyper-overlay": { |
| 25 | 26 | "version": "0.1.0", |
| 26 | "extraneous": true, | |
| 27 | 27 | "license": "AGPL-3.0", |
| 28 | 28 | "dependencies": { |
| 29 | 29 | "@hyperswarm/testnet": "^3.1.4", |
| … | … | @@ -319,7 +319,6 @@ |
| 319 | 319 | "version": "4.6.0", |
| 320 | 320 | "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.6.0.tgz", |
| 321 | 321 | "integrity": "sha512-2YkS7NuiJceSEbyEOdSNLE9tsGd+f4+f7C+Nik/MCk27SYdwIMPT/yRKvg++FZhQXgk0KWJKJyXX9RhVV0RGqA==", |
| 322 | "dev": true, | |
| 323 | 322 | "license": "Apache-2.0", |
| 324 | 323 | "dependencies": { |
| 325 | 324 | "bare-events": "^2.5.4", |
| … | … | @@ -531,7 +530,6 @@ |
| 531 | 530 | "version": "3.8.7", |
| 532 | 531 | "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz", |
| 533 | 532 | "integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==", |
| 534 | "devOptional": true, | |
| 535 | 533 | "license": "Apache-2.0", |
| 536 | 534 | "engines": { |
| 537 | 535 | "bare": ">=1.14.0" |
| … | … | @@ -541,7 +539,6 @@ |
| 541 | 539 | "version": "3.0.0", |
| 542 | 540 | "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", |
| 543 | 541 | "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", |
| 544 | "devOptional": true, | |
| 545 | 542 | "license": "Apache-2.0", |
| 546 | 543 | "dependencies": { |
| 547 | 544 | "bare-os": "^3.0.1" |
| … | … | @@ -728,7 +725,6 @@ |
| 728 | 725 | "version": "2.4.0", |
| 729 | 726 | "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", |
| 730 | 727 | "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", |
| 731 | "devOptional": true, | |
| 732 | 728 | "license": "Apache-2.0", |
| 733 | 729 | "peer": true, |
| 734 | 730 | "dependencies": { |
| … | … | @@ -823,7 +819,6 @@ |
| 823 | 819 | "version": "1.0.3", |
| 824 | 820 | "resolved": "https://registry.npmjs.org/big-sparse-array/-/big-sparse-array-1.0.3.tgz", |
| 825 | 821 | "integrity": "sha512-6RjV/3mSZORlMdpUaQ6rUSpG637cZm0//E54YYGtQg1c1O+AbZP8UTdJ/TchsDZcTVLmyWZcseBfp2HBeXUXOQ==", |
| 826 | "dev": true, | |
| 827 | 822 | "license": "MIT" |
| 828 | 823 | }, |
| 829 | 824 | "node_modules/bits-to-bytes": { |
| … | … | @@ -929,7 +924,6 @@ |
| 929 | 924 | "version": "7.9.2", |
| 930 | 925 | "resolved": "https://registry.npmjs.org/corestore/-/corestore-7.9.2.tgz", |
| 931 | 926 | "integrity": "sha512-dyIktVSVLu0skZ1UM4yODbNbZ46dBpTHypzwlwET1btVuS3YIf66Zz8Tx8h3Xa8nbLO2USGqrYtvPYzSRj2wCA==", |
| 932 | "dev": true, | |
| 933 | 927 | "license": "MIT", |
| 934 | 928 | "dependencies": { |
| 935 | 929 | "b4a": "^1.6.7", |
| … | … | @@ -946,7 +940,6 @@ |
| 946 | 940 | "version": "2.3.1", |
| 947 | 941 | "resolved": "https://registry.npmjs.org/device-file/-/device-file-2.3.1.tgz", |
| 948 | 942 | "integrity": "sha512-bmON44lwxJPle9N2OcH4tqM44pMGZKT8G6OkzXkz0urvqQ9LKkoQdTS6w0ztYfmLdUE27R4UUmx0+y2gEz4Jug==", |
| 949 | "dev": true, | |
| 950 | 943 | "license": "Apache-2.0", |
| 951 | 944 | "dependencies": { |
| 952 | 945 | "b4a": "^1.6.7", |
| … | … | @@ -1006,7 +999,6 @@ |
| 1006 | 999 | "version": "2.1.1", |
| 1007 | 1000 | "resolved": "https://registry.npmjs.org/fd-lock/-/fd-lock-2.1.1.tgz", |
| 1008 | 1001 | "integrity": "sha512-H3VkcWFl39Rk0xBokDcUyBcVs6VrYTUo/DhDWMzJOF99wXWDAvmvq/GlLTS2NTehsznZhp3fXVyrtB2ldDXhwg==", |
| 1009 | "dev": true, | |
| 1010 | 1002 | "license": "Apache-2.0", |
| 1011 | 1003 | "dependencies": { |
| 1012 | 1004 | "bare-fs": "^4.5.0", |
| … | … | @@ -1019,14 +1011,12 @@ |
| 1019 | 1011 | "version": "1.13.0", |
| 1020 | 1012 | "resolved": "https://registry.npmjs.org/flat-tree/-/flat-tree-1.13.0.tgz", |
| 1021 | 1013 | "integrity": "sha512-fT3HIuCPwHhFgJ20QYzDHgUG0zMmFg5cHvFiFo5h+QMSJ28TihsEVY0f8HGliuO+pOzmvjMx1odToeaEWkTnyQ==", |
| 1022 | "dev": true, | |
| 1023 | 1014 | "license": "MIT" |
| 1024 | 1015 | }, |
| 1025 | 1016 | "node_modules/fs-native-extensions": { |
| 1026 | 1017 | "version": "1.4.5", |
| 1027 | 1018 | "resolved": "https://registry.npmjs.org/fs-native-extensions/-/fs-native-extensions-1.4.5.tgz", |
| 1028 | 1019 | "integrity": "sha512-ekV0T//iDm4AvhOcuPaHpxub4DI7HvY5ucLJVDvi7T2J+NZkQ9S6MuvgP0yeQvoqNUaAGyLjVYb1905BF9bpmg==", |
| 1029 | "dev": true, | |
| 1030 | 1020 | "license": "Apache-2.0", |
| 1031 | 1021 | "dependencies": { |
| 1032 | 1022 | "require-addon": "^1.1.0", |
| … | … | @@ -1037,7 +1027,6 @@ |
| 1037 | 1027 | "version": "2.0.0", |
| 1038 | 1028 | "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-2.0.0.tgz", |
| 1039 | 1029 | "integrity": "sha512-KwuURPyqn2Mz8DdV29pJwQu0Y7tcsbkULr82eeOcY/ZllFK6I9Wm8dsRByIu7CKWlFi9BdW1b3mcXMp/kQBQsw==", |
| 1040 | "dev": true, | |
| 1041 | 1030 | "license": "MIT", |
| 1042 | 1031 | "dependencies": { |
| 1043 | 1032 | "is-property": "^1.0.0" |
| … | … | @@ -1047,7 +1036,6 @@ |
| 1047 | 1036 | "version": "1.0.1", |
| 1048 | 1037 | "resolved": "https://registry.npmjs.org/generate-string/-/generate-string-1.0.1.tgz", |
| 1049 | 1038 | "integrity": "sha512-IfTY0dKZM43ACyGvXkbG7De7WY7MxTS5VO6Juhe8oJKpCmrYYXoqp/cJMskkpi0k9H8wuXq0H+eI898/BCqvXg==", |
| 1050 | "dev": true, | |
| 1051 | 1039 | "license": "MIT" |
| 1052 | 1040 | }, |
| 1053 | 1041 | "node_modules/globbie": { |
| … | … | @@ -1063,11 +1051,14 @@ |
| 1063 | 1051 | "picomatch": "^4.0.2" |
| 1064 | 1052 | } |
| 1065 | 1053 | }, |
| 1054 | "node_modules/hyper-overlay": { | |
| 1055 | "resolved": "../hyper-overlay", | |
| 1056 | "link": true | |
| 1057 | }, | |
| 1066 | 1058 | "node_modules/hypercore": { |
| 1067 | 1059 | "version": "11.28.1", |
| 1068 | 1060 | "resolved": "https://registry.npmjs.org/hypercore/-/hypercore-11.28.1.tgz", |
| 1069 | 1061 | "integrity": "sha512-d8jqUilG7Gri8wT+sRdkFTYRPMHGyvBz/NCYHf5rX7+LoivRNRxQm5nWChT7caiUKbcsDak6h7IiEQ5wbD1wCg==", |
| 1070 | "dev": true, | |
| 1071 | 1062 | "license": "MIT", |
| 1072 | 1063 | "dependencies": { |
| 1073 | 1064 | "@hyperswarm/secret-stream": "^6.0.0", |
| … | … | @@ -1108,7 +1099,6 @@ |
| 1108 | 1099 | "version": "1.5.0", |
| 1109 | 1100 | "resolved": "https://registry.npmjs.org/hypercore-errors/-/hypercore-errors-1.5.0.tgz", |
| 1110 | 1101 | "integrity": "sha512-5KQ/SuDxsvet+7qWA35Ay6zdD9WyAHQoyWHGcPUTbmJBd300gvNIJoi3oma7kp4TTCSzii6qYumNZe/s0j/saQ==", |
| 1111 | "dev": true, | |
| 1112 | 1102 | "license": "Apache-2.0", |
| 1113 | 1103 | "dependencies": { |
| 1114 | 1104 | "hypercore-id-encoding": "^1.3.0" |
| … | … | @@ -1128,7 +1118,6 @@ |
| 1128 | 1118 | "version": "2.8.0", |
| 1129 | 1119 | "resolved": "https://registry.npmjs.org/hypercore-storage/-/hypercore-storage-2.8.0.tgz", |
| 1130 | 1120 | "integrity": "sha512-s35HllFMYvrkSHvkIIklVFmCI/bRVNO7H3pi4mKvWl0XLyhGGGM+NtJ7fpsh82oPRCmL9XtMRO+pT6LiMAO8MA==", |
| 1131 | "dev": true, | |
| 1132 | 1121 | "license": "Apache-2.0", |
| 1133 | 1122 | "dependencies": { |
| 1134 | 1123 | "b4a": "^1.6.7", |
| … | … | @@ -1179,7 +1168,6 @@ |
| 1179 | 1168 | "version": "1.20.1", |
| 1180 | 1169 | "resolved": "https://registry.npmjs.org/hyperschema/-/hyperschema-1.20.1.tgz", |
| 1181 | 1170 | "integrity": "sha512-7gnvaTNUs8FqdrU3NNE6nzhOneGyDlUlpROu9YFlHA61fE8ppKKf2gLZjZjsx/enNt+VFN4ITxy8ijypfyWBLw==", |
| 1182 | "dev": true, | |
| 1183 | 1171 | "license": "Apache-2.0", |
| 1184 | 1172 | "dependencies": { |
| 1185 | 1173 | "bare-fs": "^4.0.1", |
| … | … | @@ -1207,7 +1195,6 @@ |
| 1207 | 1195 | "version": "3.5.0", |
| 1208 | 1196 | "resolved": "https://registry.npmjs.org/index-encoder/-/index-encoder-3.5.0.tgz", |
| 1209 | 1197 | "integrity": "sha512-idZ1cxtZz2dRV6rUiaP9Xo99UjXbSzjcMacoQmxUMu/A7fEQcNPngvwDJYeWelQUS5XFlY71/or70lKn6XnwbQ==", |
| 1210 | "dev": true, | |
| 1211 | 1198 | "license": "Apache-2.0", |
| 1212 | 1199 | "dependencies": { |
| 1213 | 1200 | "b4a": "^1.6.4" |
| … | … | @@ -1217,7 +1204,6 @@ |
| 1217 | 1204 | "version": "1.0.2", |
| 1218 | 1205 | "resolved": "https://registry.npmjs.org/is-options/-/is-options-1.0.2.tgz", |
| 1219 | 1206 | "integrity": "sha512-u+Ai74c8Q74aS8BuHwPdI1jptGOT1FQXgCq8/zv0xRuE+wRgSMEJLj8lVO8Zp9BeGb29BXY6AsNPinfqjkr7Fg==", |
| 1220 | "dev": true, | |
| 1221 | 1207 | "license": "MIT", |
| 1222 | 1208 | "dependencies": { |
| 1223 | 1209 | "b4a": "^1.1.1" |
| … | … | @@ -1227,7 +1213,6 @@ |
| 1227 | 1213 | "version": "1.0.2", |
| 1228 | 1214 | "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", |
| 1229 | 1215 | "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", |
| 1230 | "dev": true, | |
| 1231 | 1216 | "license": "MIT" |
| 1232 | 1217 | }, |
| 1233 | 1218 | "node_modules/kademlia-routing-table": { |
| … | … | @@ -1316,7 +1301,6 @@ |
| 1316 | 1301 | "version": "2.4.8", |
| 1317 | 1302 | "resolved": "https://registry.npmjs.org/quickbit-native/-/quickbit-native-2.4.8.tgz", |
| 1318 | 1303 | "integrity": "sha512-FcCcqI+nIAWGknqhtrYT5TSD7t/N+Xd8ctM+2PrIIBuwOi5hx0SxAvuPtzLIEMfT/2h9+fhBakUe2uALOHX6yw==", |
| 1319 | "dev": true, | |
| 1320 | 1304 | "license": "Apache-2.0", |
| 1321 | 1305 | "optional": true, |
| 1322 | 1306 | "dependencies": { |
| … | … | @@ -1327,7 +1311,6 @@ |
| 1327 | 1311 | "version": "2.2.0", |
| 1328 | 1312 | "resolved": "https://registry.npmjs.org/quickbit-universal/-/quickbit-universal-2.2.0.tgz", |
| 1329 | 1313 | "integrity": "sha512-w02i1R8n7+6pEKTud8DfF8zbFY9o7RtPlUc3jWbtCkDKvhbx/AvV7oNnz4/TcmsPGpSJS+fq5Ud6RH6+YPvSGg==", |
| 1330 | "dev": true, | |
| 1331 | 1314 | "license": "ISC", |
| 1332 | 1315 | "dependencies": { |
| 1333 | 1316 | "b4a": "^1.6.0", |
| … | … | @@ -1341,7 +1324,6 @@ |
| 1341 | 1324 | "version": "1.0.0", |
| 1342 | 1325 | "resolved": "https://registry.npmjs.org/random-array-iterator/-/random-array-iterator-1.0.0.tgz", |
| 1343 | 1326 | "integrity": "sha512-u7xCM93XqKEvPTP6xZp2ehttcAemKnh73oKNf1FvzuVCfpt6dILDt1Kxl1LeBjm2iNIeR49VGFhy4Iz3yOun+Q==", |
| 1344 | "dev": true, | |
| 1345 | 1327 | "license": "MIT" |
| 1346 | 1328 | }, |
| 1347 | 1329 | "node_modules/ready-resource": { |
| … | … | @@ -1366,7 +1348,6 @@ |
| 1366 | 1348 | "version": "1.0.0", |
| 1367 | 1349 | "resolved": "https://registry.npmjs.org/refcounter/-/refcounter-1.0.0.tgz", |
| 1368 | 1350 | "integrity": "sha512-1WosVzUy0kPUaPMEtlNDwm99UsteALIhXXR8rerELoa63WkYIXAl0hxgwPFrIYBRWZPGUyekQ04FRtPJ7dHk9w==", |
| 1369 | "dev": true, | |
| 1370 | 1351 | "license": "Apache-2.0" |
| 1371 | 1352 | }, |
| 1372 | 1353 | "node_modules/require-addon": { |
| … | … | @@ -1385,21 +1366,18 @@ |
| 1385 | 1366 | "version": "1.1.0", |
| 1386 | 1367 | "resolved": "https://registry.npmjs.org/resolve-reject-promise/-/resolve-reject-promise-1.1.0.tgz", |
| 1387 | 1368 | "integrity": "sha512-LWsTOA91AqzBTjSGgX79Tc130pwcBK6xjpJEO+qRT5IKZ6bGnHKcc8QL3upUBcWuU8OTIDzKK2VNSwmmlqvAVg==", |
| 1388 | "dev": true, | |
| 1389 | 1369 | "license": "MIT" |
| 1390 | 1370 | }, |
| 1391 | 1371 | "node_modules/resource-on-exit": { |
| 1392 | 1372 | "version": "1.0.0", |
| 1393 | 1373 | "resolved": "https://registry.npmjs.org/resource-on-exit/-/resource-on-exit-1.0.0.tgz", |
| 1394 | 1374 | "integrity": "sha512-ViJwJAknCkLRJRPR+9SISQQ7R5eRgtdIHLJsM2hHx1MweAJbJxJ5XnMjjq0Lc7ZGv44ufzAqds1nKxiVkdy4ag==", |
| 1395 | "dev": true, | |
| 1396 | 1375 | "license": "Apache-2.0" |
| 1397 | 1376 | }, |
| 1398 | 1377 | "node_modules/rocksdb-native": { |
| 1399 | 1378 | "version": "3.15.0", |
| 1400 | 1379 | "resolved": "https://registry.npmjs.org/rocksdb-native/-/rocksdb-native-3.15.0.tgz", |
| 1401 | 1380 | "integrity": "sha512-czYx0hOzKAy+HYmJROZm7thvJ1tHFWHNkGI3nomumXWzrJghJdrWo4MIhehGX4OTff0lnOCoB2zu6ekzIKY0rQ==", |
| 1402 | "dev": true, | |
| 1403 | 1381 | "license": "Apache-2.0", |
| 1404 | 1382 | "dependencies": { |
| 1405 | 1383 | "compact-encoding": "^2.15.0", |
| … | … | @@ -1431,7 +1409,6 @@ |
| 1431 | 1409 | "version": "1.2.4", |
| 1432 | 1410 | "resolved": "https://registry.npmjs.org/scope-lock/-/scope-lock-1.2.4.tgz", |
| 1433 | 1411 | "integrity": "sha512-BpSd8VCuCxW9ZitcdIC/vjs3gMaP9bRBL5nkHcyfX2VrS52n13/rHuBA2xJ/S/4DPuRdAO/Bk8pWd8eD/gHCIA==", |
| 1434 | "dev": true, | |
| 1435 | 1412 | "license": "Apache-2.0" |
| 1436 | 1413 | }, |
| 1437 | 1414 | "node_modules/shuffled-priority-queue": { |
| … | … | @@ -1453,7 +1430,6 @@ |
| 1453 | 1430 | "version": "1.3.9", |
| 1454 | 1431 | "resolved": "https://registry.npmjs.org/simdle-native/-/simdle-native-1.3.9.tgz", |
| 1455 | 1432 | "integrity": "sha512-Isc8sP4OiiIU0mpslD4GHEnR0VQWvR/54WN7YtwEDkNdTJVWtpmvsSvsgRlw5BNGxdYXlVRegdnrSu10H/PhvA==", |
| 1456 | "dev": true, | |
| 1457 | 1433 | "license": "Apache-2.0", |
| 1458 | 1434 | "optional": true, |
| 1459 | 1435 | "dependencies": { |
| … | … | @@ -1465,7 +1441,6 @@ |
| 1465 | 1441 | "version": "1.1.2", |
| 1466 | 1442 | "resolved": "https://registry.npmjs.org/simdle-universal/-/simdle-universal-1.1.2.tgz", |
| 1467 | 1443 | "integrity": "sha512-3n3w1bs+uwgHKQjt6arez83EywNlhZzYvNOhvAASTl/8KqNIcqr6aHyGt3JRlfuUC7iB0tomJRPlJ2cRGIpBzA==", |
| 1468 | "dev": true, | |
| 1469 | 1444 | "license": "ISC", |
| 1470 | 1445 | "dependencies": { |
| 1471 | 1446 | "b4a": "^1.6.0" |
| … | … | @@ -1,19 +1,22 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-deploy", |
| 3 | "version": "0.2.0", | |
| 3 | "version": "0.3.0", | |
| 4 | 4 | "description": "P2P release distribution — serve build artifacts to peers over Hyperswarm", |
| 5 | "type": "module", | |
| 5 | 6 | "main": "index.js", |
| 6 | 7 | "imports": { |
| 7 | "fs": { "bare": "bare-fs", "default": "fs" }, | |
| 8 | "path": { "bare": "bare-path", "default": "path" }, | |
| 9 | "os": { "bare": "bare-os", "default": "os" } | |
| 8 | "#fs": { "bare": "bare-fs", "default": "fs" }, | |
| 9 | "#path": { "bare": "bare-path", "default": "path" }, | |
| 10 | "#os": { "bare": "bare-os", "default": "os" } | |
| 10 | 11 | }, |
| 11 | 12 | "scripts": { |
| 12 | 13 | "test": "brittle test/*.test.js" |
| 13 | 14 | }, |
| 14 | 15 | "dependencies": { |
| 16 | "corestore": "^7.0.0", | |
| 15 | 17 | "compact-encoding": "^2.0.0", |
| 16 | 18 | "hypercore-crypto": "^3.0.0", |
| 19 | "hyper-overlay": "file:../hyper-overlay", | |
| 17 | 20 | "hyperswarm": "^4.0.0", |
| 18 | 21 | "protomux": "^3.0.0", |
| 19 | 22 | "ready-resource": "^1.0.0", |
| … | … | @@ -21,7 +24,6 @@ |
| 21 | 24 | }, |
| 22 | 25 | "devDependencies": { |
| 23 | 26 | "@hyperswarm/secret-stream": "^6.0.0", |
| 24 | "brittle": "^3.0.0", | |
| 25 | "corestore": "^7.0.0" | |
| 27 | "brittle": "^3.0.0" | |
| 26 | 28 | } |
| 27 | 29 | } |