--- /dev/null
+// ─────────────────────────────────────────────
+// drop. — anonymous P2P chat & file share
+// Single-file Deno server · WebRTC + SSE
+// run: deno run --allow-net main.ts
+// ─────────────────────────────────────────────
+
+const PORT = 8080;
+const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
+const CHUNK_SIZE = 16 * 1024; // 16KB DataChannel chunks
+
+// ── Types ────────────────────────────────────
+
+interface Peer {
+ id: string;
+ controller: ReadableStreamDefaultController;
+ lastSeen: number;
+}
+
+interface Session {
+ id: string;
+ peers: Map<string, Peer>;
+ createdAt: number;
+ lastActivity: number;
+}
+
+// ── State ────────────────────────────────────
+
+const sessions = new Map<string, Session>();
+
+function touch(session: Session) {
+ session.lastActivity = Date.now();
+}
+
+function generateId(len = 6): string {
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+ let id = "";
+ for (let i = 0; i < len; i++) {
+ id += chars[Math.floor(Math.random() * chars.length)];
+ }
+ return id;
+}
+
+function generateSessionId(): string {
+ let id: string;
+ do { id = generateId(4) + "-" + generateId(4); }
+ while (sessions.has(id));
+ return id;
+}
+
+function generatePeerId(): string {
+ return generateId(8);
+}
+
+function createSession(): Session {
+ const id = generateSessionId();
+ const session: Session = {
+ id,
+ peers: new Map(),
+ createdAt: Date.now(),
+ lastActivity: Date.now(),
+ };
+ sessions.set(id, session);
+ return session;
+}
+
+const enc = new TextEncoder();
+
+function broadcast(session: Session, fromPeerId: string, event: string, data: unknown) {
+ const payload = enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
+ for (const [pid, peer] of session.peers) {
+ if (pid !== fromPeerId) {
+ try {
+ peer.controller.enqueue(payload);
+ peer.lastSeen = Date.now();
+ } catch (_) {
+ // peer disconnected
+ }
+ }
+ }
+}
+
+function sendToPeer(session: Session, toPeerId: string, event: string, data: unknown) {
+ const peer = session.peers.get(toPeerId);
+ if (!peer) return;
+ try {
+ peer.controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
+ peer.lastSeen = Date.now();
+ } catch (_) {
+ // peer disconnected
+ }
+}
+
+function sendToSelf(peer: Peer, event: string, data: unknown) {
+ try {
+ peer.controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
+ } catch (_) { /* ignore */ }
+}
+
+function removePeer(session: Session, peerId: string) {
+ session.peers.delete(peerId);
+ touch(session);
+ if (session.peers.size === 0) {
+ sessions.delete(session.id);
+ } else {
+ broadcast(session, peerId, "peer-left", { peerId });
+ }
+}
+
+// Cleanup expired sessions every 10 minutes
+setInterval(() => {
+ const now = Date.now();
+ for (const [id, session] of sessions) {
+ if (now - session.lastActivity > SESSION_TTL_MS) {
+ for (const peer of session.peers.values()) {
+ try { peer.controller.close(); } catch (_) { /* ignore */ }
+ }
+ sessions.delete(id);
+ }
+ }
+}, 10 * 60 * 1000);
+
+// ── Rate Limiter ─────────────────────────────
+
+const rlStore = new Map<string, { count: number; reset: number }>();
+
+function rateLimit(ip: string, key: string, max: number, windowMs = 60_000): boolean {
+ const k = ip + ":" + key;
+ const now = Date.now();
+ const e = rlStore.get(k);
+ if (!e || now > e.reset) { rlStore.set(k, { count: 1, reset: now + windowMs }); return true; }
+ if (e.count >= max) return false;
+ e.count++;
+ return true;
+}
+
+setInterval(() => {
+ const now = Date.now();
+ for (const [k, e] of rlStore) if (now > e.reset) rlStore.delete(k);
+}, 5 * 60 * 1000);
+
+// ── HTTP Router ──────────────────────────────
+
+async function handler(req: Request, ip: string): Promise<Response> {
+ const url = new URL(req.url);
+ const path = url.pathname;
+
+ // GET / → HTML app shell
+ if (req.method === "GET" && path === "/") {
+ return new Response(HTML, {
+ headers: { "Content-Type": "text/html; charset=utf-8" },
+ });
+ }
+
+ // POST /session → create new session
+ if (req.method === "POST" && path === "/session") {
+ if (!rateLimit(ip, "session-create", 10)) return json({ error: "Too many requests" }, 429);
+ const session = createSession();
+ return json({ sessionId: session.id });
+ }
+
+ // GET /session/:id/exists → check if session exists
+ if (req.method === "GET" && path.match(/^\/session\/[^/]+\/exists$/)) {
+ if (!rateLimit(ip, "session-check", 30)) return json({ error: "Too many requests" }, 429);
+ const sessionId = path.split("/")[2];
+ return json({ exists: sessions.has(sessionId) });
+ }
+
+ // GET /connect/:sessionId → SSE stream, join session
+ if (req.method === "GET" && path.match(/^\/connect\/[^/]+$/)) {
+ if (!rateLimit(ip, "connect", 10)) return json({ error: "Too many requests" }, 429);
+ const sessionId = path.split("/")[2];
+ const session = sessions.get(sessionId);
+ if (!session) {
+ return json({ error: "Session not found" }, 404);
+ }
+
+ if (session.peers.size >= 2) {
+ return json({ error: "Session full" }, 403);
+ }
+
+ const peerId = generatePeerId();
+ const isHost = session.peers.size === 0;
+
+ let controller!: ReadableStreamDefaultController;
+ let heartbeat: ReturnType<typeof setInterval>;
+ const stream = new ReadableStream({
+ start(c) { controller = c; },
+ cancel() { clearInterval(heartbeat); removePeer(session!, peerId); },
+ });
+
+ const peer: Peer = { id: peerId, controller, lastSeen: Date.now() };
+ session.peers.set(peerId, peer);
+ touch(session);
+
+ heartbeat = setInterval(() => {
+ try { peer.controller.enqueue(enc.encode("event: heartbeat\ndata: {}\n\n")); }
+ catch (_) { clearInterval(heartbeat); }
+ }, 30_000);
+
+ // Send welcome
+ setTimeout(() => {
+ sendToSelf(peer, "welcome", { peerId, sessionId, isHost, chunkSize: CHUNK_SIZE });
+ if (!isHost) {
+ broadcast(session!, peerId, "peer-joined", { peerId });
+ }
+ }, 50);
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ });
+ }
+
+ // POST /signal/:sessionId → WebRTC signaling relay
+ if (req.method === "POST" && path.match(/^\/signal\/[^/]+$/)) {
+ if (!rateLimit(ip, "signal", 200)) return json({ error: "Too many requests" }, 429);
+ const sessionId = path.split("/")[2];
+ const session = sessions.get(sessionId);
+ if (!session) return json({ error: "Session not found" }, 404);
+
+ const body = await req.json();
+ const { fromPeerId, toPeerId, type, payload } = body;
+ touch(session);
+
+ if (toPeerId) {
+ sendToPeer(session, toPeerId, "signal", { fromPeerId, type, payload });
+ } else {
+ broadcast(session, fromPeerId, "signal", { fromPeerId, type, payload });
+ }
+
+ return json({ ok: true });
+ }
+
+ // POST /leave/:sessionId
+ if (req.method === "POST" && path.match(/^\/leave\/[^/]+$/)) {
+ const sessionId = path.split("/")[2];
+ const session = sessions.get(sessionId);
+ if (session) {
+ const body = await req.json();
+ removePeer(session, body.peerId);
+ }
+ return json({ ok: true });
+ }
+
+ return new Response("Not found", { status: 404 });
+}
+
+function json(data: unknown, status = 200): Response {
+ return new Response(JSON.stringify(data), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+// ── Start ────────────────────────────────────
+
+console.log(`drop. running → http://localhost:${PORT}`);
+Deno.serve({ port: PORT }, (req, info) => handler(req, (info.remoteAddr as Deno.NetAddr).hostname));
+
+// ── HTML / CSS / JS ──────────────────────────
+
+const HTML = `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>drop.</title>
+<style>
+ @import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=DM+Sans:ital,opsz,wght@0,9..40,200;0,9..40,300;0,9..40,400;1,9..40,200&display=swap');
+
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+ :root {
+ --bg: #0e0e0e;
+ --surface: #161616;
+ --surface2: #1e1e1e;
+ --border: #2a2a2a;
+ --border-soft: #1f1f1f;
+ --text: #e8e8e8;
+ --text-dim: #888;
+ --text-muted: #444;
+ --accent-dim: #555;
+ --danger: #ff4444;
+ --success: #4ade80;
+ --mono: 'DM Mono', monospace;
+ --sans: 'DM Sans', sans-serif;
+ --radius: 6px;
+ --transition: 150ms ease;
+ }
+
+ html, body { height: 100%; overflow: hidden; }
+
+ body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--sans);
+ font-size: 14px;
+ font-weight: 300;
+ -webkit-font-smoothing: antialiased;
+ }
+
+ .screen { display: none; height: 100vh; }
+ .screen.active { display: flex; }
+
+ /* ── Landing ── */
+ #screen-land {
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0;
+ padding: 40px;
+ }
+
+ .wordmark {
+ font-family: var(--mono);
+ font-size: 52px;
+ font-weight: 300;
+ letter-spacing: -3px;
+ color: var(--text);
+ margin-bottom: 10px;
+ opacity: 0;
+ animation: fadeUp 600ms 100ms ease forwards;
+ }
+
+ .tagline {
+ font-size: 12px;
+ font-weight: 200;
+ color: var(--text-muted);
+ letter-spacing: 0.18em;
+ margin-bottom: 72px;
+ opacity: 0;
+ animation: fadeUp 600ms 200ms ease forwards;
+ }
+
+ .land-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ width: 100%;
+ max-width: 260px;
+ opacity: 0;
+ animation: fadeUp 600ms 300ms ease forwards;
+ }
+
+ .btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 42px;
+ border-radius: var(--radius);
+ font-family: var(--sans);
+ font-size: 13px;
+ font-weight: 400;
+ letter-spacing: 0.02em;
+ cursor: pointer;
+ transition: all var(--transition);
+ border: none;
+ outline: none;
+ }
+
+ .btn-primary {
+ background: var(--text);
+ color: var(--bg);
+ }
+ .btn-primary:hover { background: #cfcfcf; }
+ .btn-primary:active { transform: scale(0.98); }
+
+ .btn-ghost {
+ background: transparent;
+ color: var(--text-dim);
+ border: 1px solid var(--border);
+ }
+ .btn-ghost:hover { border-color: var(--accent-dim); color: var(--text); }
+ .btn-ghost:active { transform: scale(0.98); }
+
+ .divider {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--text-muted);
+ font-size: 11px;
+ letter-spacing: 0.12em;
+ font-family: var(--mono);
+ margin: 4px 0;
+ }
+ .divider::before, .divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--border);
+ }
+
+ .join-row {
+ display: flex;
+ gap: 8px;
+ width: 100%;
+ max-width: 260px;
+ opacity: 0;
+ animation: fadeUp 600ms 350ms ease forwards;
+ }
+
+ .input-code {
+ flex: 1;
+ height: 42px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 0 14px;
+ font-family: var(--mono);
+ font-size: 13px;
+ color: var(--text);
+ letter-spacing: 0.14em;
+ outline: none;
+ transition: border-color var(--transition);
+ text-transform: uppercase;
+ }
+ .input-code::placeholder { color: var(--text-muted); letter-spacing: 0.06em; text-transform: none; }
+ .input-code:focus { border-color: var(--accent-dim); }
+
+ .land-footer {
+ position: fixed;
+ bottom: 28px;
+ font-size: 11px;
+ color: var(--text-muted);
+ font-family: var(--mono);
+ letter-spacing: 0.08em;
+ opacity: 0;
+ animation: fadeUp 600ms 500ms ease forwards;
+ }
+
+ /* ── Waiting ── */
+ #screen-wait {
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 36px;
+ padding: 40px;
+ }
+
+ .wait-label {
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.2em;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ }
+
+ .session-code-big {
+ font-family: var(--mono);
+ font-size: 38px;
+ font-weight: 300;
+ letter-spacing: 10px;
+ color: var(--text);
+ cursor: pointer;
+ transition: color var(--transition);
+ padding-left: 10px; /* optical offset for letter-spacing */
+ }
+ .session-code-big:hover { color: var(--text-dim); }
+
+ .copy-hint {
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--text-muted);
+ letter-spacing: 0.08em;
+ margin-top: -24px;
+ }
+
+ .pulse-ring {
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ border: 1px solid var(--border);
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ .pulse-ring::before {
+ content: '';
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ border: 1px solid var(--accent-dim);
+ animation: pulse 2.2s ease infinite;
+ }
+ .pulse-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--text-muted);
+ }
+
+ /* ── Chat ── */
+ #screen-chat {
+ flex-direction: column;
+ height: 100vh;
+ position: relative;
+ }
+
+ .drop-overlay {
+ display: none;
+ position: absolute;
+ inset: 0;
+ background: rgba(14,14,14,0.88);
+ align-items: center;
+ justify-content: center;
+ z-index: 50;
+ pointer-events: none;
+ }
+ .drop-overlay.active { display: flex; }
+ .drop-hint {
+ font-family: var(--mono);
+ font-size: 13px;
+ letter-spacing: 0.12em;
+ color: var(--text-dim);
+ border: 1px dashed var(--border);
+ padding: 20px 36px;
+ border-radius: var(--radius);
+ }
+
+ .chat-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 20px;
+ height: 50px;
+ border-bottom: 1px solid var(--border-soft);
+ flex-shrink: 0;
+ }
+
+ .chat-wordmark {
+ font-family: var(--mono);
+ font-size: 17px;
+ font-weight: 300;
+ letter-spacing: -1px;
+ color: var(--text);
+ }
+
+ .chat-meta {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ }
+
+ .session-pill {
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.14em;
+ color: var(--text-muted);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ padding: 3px 10px;
+ border-radius: 20px;
+ cursor: pointer;
+ transition: all var(--transition);
+ }
+ .session-pill:hover { color: var(--text-dim); border-color: var(--accent-dim); }
+
+ .status-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--success);
+ box-shadow: 0 0 6px color-mix(in srgb, var(--success) 60%, transparent);
+ }
+
+ .btn-leave {
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--text-muted);
+ background: none;
+ border: none;
+ cursor: pointer;
+ letter-spacing: 0.08em;
+ padding: 4px 0;
+ transition: color var(--transition);
+ }
+ .btn-leave:hover { color: var(--danger); }
+
+ .messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ scroll-behavior: smooth;
+ }
+ .messages::-webkit-scrollbar { width: 3px; }
+ .messages::-webkit-scrollbar-track { background: transparent; }
+ .messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
+
+ .msg {
+ display: flex;
+ flex-direction: column;
+ max-width: 70%;
+ opacity: 0;
+ animation: msgIn 180ms ease forwards;
+ }
+ .msg.mine { align-self: flex-end; align-items: flex-end; }
+ .msg.theirs { align-self: flex-start; align-items: flex-start; }
+ .msg.system { align-self: center; align-items: center; max-width: 100%; }
+
+ .msg-bubble {
+ padding: 8px 13px;
+ border-radius: 14px;
+ font-size: 14px;
+ font-weight: 300;
+ line-height: 1.5;
+ word-break: break-word;
+ }
+
+ .msg.mine .msg-bubble {
+ background: var(--surface2);
+ color: var(--text);
+ border-bottom-right-radius: 3px;
+ }
+ .msg.theirs .msg-bubble {
+ background: var(--surface);
+ color: var(--text);
+ border: 1px solid var(--border-soft);
+ border-bottom-left-radius: 3px;
+ }
+ .msg.system .msg-bubble {
+ background: transparent;
+ color: var(--text-muted);
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.06em;
+ padding: 4px 0;
+ margin: 6px 0;
+ }
+
+ .msg-time {
+ font-family: var(--mono);
+ font-size: 10px;
+ color: var(--text-muted);
+ margin-top: 3px;
+ padding: 0 4px;
+ }
+
+ .msg-file {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 13px;
+ border-radius: 14px;
+ cursor: pointer;
+ transition: opacity var(--transition);
+ min-width: 210px;
+ }
+ .msg.mine .msg-file {
+ background: var(--surface2);
+ border-bottom-right-radius: 3px;
+ }
+ .msg.theirs .msg-file {
+ background: var(--surface);
+ border: 1px solid var(--border-soft);
+ border-bottom-left-radius: 3px;
+ }
+ .msg-file:hover { opacity: 0.75; }
+
+ .file-icon {
+ width: 34px;
+ height: 34px;
+ border-radius: 7px;
+ background: var(--border);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: var(--mono);
+ font-size: 8px;
+ font-weight: 500;
+ color: var(--text-dim);
+ letter-spacing: 0.04em;
+ flex-shrink: 0;
+ }
+
+ .file-info { flex: 1; min-width: 0; }
+ .file-name { font-size: 13px; font-weight: 400; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+ .file-size { font-family: var(--mono); font-size: 10px; color: var(--text-muted); margin-top: 2px; }
+ .file-progress { width: 100%; height: 1px; background: var(--border); border-radius: 1px; margin-top: 7px; overflow: hidden; }
+ .file-progress-bar { height: 100%; background: var(--text-dim); border-radius: 1px; transition: width 80ms linear; width: 0%; }
+
+ /* Input */
+ .chat-input-area {
+ padding: 10px 14px 18px;
+ border-top: 1px solid var(--border-soft);
+ flex-shrink: 0;
+ }
+
+ .input-row {
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 8px 8px 8px 14px;
+ transition: border-color var(--transition);
+ }
+ .input-row:focus-within { border-color: var(--accent-dim); }
+
+ .msg-input {
+ flex: 1;
+ background: none;
+ border: none;
+ outline: none;
+ font-family: var(--sans);
+ font-size: 14px;
+ font-weight: 300;
+ color: var(--text);
+ resize: none;
+ max-height: 120px;
+ line-height: 1.5;
+ padding: 2px 0;
+ }
+ .msg-input::placeholder { color: var(--text-muted); }
+
+ .input-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; padding-bottom: 2px; }
+
+ .icon-btn {
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--text-muted);
+ transition: all var(--transition);
+ }
+ .icon-btn:hover { background: var(--surface2); color: var(--text); }
+ .icon-btn.send { background: var(--text); color: var(--bg); }
+ .icon-btn.send:hover { background: #cfcfcf; }
+ .icon-btn.send:active { transform: scale(0.9); }
+ .icon-btn svg { width: 15px; height: 15px; }
+
+ #file-input { display: none; }
+
+ /* Transfer bar */
+ .transfer-bar {
+ display: none;
+ align-items: center;
+ gap: 10px;
+ padding: 7px 14px;
+ border-top: 1px solid var(--border-soft);
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--text-muted);
+ letter-spacing: 0.06em;
+ flex-shrink: 0;
+ }
+ .transfer-bar.active { display: flex; }
+ .transfer-track { flex: 1; height: 1px; background: var(--border); border-radius: 1px; overflow: hidden; }
+ .transfer-fill { height: 100%; background: var(--text-dim); border-radius: 1px; transition: width 80ms linear; width: 0%; }
+ .transfer-cancel { width: 22px; height: 22px; flex-shrink: 0; }
+ .transfer-cancel svg { width: 11px; height: 11px; }
+
+ /* Toast */
+ .toast {
+ position: fixed;
+ bottom: 76px;
+ left: 50%;
+ transform: translateX(-50%) translateY(8px);
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 20px;
+ padding: 7px 16px;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--text-dim);
+ letter-spacing: 0.06em;
+ opacity: 0;
+ transition: all 220ms ease;
+ pointer-events: none;
+ white-space: nowrap;
+ z-index: 100;
+ }
+ .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
+
+ /* Error */
+ #screen-err { flex-direction: column; align-items: center; justify-content: center; gap: 14px; }
+ .err-code { font-family: var(--mono); font-size: 60px; font-weight: 300; color: var(--text-muted); letter-spacing: -2px; }
+ .err-msg { font-size: 13px; color: var(--text-dim); font-weight: 200; }
+
+ @keyframes fadeUp {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+ @keyframes pulse {
+ 0% { transform: scale(1); opacity: 0.7; }
+ 70% { transform: scale(1.9); opacity: 0; }
+ 100% { transform: scale(1.9); opacity: 0; }
+ }
+ @keyframes msgIn {
+ from { opacity: 0; transform: translateY(5px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+</style>
+</head>
+<body>
+
+<!-- Landing -->
+<div id="screen-land" class="screen active">
+ <div class="wordmark">drop.</div>
+ <div class="tagline">anonymous · ephemeral · peer-to-peer</div>
+ <div class="land-actions">
+ <button class="btn btn-primary" onclick="App.createSession()">new session</button>
+ <div class="divider">or join</div>
+ </div>
+ <div class="join-row">
+ <input class="input-code" id="join-code" placeholder="XXXX-XXXX" maxlength="9"
+ oninput="App.formatCode(this)"
+ onkeydown="if(event.key==='Enter')App.joinSession()">
+ <button class="btn btn-ghost" style="width:76px;flex-shrink:0" onclick="App.joinSession()">join</button>
+ </div>
+ <div class="land-footer">no logs · no storage · vanishes in 1h</div>
+</div>
+
+<!-- Waiting -->
+<div id="screen-wait" class="screen">
+ <div class="pulse-ring"><div class="pulse-dot"></div></div>
+ <div style="display:flex;flex-direction:column;align-items:center;gap:10px">
+ <div class="wait-label">your session code</div>
+ <div class="session-code-big" id="wait-code" onclick="App.copyCode()"></div>
+ <div class="copy-hint">click to copy</div>
+ </div>
+ <div style="font-size:13px;color:var(--text-muted);font-weight:200;text-align:center;max-width:220px;line-height:1.7">
+ share the code — waiting for the other peer
+ </div>
+ <button class="btn btn-ghost" style="width:140px" onclick="App.cancelSession()">cancel</button>
+</div>
+
+<!-- Chat -->
+<div id="screen-chat" class="screen">
+ <div class="drop-overlay" id="drop-overlay"><div class="drop-hint">drop files to send</div></div>
+ <div class="chat-header">
+ <div class="chat-wordmark">drop.</div>
+ <div class="chat-meta">
+ <div class="session-pill" id="header-code" onclick="App.copyCode()" title="click to copy"></div>
+ <div class="status-dot"></div>
+ <button class="btn-leave" onclick="App.leaveSession()">leave</button>
+ </div>
+ </div>
+ <div class="messages" id="messages"></div>
+ <div class="transfer-bar" id="transfer-bar">
+ <span id="transfer-label">sending…</span>
+ <div class="transfer-track"><div class="transfer-fill" id="transfer-fill"></div></div>
+ <span id="transfer-pct">0%</span>
+ <button class="icon-btn transfer-cancel" onclick="App.cancelTransfer()" title="cancel transfer">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
+ </button>
+ </div>
+ <div class="chat-input-area">
+ <div class="input-row">
+ <textarea class="msg-input" id="msg-input" rows="1" placeholder="message…"
+ onkeydown="App.handleKey(event)" oninput="App.autoResize(this)"></textarea>
+ <div class="input-actions">
+ <input type="file" id="file-input" multiple onchange="App.sendFile(this)">
+ <button class="icon-btn" onclick="document.getElementById('file-input').click()" title="attach file">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
+ </svg>
+ </button>
+ <button class="icon-btn send" onclick="App.sendMessage()" title="send">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
+ </svg>
+ </button>
+ </div>
+ </div>
+ </div>
+</div>
+
+<!-- Error -->
+<div id="screen-err" class="screen">
+ <div class="err-code">:(</div>
+ <div class="err-msg" id="err-msg">something went wrong</div>
+ <button class="btn btn-ghost" style="width:140px;margin-top:16px" onclick="App.showScreen('land')">go back</button>
+</div>
+
+<div class="toast" id="toast"></div>
+
+<script>
+const App = (() => {
+ // ── State ───────────────────────────────────
+ let sessionId = null;
+ let myPeerId = null;
+ let remotePeerId = null;
+ let isHost = false;
+ let sseSource = null;
+ let pc = null;
+ let dc = null; // outgoing data channel (host creates it)
+ let chunkSize = 16384;
+ let _connectTimer = null;
+
+ // File send state
+ let fileQueue = [];
+ let isSending = false;
+ let sendAbortFlag = false;
+ let sendResolve = null;
+
+ // File receive state
+ let incomingFile = null;
+
+ const STUN_CFG = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] };
+
+ // ── Screen ──────────────────────────────────
+ function showScreen(id) {
+ document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
+ document.getElementById('screen-' + id).classList.add('active');
+ }
+
+ function showError(msg) {
+ document.getElementById('err-msg').textContent = msg;
+ showScreen('err');
+ }
+
+ // ── Utils ───────────────────────────────────
+ function formatCode(el) {
+ let v = el.value.replace(/[^A-Z0-9a-z]/g, '').toUpperCase();
+ if (v.length > 4) v = v.slice(0, 4) + '-' + v.slice(4, 8);
+ el.value = v;
+ }
+
+ function ts() {
+ return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ }
+
+ function fmtSize(bytes) {
+ if (bytes < 1024) return bytes + ' B';
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
+ if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
+ return (bytes / 1073741824).toFixed(2) + ' GB';
+ }
+
+ function fmtSpeed(bps) {
+ if (bps >= 1048576) return (bps / 1048576).toFixed(1) + ' MB/s';
+ return Math.round(bps / 1024) + ' KB/s';
+ }
+
+ function fmtEta(secs) {
+ if (secs >= 60) return Math.ceil(secs / 60) + 'm';
+ return secs + 's';
+ }
+
+ function fileExt(name) {
+ const p = name.lastIndexOf('.');
+ return p >= 0 ? name.slice(p + 1).toUpperCase().slice(0, 4) : 'FILE';
+ }
+
+ function escHtml(s) {
+ return s.replace(/&/g, '&').replace(/[<]/g, '<').replace(/>/g, '>').replace(/\\n/g, '<br>');
+ }
+
+ function toast(msg) {
+ const el = document.getElementById('toast');
+ el.textContent = msg;
+ el.classList.add('show');
+ clearTimeout(el._t);
+ el._t = setTimeout(() => el.classList.remove('show'), 2000);
+ }
+
+ function copyCode() {
+ if (!sessionId) return;
+ navigator.clipboard.writeText(sessionId).then(() => toast('copied')).catch(() => toast(sessionId));
+ }
+
+ function autoResize(el) {
+ el.style.height = 'auto';
+ el.style.height = Math.min(el.scrollHeight, 120) + 'px';
+ }
+
+ function handleKey(e) {
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
+ }
+
+ // ── Messages UI ─────────────────────────────
+ function addMessage(text, side, time) {
+ const el = document.createElement('div');
+ el.className = 'msg ' + side;
+ el.innerHTML =
+ '<div class="msg-bubble">' + escHtml(text) + '<\/div>' +
+ '<div class="msg-time">' + (time || ts()) + '<\/div>';
+ document.getElementById('messages').appendChild(el);
+ scrollBottom();
+ }
+
+ function addSystem(text) {
+ const el = document.createElement('div');
+ el.className = 'msg system';
+ el.innerHTML = '<div class="msg-bubble">' + text + '<\/div>';
+ document.getElementById('messages').appendChild(el);
+ scrollBottom();
+ }
+
+ function addFileMessage(side, meta) {
+ const el = document.createElement('div');
+ el.className = 'msg ' + side;
+ const fid = 'file-' + Date.now() + '-' + Math.random().toString(36).slice(2);
+ el.innerHTML =
+ '<div class="msg-file" id="' + fid + '">' +
+ '<div class="file-icon">' + fileExt(meta.name) + '<\/div>' +
+ '<div class="file-info">' +
+ '<div class="file-name">' + escHtml(meta.name) + '<\/div>' +
+ '<div class="file-size">' + fmtSize(meta.size) + '<\/div>' +
+ '<div class="file-progress"><div class="file-progress-bar" id="' + fid + '-bar"><\/div><\/div>' +
+ '<\/div>' +
+ '<\/div>' +
+ '<div class="msg-time">' + ts() + '<\/div>';
+ document.getElementById('messages').appendChild(el);
+ scrollBottom();
+ return fid;
+ }
+
+ function scrollBottom() {
+ const m = document.getElementById('messages');
+ m.scrollTop = m.scrollHeight;
+ }
+
+ // ── Create Session ───────────────────────────
+ async function createSession() {
+ try {
+ const res = await fetch('/session', { method: 'POST' });
+ const { sessionId: sid } = await res.json();
+ sessionId = sid;
+ isHost = true;
+ history.replaceState(null, '', '?s=' + sid);
+ document.getElementById('wait-code').textContent = sid;
+ document.getElementById('header-code').textContent = sid;
+ showScreen('wait');
+ connectSSE();
+ } catch (e) {
+ showError('could not create session');
+ }
+ }
+
+ // ── Join Session ─────────────────────────────
+ async function joinSession() {
+ const code = document.getElementById('join-code').value.trim().toUpperCase();
+ if (code.length < 9) { toast('enter a valid code'); return; }
+ try {
+ const res = await fetch('/session/' + code + '/exists');
+ const { exists } = await res.json();
+ if (!exists) { toast('session not found'); return; }
+ sessionId = code;
+ isHost = false;
+ history.replaceState(null, '', '?s=' + code);
+ document.getElementById('header-code').textContent = code;
+ connectSSE();
+ } catch (e) {
+ toast('could not reach server');
+ }
+ }
+
+ function cancelSession() {
+ cleanup(false);
+ showScreen('land');
+ }
+
+ function leaveSession() {
+ cleanup(true);
+ showScreen('land');
+ }
+
+ function cancelTransfer() {
+ if (!isSending) return;
+ fileQueue = [];
+ sendAbortFlag = true;
+ if (sendResolve) { sendResolve(); sendResolve = null; }
+ }
+
+ function cleanup(notify) {
+ if (notify && sessionId && myPeerId) {
+ fetch('/leave/' + sessionId, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ peerId: myPeerId })
+ }).catch(() => {});
+ }
+ cancelTransfer();
+ incomingFile = null;
+ if (_connectTimer) { clearTimeout(_connectTimer); _connectTimer = null; }
+ if (dc) { try { dc.close(); } catch (_) {} dc = null; }
+ if (pc) { try { pc.close(); } catch (_) {} pc = null; }
+ if (sseSource) { sseSource.close(); sseSource = null; }
+ sessionId = null; myPeerId = null; remotePeerId = null;
+ history.replaceState(null, '', '/');
+ document.getElementById('messages').innerHTML = '';
+ document.getElementById('join-code').value = '';
+ }
+
+ // ── SSE ─────────────────────────────────────
+ function connectSSE() {
+ sseSource = new EventSource('/connect/' + sessionId);
+
+ sseSource.addEventListener('welcome', e => {
+ const d = JSON.parse(e.data);
+ myPeerId = d.peerId;
+ if (d.chunkSize) chunkSize = d.chunkSize;
+ if (!isHost) {
+ showScreen('chat');
+ addSystem('waiting for secure channel…');
+ }
+ });
+
+ sseSource.addEventListener('peer-joined', e => {
+ const d = JSON.parse(e.data);
+ remotePeerId = d.peerId;
+ if (isHost) {
+ showScreen('chat');
+ addSystem('peer connected — establishing secure channel…');
+ startWebRTC();
+ }
+ });
+
+ sseSource.addEventListener('peer-left', () => {
+ addSystem('peer disconnected');
+ cancelTransfer();
+ incomingFile = null;
+ remotePeerId = null;
+ if (dc) { try { dc.close(); } catch (_) {} dc = null; }
+ if (pc) { try { pc.close(); } catch (_) {} pc = null; }
+ });
+
+ sseSource.addEventListener('signal', async e => {
+ const d = JSON.parse(e.data);
+ if (!remotePeerId) remotePeerId = d.fromPeerId;
+ await handleSignal(d.type, d.payload);
+ });
+
+ sseSource.onerror = () => {
+ if (sseSource.readyState === EventSource.CLOSED) {
+ cleanup(false);
+ showError('connection lost — please refresh');
+ }
+ };
+ }
+
+ // ── WebRTC ───────────────────────────────────
+ function createPC() {
+ pc = new RTCPeerConnection(STUN_CFG);
+
+ _connectTimer = setTimeout(() => {
+ if (pc && pc.connectionState !== 'connected') {
+ addSystem('direct connection timed out — this app uses STUN only and does not support TURN relay servers. both peers must be on reachable networks.');
+ }
+ }, 20000);
+
+ pc.onicecandidate = e => {
+ if (e.candidate) signal('ice', e.candidate);
+ };
+
+ pc.ondatachannel = e => {
+ setupChannel(e.channel);
+ };
+
+ pc.onconnectionstatechange = () => {
+ if (!pc) return;
+ if (pc.connectionState === 'connected') {
+ clearTimeout(_connectTimer); _connectTimer = null;
+ addSystem('secure channel open · end-to-end p2p');
+ } else if (pc.connectionState === 'failed') {
+ clearTimeout(_connectTimer); _connectTimer = null;
+ addSystem('direct connection failed — this app uses STUN only, no TURN relay support. try a different network.');
+ } else if (pc.connectionState === 'disconnected') {
+ addSystem('connection interrupted');
+ }
+ };
+ }
+
+ async function startWebRTC() {
+ createPC();
+ dc = pc.createDataChannel('drop', { ordered: true });
+ setupChannel(dc);
+ const offer = await pc.createOffer();
+ await pc.setLocalDescription(offer);
+ signal('offer', offer);
+ }
+
+ async function handleSignal(type, payload) {
+ if (type === 'offer') {
+ if (!pc) createPC();
+ await pc.setRemoteDescription(new RTCSessionDescription(payload));
+ const answer = await pc.createAnswer();
+ await pc.setLocalDescription(answer);
+ signal('answer', answer);
+ } else if (type === 'answer') {
+ await pc.setRemoteDescription(new RTCSessionDescription(payload));
+ } else if (type === 'ice') {
+ try { await pc.addIceCandidate(new RTCIceCandidate(payload)); } catch (_) {}
+ }
+ }
+
+ function signal(type, payload) {
+ fetch('/signal/' + sessionId, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ fromPeerId: myPeerId, toPeerId: remotePeerId, type, payload })
+ }).catch(() => {});
+ }
+
+ // ── Data Channel ─────────────────────────────
+ function setupChannel(chan) {
+ chan.binaryType = 'arraybuffer';
+ if (!dc) dc = chan; // receiver side stores it too
+
+ chan.onopen = () => {};
+ chan.onclose = () => {};
+
+ chan.onmessage = e => {
+ if (typeof e.data === 'string') {
+ const msg = JSON.parse(e.data);
+ if (msg.type === 'chat') {
+ addMessage(msg.text, 'theirs', msg.time);
+ } else if (msg.type === 'file-meta') {
+ incomingFile = { name: msg.name, size: msg.size, mime: msg.mime || 'application/octet-stream', chunks: [], received: 0 };
+ incomingFile.fid = addFileMessage('theirs', { name: msg.name, size: msg.size });
+ } else if (msg.type === 'file-done') {
+ if (incomingFile) finaliseIncoming();
+ }
+ } else {
+ if (incomingFile) receiveChunk(e.data);
+ }
+ };
+ }
+
+ function receiveChunk(data) {
+ incomingFile.chunks.push(data);
+ incomingFile.received += data.byteLength;
+ const pct = Math.round((incomingFile.received / incomingFile.size) * 100);
+ const bar = document.getElementById(incomingFile.fid + '-bar');
+ if (bar) bar.style.width = pct + '%';
+ }
+
+ function finaliseIncoming() {
+ const blob = new Blob(incomingFile.chunks, { type: incomingFile.mime });
+ const url = URL.createObjectURL(blob);
+ const name = incomingFile.name;
+ const fileEl = document.getElementById(incomingFile.fid);
+ if (fileEl) {
+ fileEl.onclick = () => { const a = document.createElement('a'); a.href = url; a.download = name; a.click(); };
+ const bar = document.getElementById(incomingFile.fid + '-bar');
+ if (bar) bar.style.width = '100%';
+ }
+ addSystem(escHtml(name) + ' received — click to save');
+ incomingFile = null;
+ }
+
+ // ── Send Message ─────────────────────────────
+ function sendMessage() {
+ const input = document.getElementById('msg-input');
+ const text = input.value.trim();
+ if (!text) return;
+ if (!dc || dc.readyState !== 'open') { toast('not connected yet'); return; }
+ dc.send(JSON.stringify({ type: 'chat', text, time: ts() }));
+ addMessage(text, 'mine');
+ input.value = '';
+ input.style.height = 'auto';
+ }
+
+ // ── Send File ─────────────────────────────────
+ function sendFile(input) {
+ const files = Array.from(input.files);
+ input.value = '';
+ queueFiles(files);
+ }
+
+ function queueFiles(files) {
+ if (!dc || dc.readyState !== 'open') { toast('not connected yet'); return; }
+ fileQueue.push(...files);
+ if (!isSending) drainQueue();
+ }
+
+ async function drainQueue() {
+ isSending = true;
+ while (fileQueue.length > 0) {
+ sendAbortFlag = false;
+ await sendOneFile(fileQueue.shift());
+ if (sendAbortFlag) break;
+ }
+ isSending = false;
+ sendAbortFlag = false;
+ document.getElementById('transfer-bar').classList.remove('active');
+ document.getElementById('transfer-fill').style.width = '0%';
+ document.getElementById('transfer-pct').textContent = '0%';
+ document.getElementById('transfer-label').textContent = 'sending…';
+ }
+
+ async function sendOneFile(file) {
+ const fid = addFileMessage('mine', { name: file.name, size: file.size });
+ dc.send(JSON.stringify({ type: 'file-meta', name: file.name, size: file.size, mime: file.type || 'application/octet-stream' }));
+
+ const tBar = document.getElementById('transfer-bar');
+ const tFill = document.getElementById('transfer-fill');
+ const tPct = document.getElementById('transfer-pct');
+ const tLabel = document.getElementById('transfer-label');
+ tBar.classList.add('active');
+ tLabel.textContent = file.name;
+ tFill.style.width = '0%';
+
+ let offset = 0;
+ const total = file.size;
+ const startTime = Date.now();
+
+ while (offset < total && !sendAbortFlag) {
+ if (dc.bufferedAmount > chunkSize * 16) {
+ dc.bufferedAmountLowThreshold = chunkSize * 4;
+ await new Promise(resolve => { sendResolve = resolve; dc.onbufferedamountlow = resolve; });
+ sendResolve = null;
+ dc.onbufferedamountlow = null;
+ }
+ if (sendAbortFlag || !dc || dc.readyState !== 'open') break;
+ const end = Math.min(offset + chunkSize, total);
+ const chunk = await file.slice(offset, end).arrayBuffer();
+ if (sendAbortFlag) break;
+ dc.send(chunk);
+ offset = end;
+
+ const p = Math.round((offset / total) * 100);
+ const elapsed = (Date.now() - startTime) / 1000;
+ const speed = elapsed > 0.5 ? offset / elapsed : 0;
+ const eta = speed > 0 && offset < total ? Math.ceil((total - offset) / speed) : 0;
+ tFill.style.width = p + '%';
+ tPct.textContent = p + '%';
+ tLabel.textContent = file.name
+ + (speed > 0 ? ' · ' + fmtSpeed(speed) : '')
+ + (eta > 0 ? ' · ' + fmtEta(eta) : '');
+ const localBar = document.getElementById(fid + '-bar');
+ if (localBar) localBar.style.width = p + '%';
+ }
+
+ if (!sendAbortFlag && dc && dc.readyState === 'open') {
+ dc.send(JSON.stringify({ type: 'file-done' }));
+ addSystem(escHtml(file.name) + ' sent');
+ }
+ }
+
+ // ── Drag-drop + Paste ────────────────────────
+ function setupListeners() {
+ const chatEl = document.getElementById('screen-chat');
+
+ chatEl.addEventListener('dragenter', e => {
+ e.preventDefault();
+ if (dc && dc.readyState === 'open') document.getElementById('drop-overlay').classList.add('active');
+ });
+ chatEl.addEventListener('dragover', e => { e.preventDefault(); });
+ chatEl.addEventListener('dragleave', e => {
+ if (!chatEl.contains(e.relatedTarget)) document.getElementById('drop-overlay').classList.remove('active');
+ });
+ chatEl.addEventListener('drop', e => {
+ e.preventDefault();
+ document.getElementById('drop-overlay').classList.remove('active');
+ const files = Array.from(e.dataTransfer.files);
+ if (files.length) queueFiles(files);
+ });
+
+ document.addEventListener('paste', e => {
+ if (!dc || dc.readyState !== 'open') return;
+ const files = Array.from(e.clipboardData.files);
+ if (files.length) queueFiles(files);
+ });
+ }
+
+ setupListeners();
+
+ // ── Public API ───────────────────────────────
+ return { showScreen, createSession, joinSession, cancelSession, leaveSession, copyCode, formatCode, handleKey, autoResize, sendMessage, sendFile, queueFiles, cancelTransfer };
+})();
+
+// Deep-link support: ?s=XXXX-XXXX
+const params = new URLSearchParams(location.search);
+const urlSession = params.get('s');
+if (urlSession) {
+ document.getElementById('join-code').value = urlSession.toUpperCase().slice(0, 9);
+ App.joinSession();
+}
+</script>
+</body>
+</html>`;
+++ /dev/null
-// ─────────────────────────────────────────────
-// drop. — anonymous P2P chat & file share
-// Single-file Deno server · WebRTC + SSE
-// run: deno run --allow-net main.ts
-// ─────────────────────────────────────────────
-
-const PORT = 8080;
-const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
-const CHUNK_SIZE = 16 * 1024; // 16KB DataChannel chunks
-
-// ── Types ────────────────────────────────────
-
-interface Peer {
- id: string;
- controller: ReadableStreamDefaultController;
- lastSeen: number;
-}
-
-interface Session {
- id: string;
- peers: Map<string, Peer>;
- createdAt: number;
- lastActivity: number;
-}
-
-// ── State ────────────────────────────────────
-
-const sessions = new Map<string, Session>();
-
-function touch(session: Session) {
- session.lastActivity = Date.now();
-}
-
-function generateId(len = 6): string {
- const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
- let id = "";
- for (let i = 0; i < len; i++) {
- id += chars[Math.floor(Math.random() * chars.length)];
- }
- return id;
-}
-
-function generateSessionId(): string {
- let id: string;
- do { id = generateId(4) + "-" + generateId(4); }
- while (sessions.has(id));
- return id;
-}
-
-function generatePeerId(): string {
- return generateId(8);
-}
-
-function createSession(): Session {
- const id = generateSessionId();
- const session: Session = {
- id,
- peers: new Map(),
- createdAt: Date.now(),
- lastActivity: Date.now(),
- };
- sessions.set(id, session);
- return session;
-}
-
-const enc = new TextEncoder();
-
-function broadcast(session: Session, fromPeerId: string, event: string, data: unknown) {
- const payload = enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
- for (const [pid, peer] of session.peers) {
- if (pid !== fromPeerId) {
- try {
- peer.controller.enqueue(payload);
- peer.lastSeen = Date.now();
- } catch (_) {
- // peer disconnected
- }
- }
- }
-}
-
-function sendToPeer(session: Session, toPeerId: string, event: string, data: unknown) {
- const peer = session.peers.get(toPeerId);
- if (!peer) return;
- try {
- peer.controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
- peer.lastSeen = Date.now();
- } catch (_) {
- // peer disconnected
- }
-}
-
-function sendToSelf(peer: Peer, event: string, data: unknown) {
- try {
- peer.controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
- } catch (_) { /* ignore */ }
-}
-
-function removePeer(session: Session, peerId: string) {
- session.peers.delete(peerId);
- touch(session);
- if (session.peers.size === 0) {
- sessions.delete(session.id);
- } else {
- broadcast(session, peerId, "peer-left", { peerId });
- }
-}
-
-// Cleanup expired sessions every 10 minutes
-setInterval(() => {
- const now = Date.now();
- for (const [id, session] of sessions) {
- if (now - session.lastActivity > SESSION_TTL_MS) {
- for (const peer of session.peers.values()) {
- try { peer.controller.close(); } catch (_) { /* ignore */ }
- }
- sessions.delete(id);
- }
- }
-}, 10 * 60 * 1000);
-
-// ── Rate Limiter ─────────────────────────────
-
-const rlStore = new Map<string, { count: number; reset: number }>();
-
-function rateLimit(ip: string, key: string, max: number, windowMs = 60_000): boolean {
- const k = ip + ":" + key;
- const now = Date.now();
- const e = rlStore.get(k);
- if (!e || now > e.reset) { rlStore.set(k, { count: 1, reset: now + windowMs }); return true; }
- if (e.count >= max) return false;
- e.count++;
- return true;
-}
-
-setInterval(() => {
- const now = Date.now();
- for (const [k, e] of rlStore) if (now > e.reset) rlStore.delete(k);
-}, 5 * 60 * 1000);
-
-// ── HTTP Router ──────────────────────────────
-
-async function handler(req: Request, ip: string): Promise<Response> {
- const url = new URL(req.url);
- const path = url.pathname;
-
- // GET / → HTML app shell
- if (req.method === "GET" && path === "/") {
- return new Response(HTML, {
- headers: { "Content-Type": "text/html; charset=utf-8" },
- });
- }
-
- // POST /session → create new session
- if (req.method === "POST" && path === "/session") {
- if (!rateLimit(ip, "session-create", 10)) return json({ error: "Too many requests" }, 429);
- const session = createSession();
- return json({ sessionId: session.id });
- }
-
- // GET /session/:id/exists → check if session exists
- if (req.method === "GET" && path.match(/^\/session\/[^/]+\/exists$/)) {
- if (!rateLimit(ip, "session-check", 30)) return json({ error: "Too many requests" }, 429);
- const sessionId = path.split("/")[2];
- return json({ exists: sessions.has(sessionId) });
- }
-
- // GET /connect/:sessionId → SSE stream, join session
- if (req.method === "GET" && path.match(/^\/connect\/[^/]+$/)) {
- if (!rateLimit(ip, "connect", 10)) return json({ error: "Too many requests" }, 429);
- const sessionId = path.split("/")[2];
- const session = sessions.get(sessionId);
- if (!session) {
- return json({ error: "Session not found" }, 404);
- }
-
- if (session.peers.size >= 2) {
- return json({ error: "Session full" }, 403);
- }
-
- const peerId = generatePeerId();
- const isHost = session.peers.size === 0;
-
- let controller!: ReadableStreamDefaultController;
- let heartbeat: ReturnType<typeof setInterval>;
- const stream = new ReadableStream({
- start(c) { controller = c; },
- cancel() { clearInterval(heartbeat); removePeer(session!, peerId); },
- });
-
- const peer: Peer = { id: peerId, controller, lastSeen: Date.now() };
- session.peers.set(peerId, peer);
- touch(session);
-
- heartbeat = setInterval(() => {
- try { peer.controller.enqueue(enc.encode("event: heartbeat\ndata: {}\n\n")); }
- catch (_) { clearInterval(heartbeat); }
- }, 30_000);
-
- // Send welcome
- setTimeout(() => {
- sendToSelf(peer, "welcome", { peerId, sessionId, isHost, chunkSize: CHUNK_SIZE });
- if (!isHost) {
- broadcast(session!, peerId, "peer-joined", { peerId });
- }
- }, 50);
-
- return new Response(stream, {
- headers: {
- "Content-Type": "text/event-stream",
- "Cache-Control": "no-cache",
- "Connection": "keep-alive",
- "X-Accel-Buffering": "no",
- },
- });
- }
-
- // POST /signal/:sessionId → WebRTC signaling relay
- if (req.method === "POST" && path.match(/^\/signal\/[^/]+$/)) {
- if (!rateLimit(ip, "signal", 200)) return json({ error: "Too many requests" }, 429);
- const sessionId = path.split("/")[2];
- const session = sessions.get(sessionId);
- if (!session) return json({ error: "Session not found" }, 404);
-
- const body = await req.json();
- const { fromPeerId, toPeerId, type, payload } = body;
- touch(session);
-
- if (toPeerId) {
- sendToPeer(session, toPeerId, "signal", { fromPeerId, type, payload });
- } else {
- broadcast(session, fromPeerId, "signal", { fromPeerId, type, payload });
- }
-
- return json({ ok: true });
- }
-
- // POST /leave/:sessionId
- if (req.method === "POST" && path.match(/^\/leave\/[^/]+$/)) {
- const sessionId = path.split("/")[2];
- const session = sessions.get(sessionId);
- if (session) {
- const body = await req.json();
- removePeer(session, body.peerId);
- }
- return json({ ok: true });
- }
-
- return new Response("Not found", { status: 404 });
-}
-
-function json(data: unknown, status = 200): Response {
- return new Response(JSON.stringify(data), {
- status,
- headers: { "Content-Type": "application/json" },
- });
-}
-
-// ── Start ────────────────────────────────────
-
-console.log(`drop. running → http://localhost:${PORT}`);
-Deno.serve({ port: PORT }, (req, info) => handler(req, (info.remoteAddr as Deno.NetAddr).hostname));
-
-// ── HTML / CSS / JS ──────────────────────────
-
-const HTML = `<!DOCTYPE html>
-<html lang="en">
-<head>
-<meta charset="UTF-8">
-<meta name="viewport" content="width=device-width, initial-scale=1.0">
-<title>drop.</title>
-<style>
- @import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=DM+Sans:ital,opsz,wght@0,9..40,200;0,9..40,300;0,9..40,400;1,9..40,200&display=swap');
-
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
-
- :root {
- --bg: #0e0e0e;
- --surface: #161616;
- --surface2: #1e1e1e;
- --border: #2a2a2a;
- --border-soft: #1f1f1f;
- --text: #e8e8e8;
- --text-dim: #888;
- --text-muted: #444;
- --accent-dim: #555;
- --danger: #ff4444;
- --success: #4ade80;
- --mono: 'DM Mono', monospace;
- --sans: 'DM Sans', sans-serif;
- --radius: 6px;
- --transition: 150ms ease;
- }
-
- html, body { height: 100%; overflow: hidden; }
-
- body {
- background: var(--bg);
- color: var(--text);
- font-family: var(--sans);
- font-size: 14px;
- font-weight: 300;
- -webkit-font-smoothing: antialiased;
- }
-
- .screen { display: none; height: 100vh; }
- .screen.active { display: flex; }
-
- /* ── Landing ── */
- #screen-land {
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 0;
- padding: 40px;
- }
-
- .wordmark {
- font-family: var(--mono);
- font-size: 52px;
- font-weight: 300;
- letter-spacing: -3px;
- color: var(--text);
- margin-bottom: 10px;
- opacity: 0;
- animation: fadeUp 600ms 100ms ease forwards;
- }
-
- .tagline {
- font-size: 12px;
- font-weight: 200;
- color: var(--text-muted);
- letter-spacing: 0.18em;
- margin-bottom: 72px;
- opacity: 0;
- animation: fadeUp 600ms 200ms ease forwards;
- }
-
- .land-actions {
- display: flex;
- flex-direction: column;
- gap: 10px;
- width: 100%;
- max-width: 260px;
- opacity: 0;
- animation: fadeUp 600ms 300ms ease forwards;
- }
-
- .btn {
- display: flex;
- align-items: center;
- justify-content: center;
- height: 42px;
- border-radius: var(--radius);
- font-family: var(--sans);
- font-size: 13px;
- font-weight: 400;
- letter-spacing: 0.02em;
- cursor: pointer;
- transition: all var(--transition);
- border: none;
- outline: none;
- }
-
- .btn-primary {
- background: var(--text);
- color: var(--bg);
- }
- .btn-primary:hover { background: #cfcfcf; }
- .btn-primary:active { transform: scale(0.98); }
-
- .btn-ghost {
- background: transparent;
- color: var(--text-dim);
- border: 1px solid var(--border);
- }
- .btn-ghost:hover { border-color: var(--accent-dim); color: var(--text); }
- .btn-ghost:active { transform: scale(0.98); }
-
- .divider {
- display: flex;
- align-items: center;
- gap: 10px;
- color: var(--text-muted);
- font-size: 11px;
- letter-spacing: 0.12em;
- font-family: var(--mono);
- margin: 4px 0;
- }
- .divider::before, .divider::after {
- content: '';
- flex: 1;
- height: 1px;
- background: var(--border);
- }
-
- .join-row {
- display: flex;
- gap: 8px;
- width: 100%;
- max-width: 260px;
- opacity: 0;
- animation: fadeUp 600ms 350ms ease forwards;
- }
-
- .input-code {
- flex: 1;
- height: 42px;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 0 14px;
- font-family: var(--mono);
- font-size: 13px;
- color: var(--text);
- letter-spacing: 0.14em;
- outline: none;
- transition: border-color var(--transition);
- text-transform: uppercase;
- }
- .input-code::placeholder { color: var(--text-muted); letter-spacing: 0.06em; text-transform: none; }
- .input-code:focus { border-color: var(--accent-dim); }
-
- .land-footer {
- position: fixed;
- bottom: 28px;
- font-size: 11px;
- color: var(--text-muted);
- font-family: var(--mono);
- letter-spacing: 0.08em;
- opacity: 0;
- animation: fadeUp 600ms 500ms ease forwards;
- }
-
- /* ── Waiting ── */
- #screen-wait {
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 36px;
- padding: 40px;
- }
-
- .wait-label {
- font-family: var(--mono);
- font-size: 10px;
- letter-spacing: 0.2em;
- color: var(--text-muted);
- text-transform: uppercase;
- }
-
- .session-code-big {
- font-family: var(--mono);
- font-size: 38px;
- font-weight: 300;
- letter-spacing: 10px;
- color: var(--text);
- cursor: pointer;
- transition: color var(--transition);
- padding-left: 10px; /* optical offset for letter-spacing */
- }
- .session-code-big:hover { color: var(--text-dim); }
-
- .copy-hint {
- font-family: var(--mono);
- font-size: 11px;
- color: var(--text-muted);
- letter-spacing: 0.08em;
- margin-top: -24px;
- }
-
- .pulse-ring {
- width: 56px;
- height: 56px;
- border-radius: 50%;
- border: 1px solid var(--border);
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .pulse-ring::before {
- content: '';
- position: absolute;
- width: 100%;
- height: 100%;
- border-radius: 50%;
- border: 1px solid var(--accent-dim);
- animation: pulse 2.2s ease infinite;
- }
- .pulse-dot {
- width: 7px;
- height: 7px;
- border-radius: 50%;
- background: var(--text-muted);
- }
-
- /* ── Chat ── */
- #screen-chat {
- flex-direction: column;
- height: 100vh;
- position: relative;
- }
-
- .drop-overlay {
- display: none;
- position: absolute;
- inset: 0;
- background: rgba(14,14,14,0.88);
- align-items: center;
- justify-content: center;
- z-index: 50;
- pointer-events: none;
- }
- .drop-overlay.active { display: flex; }
- .drop-hint {
- font-family: var(--mono);
- font-size: 13px;
- letter-spacing: 0.12em;
- color: var(--text-dim);
- border: 1px dashed var(--border);
- padding: 20px 36px;
- border-radius: var(--radius);
- }
-
- .chat-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 20px;
- height: 50px;
- border-bottom: 1px solid var(--border-soft);
- flex-shrink: 0;
- }
-
- .chat-wordmark {
- font-family: var(--mono);
- font-size: 17px;
- font-weight: 300;
- letter-spacing: -1px;
- color: var(--text);
- }
-
- .chat-meta {
- display: flex;
- align-items: center;
- gap: 14px;
- }
-
- .session-pill {
- font-family: var(--mono);
- font-size: 11px;
- letter-spacing: 0.14em;
- color: var(--text-muted);
- background: var(--surface);
- border: 1px solid var(--border);
- padding: 3px 10px;
- border-radius: 20px;
- cursor: pointer;
- transition: all var(--transition);
- }
- .session-pill:hover { color: var(--text-dim); border-color: var(--accent-dim); }
-
- .status-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--success);
- box-shadow: 0 0 6px color-mix(in srgb, var(--success) 60%, transparent);
- }
-
- .btn-leave {
- font-family: var(--mono);
- font-size: 11px;
- color: var(--text-muted);
- background: none;
- border: none;
- cursor: pointer;
- letter-spacing: 0.08em;
- padding: 4px 0;
- transition: color var(--transition);
- }
- .btn-leave:hover { color: var(--danger); }
-
- .messages {
- flex: 1;
- overflow-y: auto;
- padding: 20px;
- display: flex;
- flex-direction: column;
- gap: 2px;
- scroll-behavior: smooth;
- }
- .messages::-webkit-scrollbar { width: 3px; }
- .messages::-webkit-scrollbar-track { background: transparent; }
- .messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
-
- .msg {
- display: flex;
- flex-direction: column;
- max-width: 70%;
- opacity: 0;
- animation: msgIn 180ms ease forwards;
- }
- .msg.mine { align-self: flex-end; align-items: flex-end; }
- .msg.theirs { align-self: flex-start; align-items: flex-start; }
- .msg.system { align-self: center; align-items: center; max-width: 100%; }
-
- .msg-bubble {
- padding: 8px 13px;
- border-radius: 14px;
- font-size: 14px;
- font-weight: 300;
- line-height: 1.5;
- word-break: break-word;
- }
-
- .msg.mine .msg-bubble {
- background: var(--surface2);
- color: var(--text);
- border-bottom-right-radius: 3px;
- }
- .msg.theirs .msg-bubble {
- background: var(--surface);
- color: var(--text);
- border: 1px solid var(--border-soft);
- border-bottom-left-radius: 3px;
- }
- .msg.system .msg-bubble {
- background: transparent;
- color: var(--text-muted);
- font-family: var(--mono);
- font-size: 11px;
- letter-spacing: 0.06em;
- padding: 4px 0;
- margin: 6px 0;
- }
-
- .msg-time {
- font-family: var(--mono);
- font-size: 10px;
- color: var(--text-muted);
- margin-top: 3px;
- padding: 0 4px;
- }
-
- .msg-file {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 10px 13px;
- border-radius: 14px;
- cursor: pointer;
- transition: opacity var(--transition);
- min-width: 210px;
- }
- .msg.mine .msg-file {
- background: var(--surface2);
- border-bottom-right-radius: 3px;
- }
- .msg.theirs .msg-file {
- background: var(--surface);
- border: 1px solid var(--border-soft);
- border-bottom-left-radius: 3px;
- }
- .msg-file:hover { opacity: 0.75; }
-
- .file-icon {
- width: 34px;
- height: 34px;
- border-radius: 7px;
- background: var(--border);
- display: flex;
- align-items: center;
- justify-content: center;
- font-family: var(--mono);
- font-size: 8px;
- font-weight: 500;
- color: var(--text-dim);
- letter-spacing: 0.04em;
- flex-shrink: 0;
- }
-
- .file-info { flex: 1; min-width: 0; }
- .file-name { font-size: 13px; font-weight: 400; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
- .file-size { font-family: var(--mono); font-size: 10px; color: var(--text-muted); margin-top: 2px; }
- .file-progress { width: 100%; height: 1px; background: var(--border); border-radius: 1px; margin-top: 7px; overflow: hidden; }
- .file-progress-bar { height: 100%; background: var(--text-dim); border-radius: 1px; transition: width 80ms linear; width: 0%; }
-
- /* Input */
- .chat-input-area {
- padding: 10px 14px 18px;
- border-top: 1px solid var(--border-soft);
- flex-shrink: 0;
- }
-
- .input-row {
- display: flex;
- align-items: flex-end;
- gap: 8px;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: 12px;
- padding: 8px 8px 8px 14px;
- transition: border-color var(--transition);
- }
- .input-row:focus-within { border-color: var(--accent-dim); }
-
- .msg-input {
- flex: 1;
- background: none;
- border: none;
- outline: none;
- font-family: var(--sans);
- font-size: 14px;
- font-weight: 300;
- color: var(--text);
- resize: none;
- max-height: 120px;
- line-height: 1.5;
- padding: 2px 0;
- }
- .msg-input::placeholder { color: var(--text-muted); }
-
- .input-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; padding-bottom: 2px; }
-
- .icon-btn {
- width: 32px;
- height: 32px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 8px;
- background: none;
- border: none;
- cursor: pointer;
- color: var(--text-muted);
- transition: all var(--transition);
- }
- .icon-btn:hover { background: var(--surface2); color: var(--text); }
- .icon-btn.send { background: var(--text); color: var(--bg); }
- .icon-btn.send:hover { background: #cfcfcf; }
- .icon-btn.send:active { transform: scale(0.9); }
- .icon-btn svg { width: 15px; height: 15px; }
-
- #file-input { display: none; }
-
- /* Transfer bar */
- .transfer-bar {
- display: none;
- align-items: center;
- gap: 10px;
- padding: 7px 14px;
- border-top: 1px solid var(--border-soft);
- font-family: var(--mono);
- font-size: 11px;
- color: var(--text-muted);
- letter-spacing: 0.06em;
- flex-shrink: 0;
- }
- .transfer-bar.active { display: flex; }
- .transfer-track { flex: 1; height: 1px; background: var(--border); border-radius: 1px; overflow: hidden; }
- .transfer-fill { height: 100%; background: var(--text-dim); border-radius: 1px; transition: width 80ms linear; width: 0%; }
- .transfer-cancel { width: 22px; height: 22px; flex-shrink: 0; }
- .transfer-cancel svg { width: 11px; height: 11px; }
-
- /* Toast */
- .toast {
- position: fixed;
- bottom: 76px;
- left: 50%;
- transform: translateX(-50%) translateY(8px);
- background: var(--surface2);
- border: 1px solid var(--border);
- border-radius: 20px;
- padding: 7px 16px;
- font-family: var(--mono);
- font-size: 11px;
- color: var(--text-dim);
- letter-spacing: 0.06em;
- opacity: 0;
- transition: all 220ms ease;
- pointer-events: none;
- white-space: nowrap;
- z-index: 100;
- }
- .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
-
- /* Error */
- #screen-err { flex-direction: column; align-items: center; justify-content: center; gap: 14px; }
- .err-code { font-family: var(--mono); font-size: 60px; font-weight: 300; color: var(--text-muted); letter-spacing: -2px; }
- .err-msg { font-size: 13px; color: var(--text-dim); font-weight: 200; }
-
- @keyframes fadeUp {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: translateY(0); }
- }
- @keyframes pulse {
- 0% { transform: scale(1); opacity: 0.7; }
- 70% { transform: scale(1.9); opacity: 0; }
- 100% { transform: scale(1.9); opacity: 0; }
- }
- @keyframes msgIn {
- from { opacity: 0; transform: translateY(5px); }
- to { opacity: 1; transform: translateY(0); }
- }
-</style>
-</head>
-<body>
-
-<!-- Landing -->
-<div id="screen-land" class="screen active">
- <div class="wordmark">drop.</div>
- <div class="tagline">anonymous · ephemeral · peer-to-peer</div>
- <div class="land-actions">
- <button class="btn btn-primary" onclick="App.createSession()">new session</button>
- <div class="divider">or join</div>
- </div>
- <div class="join-row">
- <input class="input-code" id="join-code" placeholder="XXXX-XXXX" maxlength="9"
- oninput="App.formatCode(this)"
- onkeydown="if(event.key==='Enter')App.joinSession()">
- <button class="btn btn-ghost" style="width:76px;flex-shrink:0" onclick="App.joinSession()">join</button>
- </div>
- <div class="land-footer">no logs · no storage · vanishes in 1h</div>
-</div>
-
-<!-- Waiting -->
-<div id="screen-wait" class="screen">
- <div class="pulse-ring"><div class="pulse-dot"></div></div>
- <div style="display:flex;flex-direction:column;align-items:center;gap:10px">
- <div class="wait-label">your session code</div>
- <div class="session-code-big" id="wait-code" onclick="App.copyCode()"></div>
- <div class="copy-hint">click to copy</div>
- </div>
- <div style="font-size:13px;color:var(--text-muted);font-weight:200;text-align:center;max-width:220px;line-height:1.7">
- share the code — waiting for the other peer
- </div>
- <button class="btn btn-ghost" style="width:140px" onclick="App.cancelSession()">cancel</button>
-</div>
-
-<!-- Chat -->
-<div id="screen-chat" class="screen">
- <div class="drop-overlay" id="drop-overlay"><div class="drop-hint">drop files to send</div></div>
- <div class="chat-header">
- <div class="chat-wordmark">drop.</div>
- <div class="chat-meta">
- <div class="session-pill" id="header-code" onclick="App.copyCode()" title="click to copy"></div>
- <div class="status-dot"></div>
- <button class="btn-leave" onclick="App.leaveSession()">leave</button>
- </div>
- </div>
- <div class="messages" id="messages"></div>
- <div class="transfer-bar" id="transfer-bar">
- <span id="transfer-label">sending…</span>
- <div class="transfer-track"><div class="transfer-fill" id="transfer-fill"></div></div>
- <span id="transfer-pct">0%</span>
- <button class="icon-btn transfer-cancel" onclick="App.cancelTransfer()" title="cancel transfer">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
- </button>
- </div>
- <div class="chat-input-area">
- <div class="input-row">
- <textarea class="msg-input" id="msg-input" rows="1" placeholder="message…"
- onkeydown="App.handleKey(event)" oninput="App.autoResize(this)"></textarea>
- <div class="input-actions">
- <input type="file" id="file-input" multiple onchange="App.sendFile(this)">
- <button class="icon-btn" onclick="document.getElementById('file-input').click()" title="attach file">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
- <path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
- </svg>
- </button>
- <button class="icon-btn send" onclick="App.sendMessage()" title="send">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
- <path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
- </svg>
- </button>
- </div>
- </div>
- </div>
-</div>
-
-<!-- Error -->
-<div id="screen-err" class="screen">
- <div class="err-code">:(</div>
- <div class="err-msg" id="err-msg">something went wrong</div>
- <button class="btn btn-ghost" style="width:140px;margin-top:16px" onclick="App.showScreen('land')">go back</button>
-</div>
-
-<div class="toast" id="toast"></div>
-
-<script>
-const App = (() => {
- // ── State ───────────────────────────────────
- let sessionId = null;
- let myPeerId = null;
- let remotePeerId = null;
- let isHost = false;
- let sseSource = null;
- let pc = null;
- let dc = null; // outgoing data channel (host creates it)
- let chunkSize = 16384;
- let _connectTimer = null;
-
- // File send state
- let fileQueue = [];
- let isSending = false;
- let sendAbortFlag = false;
- let sendResolve = null;
-
- // File receive state
- let incomingFile = null;
-
- const STUN_CFG = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] };
-
- // ── Screen ──────────────────────────────────
- function showScreen(id) {
- document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
- document.getElementById('screen-' + id).classList.add('active');
- }
-
- function showError(msg) {
- document.getElementById('err-msg').textContent = msg;
- showScreen('err');
- }
-
- // ── Utils ───────────────────────────────────
- function formatCode(el) {
- let v = el.value.replace(/[^A-Z0-9a-z]/g, '').toUpperCase();
- if (v.length > 4) v = v.slice(0, 4) + '-' + v.slice(4, 8);
- el.value = v;
- }
-
- function ts() {
- return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- }
-
- function fmtSize(bytes) {
- if (bytes < 1024) return bytes + ' B';
- if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
- if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
- return (bytes / 1073741824).toFixed(2) + ' GB';
- }
-
- function fmtSpeed(bps) {
- if (bps >= 1048576) return (bps / 1048576).toFixed(1) + ' MB/s';
- return Math.round(bps / 1024) + ' KB/s';
- }
-
- function fmtEta(secs) {
- if (secs >= 60) return Math.ceil(secs / 60) + 'm';
- return secs + 's';
- }
-
- function fileExt(name) {
- const p = name.lastIndexOf('.');
- return p >= 0 ? name.slice(p + 1).toUpperCase().slice(0, 4) : 'FILE';
- }
-
- function escHtml(s) {
- return s.replace(/&/g, '&').replace(/[<]/g, '<').replace(/>/g, '>').replace(/\\n/g, '<br>');
- }
-
- function toast(msg) {
- const el = document.getElementById('toast');
- el.textContent = msg;
- el.classList.add('show');
- clearTimeout(el._t);
- el._t = setTimeout(() => el.classList.remove('show'), 2000);
- }
-
- function copyCode() {
- if (!sessionId) return;
- navigator.clipboard.writeText(sessionId).then(() => toast('copied')).catch(() => toast(sessionId));
- }
-
- function autoResize(el) {
- el.style.height = 'auto';
- el.style.height = Math.min(el.scrollHeight, 120) + 'px';
- }
-
- function handleKey(e) {
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
- }
-
- // ── Messages UI ─────────────────────────────
- function addMessage(text, side, time) {
- const el = document.createElement('div');
- el.className = 'msg ' + side;
- el.innerHTML =
- '<div class="msg-bubble">' + escHtml(text) + '<\/div>' +
- '<div class="msg-time">' + (time || ts()) + '<\/div>';
- document.getElementById('messages').appendChild(el);
- scrollBottom();
- }
-
- function addSystem(text) {
- const el = document.createElement('div');
- el.className = 'msg system';
- el.innerHTML = '<div class="msg-bubble">' + text + '<\/div>';
- document.getElementById('messages').appendChild(el);
- scrollBottom();
- }
-
- function addFileMessage(side, meta) {
- const el = document.createElement('div');
- el.className = 'msg ' + side;
- const fid = 'file-' + Date.now() + '-' + Math.random().toString(36).slice(2);
- el.innerHTML =
- '<div class="msg-file" id="' + fid + '">' +
- '<div class="file-icon">' + fileExt(meta.name) + '<\/div>' +
- '<div class="file-info">' +
- '<div class="file-name">' + escHtml(meta.name) + '<\/div>' +
- '<div class="file-size">' + fmtSize(meta.size) + '<\/div>' +
- '<div class="file-progress"><div class="file-progress-bar" id="' + fid + '-bar"><\/div><\/div>' +
- '<\/div>' +
- '<\/div>' +
- '<div class="msg-time">' + ts() + '<\/div>';
- document.getElementById('messages').appendChild(el);
- scrollBottom();
- return fid;
- }
-
- function scrollBottom() {
- const m = document.getElementById('messages');
- m.scrollTop = m.scrollHeight;
- }
-
- // ── Create Session ───────────────────────────
- async function createSession() {
- try {
- const res = await fetch('/session', { method: 'POST' });
- const { sessionId: sid } = await res.json();
- sessionId = sid;
- isHost = true;
- history.replaceState(null, '', '?s=' + sid);
- document.getElementById('wait-code').textContent = sid;
- document.getElementById('header-code').textContent = sid;
- showScreen('wait');
- connectSSE();
- } catch (e) {
- showError('could not create session');
- }
- }
-
- // ── Join Session ─────────────────────────────
- async function joinSession() {
- const code = document.getElementById('join-code').value.trim().toUpperCase();
- if (code.length < 9) { toast('enter a valid code'); return; }
- try {
- const res = await fetch('/session/' + code + '/exists');
- const { exists } = await res.json();
- if (!exists) { toast('session not found'); return; }
- sessionId = code;
- isHost = false;
- history.replaceState(null, '', '?s=' + code);
- document.getElementById('header-code').textContent = code;
- connectSSE();
- } catch (e) {
- toast('could not reach server');
- }
- }
-
- function cancelSession() {
- cleanup(false);
- showScreen('land');
- }
-
- function leaveSession() {
- cleanup(true);
- showScreen('land');
- }
-
- function cancelTransfer() {
- if (!isSending) return;
- fileQueue = [];
- sendAbortFlag = true;
- if (sendResolve) { sendResolve(); sendResolve = null; }
- }
-
- function cleanup(notify) {
- if (notify && sessionId && myPeerId) {
- fetch('/leave/' + sessionId, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ peerId: myPeerId })
- }).catch(() => {});
- }
- cancelTransfer();
- incomingFile = null;
- if (_connectTimer) { clearTimeout(_connectTimer); _connectTimer = null; }
- if (dc) { try { dc.close(); } catch (_) {} dc = null; }
- if (pc) { try { pc.close(); } catch (_) {} pc = null; }
- if (sseSource) { sseSource.close(); sseSource = null; }
- sessionId = null; myPeerId = null; remotePeerId = null;
- history.replaceState(null, '', '/');
- document.getElementById('messages').innerHTML = '';
- document.getElementById('join-code').value = '';
- }
-
- // ── SSE ─────────────────────────────────────
- function connectSSE() {
- sseSource = new EventSource('/connect/' + sessionId);
-
- sseSource.addEventListener('welcome', e => {
- const d = JSON.parse(e.data);
- myPeerId = d.peerId;
- if (d.chunkSize) chunkSize = d.chunkSize;
- if (!isHost) {
- showScreen('chat');
- addSystem('waiting for secure channel…');
- }
- });
-
- sseSource.addEventListener('peer-joined', e => {
- const d = JSON.parse(e.data);
- remotePeerId = d.peerId;
- if (isHost) {
- showScreen('chat');
- addSystem('peer connected — establishing secure channel…');
- startWebRTC();
- }
- });
-
- sseSource.addEventListener('peer-left', () => {
- addSystem('peer disconnected');
- cancelTransfer();
- incomingFile = null;
- remotePeerId = null;
- if (dc) { try { dc.close(); } catch (_) {} dc = null; }
- if (pc) { try { pc.close(); } catch (_) {} pc = null; }
- });
-
- sseSource.addEventListener('signal', async e => {
- const d = JSON.parse(e.data);
- if (!remotePeerId) remotePeerId = d.fromPeerId;
- await handleSignal(d.type, d.payload);
- });
-
- sseSource.onerror = () => {
- if (sseSource.readyState === EventSource.CLOSED) {
- cleanup(false);
- showError('connection lost — please refresh');
- }
- };
- }
-
- // ── WebRTC ───────────────────────────────────
- function createPC() {
- pc = new RTCPeerConnection(STUN_CFG);
-
- _connectTimer = setTimeout(() => {
- if (pc && pc.connectionState !== 'connected') {
- addSystem('direct connection timed out — this app uses STUN only and does not support TURN relay servers. both peers must be on reachable networks.');
- }
- }, 20000);
-
- pc.onicecandidate = e => {
- if (e.candidate) signal('ice', e.candidate);
- };
-
- pc.ondatachannel = e => {
- setupChannel(e.channel);
- };
-
- pc.onconnectionstatechange = () => {
- if (!pc) return;
- if (pc.connectionState === 'connected') {
- clearTimeout(_connectTimer); _connectTimer = null;
- addSystem('secure channel open · end-to-end p2p');
- } else if (pc.connectionState === 'failed') {
- clearTimeout(_connectTimer); _connectTimer = null;
- addSystem('direct connection failed — this app uses STUN only, no TURN relay support. try a different network.');
- } else if (pc.connectionState === 'disconnected') {
- addSystem('connection interrupted');
- }
- };
- }
-
- async function startWebRTC() {
- createPC();
- dc = pc.createDataChannel('drop', { ordered: true });
- setupChannel(dc);
- const offer = await pc.createOffer();
- await pc.setLocalDescription(offer);
- signal('offer', offer);
- }
-
- async function handleSignal(type, payload) {
- if (type === 'offer') {
- if (!pc) createPC();
- await pc.setRemoteDescription(new RTCSessionDescription(payload));
- const answer = await pc.createAnswer();
- await pc.setLocalDescription(answer);
- signal('answer', answer);
- } else if (type === 'answer') {
- await pc.setRemoteDescription(new RTCSessionDescription(payload));
- } else if (type === 'ice') {
- try { await pc.addIceCandidate(new RTCIceCandidate(payload)); } catch (_) {}
- }
- }
-
- function signal(type, payload) {
- fetch('/signal/' + sessionId, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ fromPeerId: myPeerId, toPeerId: remotePeerId, type, payload })
- }).catch(() => {});
- }
-
- // ── Data Channel ─────────────────────────────
- function setupChannel(chan) {
- chan.binaryType = 'arraybuffer';
- if (!dc) dc = chan; // receiver side stores it too
-
- chan.onopen = () => {};
- chan.onclose = () => {};
-
- chan.onmessage = e => {
- if (typeof e.data === 'string') {
- const msg = JSON.parse(e.data);
- if (msg.type === 'chat') {
- addMessage(msg.text, 'theirs', msg.time);
- } else if (msg.type === 'file-meta') {
- incomingFile = { name: msg.name, size: msg.size, mime: msg.mime || 'application/octet-stream', chunks: [], received: 0 };
- incomingFile.fid = addFileMessage('theirs', { name: msg.name, size: msg.size });
- } else if (msg.type === 'file-done') {
- if (incomingFile) finaliseIncoming();
- }
- } else {
- if (incomingFile) receiveChunk(e.data);
- }
- };
- }
-
- function receiveChunk(data) {
- incomingFile.chunks.push(data);
- incomingFile.received += data.byteLength;
- const pct = Math.round((incomingFile.received / incomingFile.size) * 100);
- const bar = document.getElementById(incomingFile.fid + '-bar');
- if (bar) bar.style.width = pct + '%';
- }
-
- function finaliseIncoming() {
- const blob = new Blob(incomingFile.chunks, { type: incomingFile.mime });
- const url = URL.createObjectURL(blob);
- const name = incomingFile.name;
- const fileEl = document.getElementById(incomingFile.fid);
- if (fileEl) {
- fileEl.onclick = () => { const a = document.createElement('a'); a.href = url; a.download = name; a.click(); };
- const bar = document.getElementById(incomingFile.fid + '-bar');
- if (bar) bar.style.width = '100%';
- }
- addSystem(escHtml(name) + ' received — click to save');
- incomingFile = null;
- }
-
- // ── Send Message ─────────────────────────────
- function sendMessage() {
- const input = document.getElementById('msg-input');
- const text = input.value.trim();
- if (!text) return;
- if (!dc || dc.readyState !== 'open') { toast('not connected yet'); return; }
- dc.send(JSON.stringify({ type: 'chat', text, time: ts() }));
- addMessage(text, 'mine');
- input.value = '';
- input.style.height = 'auto';
- }
-
- // ── Send File ─────────────────────────────────
- function sendFile(input) {
- const files = Array.from(input.files);
- input.value = '';
- queueFiles(files);
- }
-
- function queueFiles(files) {
- if (!dc || dc.readyState !== 'open') { toast('not connected yet'); return; }
- fileQueue.push(...files);
- if (!isSending) drainQueue();
- }
-
- async function drainQueue() {
- isSending = true;
- while (fileQueue.length > 0) {
- sendAbortFlag = false;
- await sendOneFile(fileQueue.shift());
- if (sendAbortFlag) break;
- }
- isSending = false;
- sendAbortFlag = false;
- document.getElementById('transfer-bar').classList.remove('active');
- document.getElementById('transfer-fill').style.width = '0%';
- document.getElementById('transfer-pct').textContent = '0%';
- document.getElementById('transfer-label').textContent = 'sending…';
- }
-
- async function sendOneFile(file) {
- const fid = addFileMessage('mine', { name: file.name, size: file.size });
- dc.send(JSON.stringify({ type: 'file-meta', name: file.name, size: file.size, mime: file.type || 'application/octet-stream' }));
-
- const tBar = document.getElementById('transfer-bar');
- const tFill = document.getElementById('transfer-fill');
- const tPct = document.getElementById('transfer-pct');
- const tLabel = document.getElementById('transfer-label');
- tBar.classList.add('active');
- tLabel.textContent = file.name;
- tFill.style.width = '0%';
-
- let offset = 0;
- const total = file.size;
- const startTime = Date.now();
-
- while (offset < total && !sendAbortFlag) {
- if (dc.bufferedAmount > chunkSize * 16) {
- dc.bufferedAmountLowThreshold = chunkSize * 4;
- await new Promise(resolve => { sendResolve = resolve; dc.onbufferedamountlow = resolve; });
- sendResolve = null;
- dc.onbufferedamountlow = null;
- }
- if (sendAbortFlag || !dc || dc.readyState !== 'open') break;
- const end = Math.min(offset + chunkSize, total);
- const chunk = await file.slice(offset, end).arrayBuffer();
- if (sendAbortFlag) break;
- dc.send(chunk);
- offset = end;
-
- const p = Math.round((offset / total) * 100);
- const elapsed = (Date.now() - startTime) / 1000;
- const speed = elapsed > 0.5 ? offset / elapsed : 0;
- const eta = speed > 0 && offset < total ? Math.ceil((total - offset) / speed) : 0;
- tFill.style.width = p + '%';
- tPct.textContent = p + '%';
- tLabel.textContent = file.name
- + (speed > 0 ? ' · ' + fmtSpeed(speed) : '')
- + (eta > 0 ? ' · ' + fmtEta(eta) : '');
- const localBar = document.getElementById(fid + '-bar');
- if (localBar) localBar.style.width = p + '%';
- }
-
- if (!sendAbortFlag && dc && dc.readyState === 'open') {
- dc.send(JSON.stringify({ type: 'file-done' }));
- addSystem(escHtml(file.name) + ' sent');
- }
- }
-
- // ── Drag-drop + Paste ────────────────────────
- function setupListeners() {
- const chatEl = document.getElementById('screen-chat');
-
- chatEl.addEventListener('dragenter', e => {
- e.preventDefault();
- if (dc && dc.readyState === 'open') document.getElementById('drop-overlay').classList.add('active');
- });
- chatEl.addEventListener('dragover', e => { e.preventDefault(); });
- chatEl.addEventListener('dragleave', e => {
- if (!chatEl.contains(e.relatedTarget)) document.getElementById('drop-overlay').classList.remove('active');
- });
- chatEl.addEventListener('drop', e => {
- e.preventDefault();
- document.getElementById('drop-overlay').classList.remove('active');
- const files = Array.from(e.dataTransfer.files);
- if (files.length) queueFiles(files);
- });
-
- document.addEventListener('paste', e => {
- if (!dc || dc.readyState !== 'open') return;
- const files = Array.from(e.clipboardData.files);
- if (files.length) queueFiles(files);
- });
- }
-
- setupListeners();
-
- // ── Public API ───────────────────────────────
- return { showScreen, createSession, joinSession, cancelSession, leaveSession, copyCode, formatCode, handleKey, autoResize, sendMessage, sendFile, queueFiles, cancelTransfer };
-})();
-
-// Deep-link support: ?s=XXXX-XXXX
-const params = new URLSearchParams(location.search);
-const urlSession = params.get('s');
-if (urlSession) {
- document.getElementById('join-code').value = urlSession.toUpperCase().slice(0, 9);
- App.joinSession();
-}
-</script>
-</body>
-</html>`;