peerpigeon
Version:
WebRTC-based peer-to-peer mesh networking library with intelligent routing and signaling server
936 lines (791 loc) • 31.3 kB
HTML
<html>
<head>
<title>PeerPigeon Stream File Transfer Demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 16px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
h1 {
color: #667eea;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 12px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
}
.status-bar {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.connection-panel {
background: rgba(255, 255, 255, 0.85);
border-radius: 12px;
border: 2px solid #dee2e6;
padding: 25px;
margin-bottom: 30px;
}
.connection-panel h2 {
color: #495057;
margin-bottom: 16px;
font-size: 20px;
}
.connection-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.connection-panel label {
font-weight: 600;
margin-bottom: 6px;
display: block;
color: #495057;
}
.connection-panel input {
width: 100%;
padding: 12px;
border: 2px solid #dee2e6;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.connection-panel input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.25);
}
.connection-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.connection-buttons button {
flex: 1;
min-width: 160px;
}
.status-tag {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 999px;
font-weight: 600;
font-size: 13px;
margin-top: 10px;
}
.status-connected {
background: rgba(40, 167, 69, 0.15);
color: #1e7e34;
border: 1px solid rgba(40, 167, 69, 0.35);
}
.status-connecting {
background: rgba(255, 193, 7, 0.15);
color: #856404;
border: 1px solid rgba(255, 193, 7, 0.35);
}
.status-disconnected {
background: rgba(220, 53, 69, 0.15);
color: #841f2d;
border: 1px solid rgba(220, 53, 69, 0.35);
}
.status-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 12px;
text-align: center;
}
.status-label {
font-size: 14px;
opacity: 0.9;
margin-bottom: 8px;
}
.status-value {
font-size: 28px;
font-weight: bold;
}
.main-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
margin-bottom: 30px;
}
.panel {
background: #f8f9fa;
border-radius: 12px;
padding: 25px;
border: 2px solid #dee2e6;
}
.panel h2 {
color: #495057;
margin-bottom: 20px;
font-size: 20px;
}
.file-upload {
border: 3px dashed #667eea;
border-radius: 12px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: white;
}
.file-upload:hover {
background: #f0f4ff;
border-color: #5568d3;
}
.file-upload.drag-over {
background: #e0e7ff;
border-color: #4f46e5;
transform: scale(1.02);
}
.file-input {
display: none;
}
.file-info {
background: white;
border-radius: 8px;
padding: 15px;
margin: 15px 0;
border-left: 4px solid #667eea;
}
.file-name {
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.file-size {
color: #666;
font-size: 14px;
}
button {
background: #667eea;
color: white;
border: none;
padding: 14px 28px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: all 0.3s;
width: 100%;
margin: 10px 0;
}
button:hover:not(:disabled) {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
button:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
select {
width: 100%;
padding: 12px;
border: 2px solid #dee2e6;
border-radius: 8px;
font-size: 14px;
margin: 10px 0;
background: white;
cursor: pointer;
}
.progress-container {
background: #e9ecef;
border-radius: 8px;
height: 30px;
margin: 15px 0;
overflow: hidden;
position: relative;
}
.progress-bar {
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
height: 100%;
transition: width 0.3s;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 14px;
}
.received-files {
max-height: 400px;
overflow-y: auto;
}
.received-file {
background: white;
border-radius: 8px;
padding: 15px;
margin: 10px 0;
border-left: 4px solid #28a745;
display: flex;
justify-content: space-between;
align-items: center;
}
.download-btn {
background: #28a745;
padding: 8px 16px;
font-size: 14px;
width: auto;
margin: 0;
}
.download-btn:hover {
background: #218838;
}
.log {
background: #1e1e1e;
color: #d4d4d4;
padding: 20px;
border-radius: 12px;
max-height: 300px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
.log-entry {
padding: 4px 0;
border-bottom: 1px solid #333;
}
.log-entry:last-child {
border-bottom: none;
}
.log-entry.success { color: #4ec9b0; }
.log-entry.error { color: #f48771; }
.log-entry.info { color: #9cdcfe; }
.log-entry.stream { color: #dcdcaa; }
.log-entry.warning { color: #f2cc60; }
.peers-list {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin: 15px 0;
}
.peer-badge {
background: #667eea;
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
}
.icon {
font-size: 48px;
margin-bottom: 15px;
}
</style>
</head>
<body>
<div class="container">
<h1>
📡 PeerPigeon Stream File Transfer
</h1>
<p class="subtitle">Transfer files efficiently using ReadableStream/WritableStream API</p>
<div class="status-bar">
<div class="status-card">
<div class="status-label">Connected Peers</div>
<div class="status-value" id="peer-count">0</div>
</div>
<div class="status-card">
<div class="status-label">Files Sent</div>
<div class="status-value" id="files-sent">0</div>
</div>
<div class="status-card">
<div class="status-label">Files Received</div>
<div class="status-value" id="files-received">0</div>
</div>
<div class="status-card">
<div class="status-label">Data Transferred</div>
<div class="status-value" id="data-transferred">0 MB</div>
</div>
</div>
<div class="connection-panel">
<h2>⚙️ Connection Settings</h2>
<div class="connection-grid">
<div>
<label for="signaling-url">Signaling Server URL</label>
<input type="text" id="signaling-url" value="ws://localhost:3000" placeholder="ws://localhost:3000">
</div>
<div>
<label for="network-name">Network Namespace</label>
<input type="text" id="network-name" value="file-transfer" placeholder="Enter namespace">
</div>
</div>
<div class="connection-buttons">
<button id="connect-btn">Connect</button>
<button id="disconnect-btn" disabled>Disconnect</button>
</div>
<div id="connection-status" class="status-tag status-disconnected">Disconnected</div>
</div>
<div class="main-content">
<div class="panel">
<h2>📤 Send Files</h2>
<div class="file-upload" id="drop-zone">
<div class="icon">📁</div>
<p style="font-size: 18px; margin-bottom: 10px;">
Drag & Drop files here
</p>
<p style="color: #666;">or click to browse</p>
<input type="file" id="file-input" class="file-input" multiple>
</div>
<div id="selected-files"></div>
<label style="display: block; margin-top: 20px; font-weight: 600;">Send to:</label>
<select id="target-peer">
<option value="">Select a peer...</option>
</select>
<div id="send-progress" style="display: none;">
<div class="progress-container">
<div class="progress-bar" id="send-progress-bar">0%</div>
</div>
</div>
<button id="send-btn" disabled>Send Files</button>
</div>
<div class="panel">
<h2>📥 Received Files</h2>
<div class="received-files" id="received-files">
<p style="color: #666; text-align: center; padding: 40px;">
No files received yet
</p>
</div>
</div>
</div>
<div class="panel">
<h2>📜 Activity Log</h2>
<div class="log" id="log"></div>
</div>
<div class="panel" style="margin-top: 20px;">
<h2>🔗 Connected Peers</h2>
<div class="peers-list" id="peers-list">
<span style="color: #666;">No peers connected</span>
</div>
</div>
</div>
<script src="../dist/peerpigeon-browser.js"></script>
<script>
const PeerPigeonMeshConstructor = window.PeerPigeonMesh || (window.PeerPigeon && window.PeerPigeon.PeerPigeonMesh);
let mesh = null;
let filesSent = 0;
let filesReceived = 0;
let dataTransferred = 0;
let selectedFiles = [];
let receivedFileBlobs = new Map(); // streamId -> blob
let isMeshConnected = false;
async function init() {
setupFileUpload();
setupConnectionControls();
updateUI();
if (!PeerPigeonMeshConstructor) {
log('❌ PeerPigeon browser bundle not found. Build the project with `npm run build`.', 'error');
setConnectionStatus('disconnected', 'Bundle unavailable');
document.getElementById('connect-btn').disabled = true;
return;
}
log('✅ Ready! Configure your namespace and click Connect when you are set.', 'info');
}
function setupConnectionControls() {
const connectBtn = document.getElementById('connect-btn');
const disconnectBtn = document.getElementById('disconnect-btn');
const networkInput = document.getElementById('network-name');
connectBtn.addEventListener('click', () => {
connectMesh().catch(error => {
log(`❌ Connection error: ${error.message}`, 'error');
setConnectionStatus('disconnected', 'Connection failed');
});
});
disconnectBtn.addEventListener('click', () => {
disconnectMesh().catch(error => {
log(`⚠️ Error during disconnect: ${error.message}`, 'error');
});
});
networkInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
connectBtn.click();
}
});
}
async function connectMesh() {
const signalingUrl = document.getElementById('signaling-url').value.trim() || 'ws://localhost:3000';
const networkName = document.getElementById('network-name').value.trim() || 'file-transfer';
const connectBtn = document.getElementById('connect-btn');
const disconnectBtn = document.getElementById('disconnect-btn');
if (!PeerPigeonMeshConstructor) {
throw new Error('PeerPigeon bundle unavailable');
}
connectBtn.disabled = true;
disconnectBtn.disabled = true;
setConnectionStatus('connecting', `Connecting to ${networkName}...`);
if (mesh) {
try {
await mesh.disconnect();
} catch (error) {
log(`⚠️ Error disconnecting previous mesh: ${error.message}`, 'error');
}
}
mesh = new PeerPigeonMeshConstructor({
signalingServer: signalingUrl,
networkName,
autoConnect: true,
maxPeers: 8,
debug: true
});
attachMeshEventListeners(mesh);
try {
if (typeof mesh.init === 'function') {
setConnectionStatus('connecting', 'Initializing mesh...');
await mesh.init();
}
await mesh.connect(signalingUrl);
isMeshConnected = true;
setConnectionStatus('connected', `Connected to ${networkName}`);
log(`🤝 Connected to namespace "${networkName}"`, 'success');
updateUI();
disconnectBtn.disabled = false;
} catch (error) {
isMeshConnected = false;
setConnectionStatus('disconnected', 'Connection failed');
mesh = null;
throw error;
} finally {
connectBtn.disabled = false;
updateSendButton();
}
}
async function disconnectMesh() {
const disconnectBtn = document.getElementById('disconnect-btn');
const connectBtn = document.getElementById('connect-btn');
disconnectBtn.disabled = true;
connectBtn.disabled = true;
if (!mesh) {
setConnectionStatus('disconnected', 'No active connection');
connectBtn.disabled = false;
return;
}
try {
await mesh.disconnect();
log('👋 Disconnected from mesh', 'info');
} finally {
mesh = null;
isMeshConnected = false;
updateUI();
setConnectionStatus('disconnected', 'Disconnected');
connectBtn.disabled = false;
}
}
function attachMeshEventListeners(meshInstance) {
const safeHandler = (callback) => (event) => {
if (meshInstance !== mesh) return;
callback(event);
};
meshInstance.addEventListener('connected', safeHandler(() => {
log('✅ Connected to signaling server', 'success');
}));
meshInstance.addEventListener('peerConnected', safeHandler((event) => {
log(`🤝 Peer connected: ${event.peerId.substring(0, 8)}...`, 'success');
updateUI();
}));
meshInstance.addEventListener('peerDisconnected', safeHandler((event) => {
log(`👋 Peer disconnected: ${event.peerId.substring(0, 8)}...`, 'info');
updateUI();
}));
meshInstance.addEventListener('disconnected', safeHandler(() => {
if (!isMeshConnected) return;
isMeshConnected = false;
document.getElementById('disconnect-btn').disabled = true;
setConnectionStatus('disconnected', 'Disconnected');
updateUI();
}));
meshInstance.addEventListener('streamReceived', safeHandler(async (event) => {
const { peerId, streamId, stream, metadata } = event;
log(`📥 Receiving file "${metadata.filename}" from ${peerId.substring(0, 8)}... (${formatBytes(metadata.totalSize)})`, 'stream');
try {
const chunks = [];
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
log(`📥 Received chunk (${value.length} bytes)`, 'info');
}
const blob = new Blob(chunks, { type: metadata.mimeType });
receivedFileBlobs.set(streamId, blob);
addReceivedFile({
streamId,
filename: metadata.filename,
size: metadata.totalSize,
mimeType: metadata.mimeType,
from: peerId,
blob
});
filesReceived++;
dataTransferred += metadata.totalSize;
updateStats();
log(`✅ File "${metadata.filename}" received successfully`, 'success');
} catch (error) {
log(`❌ Error receiving file: ${error.message}`, 'error');
}
}));
meshInstance.addEventListener('streamCompleted', safeHandler((event) => {
log(`✅ Stream ${event.streamId} completed`, 'success');
}));
meshInstance.addEventListener('streamAborted', safeHandler((event) => {
log(`❌ Stream ${event.streamId} aborted: ${event.reason}`, 'error');
}));
}
function setupFileUpload() {
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
const sendBtn = document.getElementById('send-btn');
const peerSelect = document.getElementById('target-peer');
dropZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (event) => {
handleFiles(event.target.files);
});
dropZone.addEventListener('dragover', (event) => {
event.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (event) => {
event.preventDefault();
dropZone.classList.remove('drag-over');
handleFiles(event.dataTransfer.files);
});
peerSelect.addEventListener('change', updateSendButton);
sendBtn.addEventListener('click', sendFiles);
}
function handleFiles(files) {
selectedFiles = Array.from(files);
displaySelectedFiles();
updateSendButton();
}
function displaySelectedFiles() {
const container = document.getElementById('selected-files');
if (selectedFiles.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = selectedFiles.map((file) => `
<div class="file-info">
<div class="file-name">📄 ${file.name}</div>
<div class="file-size">${formatBytes(file.size)} • ${file.type || 'unknown type'}</div>
</div>
`).join('');
}
function updateSendButton() {
const sendBtn = document.getElementById('send-btn');
const targetPeer = document.getElementById('target-peer').value;
sendBtn.disabled = !isMeshConnected || selectedFiles.length === 0 || !targetPeer;
}
async function sendFiles() {
const targetPeer = document.getElementById('target-peer').value;
if (!isMeshConnected || !mesh) {
log('⚠️ Connect to a namespace before sending files.', 'warning');
return;
}
if (!targetPeer || selectedFiles.length === 0) return;
const sendBtn = document.getElementById('send-btn');
const progressContainer = document.getElementById('send-progress');
const progressBar = document.getElementById('send-progress-bar');
sendBtn.disabled = true;
progressContainer.style.display = 'block';
try {
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
const progress = Math.round(((i + 1) / selectedFiles.length) * 100);
progressBar.style.width = `${progress}%`;
progressBar.textContent = `${progress}% (${i + 1}/${selectedFiles.length})`;
log(`📤 Sending file "${file.name}" to ${targetPeer.substring(0, 8)}...`, 'stream');
await mesh.sendFile(targetPeer, file);
filesSent++;
dataTransferred += file.size;
updateStats();
log(`✅ File "${file.name}" sent successfully`, 'success');
}
selectedFiles = [];
document.getElementById('file-input').value = '';
displaySelectedFiles();
progressContainer.style.display = 'none';
updateSendButton();
} catch (error) {
log(`❌ Error sending files: ${error.message}`, 'error');
} finally {
sendBtn.disabled = false;
}
}
function addReceivedFile(fileData) {
const container = document.getElementById('received-files');
if (container.querySelector('p')) {
container.innerHTML = '';
}
const fileEl = document.createElement('div');
fileEl.className = 'received-file';
fileEl.innerHTML = `
<div>
<div class="file-name">📄 ${fileData.filename}</div>
<div class="file-size">
${formatBytes(fileData.size)} • From ${fileData.from.substring(0, 8)}...
</div>
</div>
<button class="download-btn" onclick="downloadFile('${fileData.streamId}')">
⬇️ Download
</button>
`;
container.insertBefore(fileEl, container.firstChild);
}
function downloadFile(streamId) {
const blob = receivedFileBlobs.get(streamId);
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `received-file-${Date.now()}`;
a.click();
URL.revokeObjectURL(url);
log('💾 File downloaded', 'success');
}
function updateUI() {
updatePeersList();
updatePeerDropdown();
updateStats();
updateSendButton();
}
function getConnectedPeers() {
if (!mesh || !isMeshConnected) {
return [];
}
const normalizePeer = (peer) => {
if (!peer) return null;
if (typeof peer === 'string') return { peerId: peer };
if (typeof peer.peerId === 'string') return { peerId: peer.peerId };
return null;
};
const collectPeers = (source) => {
if (!source) return [];
if (typeof source === 'string') {
const single = normalizePeer(source);
return single ? [single] : [];
}
const normalized = Array.from(source)
.map(normalizePeer)
.filter(Boolean);
return normalized;
};
try {
if (typeof mesh.getConnectedPeers === 'function') {
const peers = mesh.getConnectedPeers();
if (peers && typeof peers[Symbol.iterator] === 'function') {
return collectPeers(peers);
}
}
if (typeof mesh.getConnectedPeerIds === 'function') {
return collectPeers(mesh.getConnectedPeerIds());
}
if (mesh.connectionManager) {
if (typeof mesh.connectionManager.getConnectedPeers === 'function') {
return collectPeers(mesh.connectionManager.getConnectedPeers());
}
if (typeof mesh.connectionManager.getPeers === 'function') {
const peerSummaries = mesh.connectionManager.getPeers()
.filter(peer => peer.status === 'connected')
.map(peer => peer.peerId);
return collectPeers(peerSummaries);
}
}
if (mesh.peers) {
if (typeof mesh.peers.values === 'function') {
return collectPeers(mesh.peers.values());
}
return collectPeers(Object.values(mesh.peers));
}
} catch (error) {
log(`⚠️ Could not fetch peers: ${error.message}`, 'warning');
}
return [];
}
function updatePeersList() {
const peers = getConnectedPeers();
const container = document.getElementById('peers-list');
if (peers.length === 0) {
container.innerHTML = '<span style="color: #666;">No peers connected</span>';
} else {
container.innerHTML = peers.map(peer =>
`<div class="peer-badge">${peer.peerId.substring(0, 12)}...</div>`
).join('');
}
document.getElementById('peer-count').textContent = peers.length;
}
function updatePeerDropdown() {
const peers = getConnectedPeers();
const select = document.getElementById('target-peer');
const currentValue = select.value;
select.innerHTML = '<option value="">Select a peer...</option>';
peers.forEach(peer => {
const option = document.createElement('option');
option.value = peer.peerId;
option.textContent = peer.peerId.substring(0, 20) + '...';
select.appendChild(option);
});
if (peers.some(peer => peer.peerId === currentValue)) {
select.value = currentValue;
}
}
function updateStats() {
document.getElementById('files-sent').textContent = filesSent;
document.getElementById('files-received').textContent = filesReceived;
document.getElementById('data-transferred').textContent =
(dataTransferred / (1024 * 1024)).toFixed(2) + ' MB';
}
function setConnectionStatus(state, message) {
const statusEl = document.getElementById('connection-status');
statusEl.textContent = message;
statusEl.classList.remove('status-connected', 'status-connecting', 'status-disconnected');
statusEl.classList.add(`status-${state}`);
}
function log(message, type = 'info') {
const logEl = document.getElementById('log');
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
logEl.appendChild(entry);
logEl.scrollTop = logEl.scrollHeight;
}
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
window.downloadFile = downloadFile;
init().catch(error => {
log(`❌ Initialization error: ${error.message}`, 'error');
});
</script>
</body>
</html>