hotdrop 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/cli.js +111 -0
- package/local_install.sh +37 -0
- package/package.json +46 -0
- package/public/icon-192.svg +14 -0
- package/public/icon-512.svg +10 -0
- package/public/index.html +1204 -0
- package/public/manifest.json +25 -0
- package/public/sw.js +25 -0
- package/signaling/package.json +16 -0
- package/signaling/server.js +142 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "HotDrop",
|
|
3
|
+
"short_name": "HotDrop",
|
|
4
|
+
"description": "P2P file sharing between devices — no cloud, no cables",
|
|
5
|
+
"start_url": "/",
|
|
6
|
+
"display": "standalone",
|
|
7
|
+
"background_color": "#0a0a0a",
|
|
8
|
+
"theme_color": "#0a0a0a",
|
|
9
|
+
"orientation": "any",
|
|
10
|
+
"icons": [
|
|
11
|
+
{
|
|
12
|
+
"src": "/icon-192.svg",
|
|
13
|
+
"sizes": "192x192",
|
|
14
|
+
"type": "image/svg+xml",
|
|
15
|
+
"purpose": "any maskable"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"src": "/icon-512.svg",
|
|
19
|
+
"sizes": "512x512",
|
|
20
|
+
"type": "image/svg+xml",
|
|
21
|
+
"purpose": "any maskable"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"categories": ["utilities", "productivity"]
|
|
25
|
+
}
|
package/public/sw.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const CACHE = 'hotdrop-v0.0.3';
|
|
2
|
+
const STATIC = ['/', '/index.html', '/manifest.json', '/icon-192.svg', '/icon-512.svg'];
|
|
3
|
+
|
|
4
|
+
self.addEventListener('install', e => {
|
|
5
|
+
e.waitUntil(caches.open(CACHE).then(c => c.addAll(STATIC)).then(() => self.skipWaiting()));
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
self.addEventListener('activate', e => {
|
|
9
|
+
e.waitUntil(
|
|
10
|
+
caches.keys()
|
|
11
|
+
.then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
|
|
12
|
+
.then(() => self.clients.claim())
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
self.addEventListener('fetch', e => {
|
|
17
|
+
// Never cache API or WebSocket
|
|
18
|
+
if (e.request.url.includes('/api/') || e.request.url.startsWith('ws')) return;
|
|
19
|
+
e.respondWith(
|
|
20
|
+
caches.match(e.request).then(cached => cached || fetch(e.request).then(res => {
|
|
21
|
+
if (res.ok) caches.open(CACHE).then(c => c.put(e.request, res.clone()));
|
|
22
|
+
return res;
|
|
23
|
+
}))
|
|
24
|
+
);
|
|
25
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hotdrop",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "P2P file sharing via WebRTC — signaling server + PWA frontend",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "node server.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=16"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"express": "^5.2.1",
|
|
14
|
+
"ws": "^8.18.0"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const { WebSocketServer } = require('ws');
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
function getLocalIP() {
|
|
8
|
+
for (const ifaces of Object.values(os.networkInterfaces())) {
|
|
9
|
+
for (const iface of ifaces) {
|
|
10
|
+
if (iface.family === 'IPv4' && !iface.internal) return iface.address;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const PORT = process.env.PORT || 5821;
|
|
17
|
+
|
|
18
|
+
const app = express();
|
|
19
|
+
|
|
20
|
+
// Serve the PWA frontend from ../public
|
|
21
|
+
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
22
|
+
|
|
23
|
+
// Health check
|
|
24
|
+
app.get('/status', (req, res) => {
|
|
25
|
+
res.json({ status: 'hotdrop online', rooms: rooms.size });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Local IP — used by the client to generate a scannable QR when running on localhost
|
|
29
|
+
app.get('/api/local-ip', (req, res) => {
|
|
30
|
+
res.json({ ip: getLocalIP() });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Fallback to index.html for any unmatched route (PWA deep links)
|
|
34
|
+
app.get('*splat', (req, res) => {
|
|
35
|
+
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const httpServer = http.createServer(app);
|
|
39
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
40
|
+
|
|
41
|
+
// rooms: Map<roomCode, { peers: Set<ws>, timer: ReturnType<typeof setTimeout> | null }>
|
|
42
|
+
const rooms = new Map();
|
|
43
|
+
const ROOM_TTL = 5 * 60 * 1000; // 5-minute grace period after last peer leaves
|
|
44
|
+
|
|
45
|
+
function generateCode() {
|
|
46
|
+
return Math.random().toString(36).slice(2, 8).toUpperCase();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function send(ws, data) {
|
|
50
|
+
if (ws.readyState === 1) ws.send(JSON.stringify(data));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function broadcast(roomCode, data, exclude = null) {
|
|
54
|
+
const room = rooms.get(roomCode);
|
|
55
|
+
if (!room) return;
|
|
56
|
+
for (const peer of room.peers) {
|
|
57
|
+
if (peer !== exclude) send(peer, data);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
wss.on('connection', ws => {
|
|
62
|
+
ws.room = null;
|
|
63
|
+
ws.peerId = Math.random().toString(36).slice(2, 10);
|
|
64
|
+
|
|
65
|
+
ws.on('message', raw => {
|
|
66
|
+
let msg;
|
|
67
|
+
try { msg = JSON.parse(raw); } catch { return; }
|
|
68
|
+
|
|
69
|
+
switch (msg.type) {
|
|
70
|
+
|
|
71
|
+
case 'create': {
|
|
72
|
+
let code = generateCode();
|
|
73
|
+
while (rooms.has(code)) code = generateCode();
|
|
74
|
+
rooms.set(code, { peers: new Set([ws]), timer: null });
|
|
75
|
+
ws.room = code;
|
|
76
|
+
send(ws, { type: 'created', code, peerId: ws.peerId });
|
|
77
|
+
console.log(`Room created: ${code}`);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
case 'join': {
|
|
82
|
+
const code = (msg.code || '').toUpperCase().trim();
|
|
83
|
+
if (!rooms.has(code)) {
|
|
84
|
+
send(ws, { type: 'error', message: 'Room not found' });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const room = rooms.get(code);
|
|
88
|
+
// Cancel expiry timer if the room was in grace period
|
|
89
|
+
if (room.timer) {
|
|
90
|
+
clearTimeout(room.timer);
|
|
91
|
+
room.timer = null;
|
|
92
|
+
}
|
|
93
|
+
const existingPeerIds = [...room.peers].map(p => p.peerId);
|
|
94
|
+
room.peers.add(ws);
|
|
95
|
+
ws.room = code;
|
|
96
|
+
send(ws, { type: 'joined', code, peerId: ws.peerId, peers: existingPeerIds });
|
|
97
|
+
broadcast(code, { type: 'peer_joined', peerId: ws.peerId }, ws);
|
|
98
|
+
console.log(`Peer joined room: ${code} (${room.peers.size} peers)`);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
case 'offer':
|
|
103
|
+
case 'answer':
|
|
104
|
+
case 'ice': {
|
|
105
|
+
if (!ws.room) return;
|
|
106
|
+
const room = rooms.get(ws.room);
|
|
107
|
+
if (!room) return;
|
|
108
|
+
const payload = { ...msg, from: ws.peerId };
|
|
109
|
+
if (msg.to) {
|
|
110
|
+
// Route to specific peer
|
|
111
|
+
for (const peer of room.peers) {
|
|
112
|
+
if (peer.peerId === msg.to) { send(peer, payload); break; }
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
broadcast(ws.room, payload, ws);
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
ws.on('close', () => {
|
|
123
|
+
if (!ws.room || !rooms.has(ws.room)) return;
|
|
124
|
+
const room = rooms.get(ws.room);
|
|
125
|
+
room.peers.delete(ws);
|
|
126
|
+
broadcast(ws.room, { type: 'peer_left', peerId: ws.peerId });
|
|
127
|
+
if (room.peers.size === 0) {
|
|
128
|
+
const code = ws.room;
|
|
129
|
+
room.timer = setTimeout(() => {
|
|
130
|
+
rooms.delete(code);
|
|
131
|
+
console.log(`Room expired: ${code}`);
|
|
132
|
+
}, ROOM_TTL);
|
|
133
|
+
console.log(`Room empty, expires in ${ROOM_TTL / 1000}s: ${ws.room}`);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
ws.on('error', () => {});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
httpServer.listen(PORT, () => {
|
|
141
|
+
console.log(`hotdrop running on port ${PORT}`);
|
|
142
|
+
});
|