]> git.morado.dev Git - env.morado.dev/commitdiff
Initial commit
authorRoberto Morado <roramigator@duck.com>
Wed, 20 May 2026 20:11:18 +0000 (16:11 -0400)
committerRoberto Morado <roramigator@duck.com>
Wed, 20 May 2026 20:11:18 +0000 (16:11 -0400)
.claude/settings.local.json [new file with mode: 0644]
p2p.ts [new file with mode: 0644]

diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644 (file)
index 0000000..545fbdb
--- /dev/null
@@ -0,0 +1,8 @@
+{
+  "permissions": {
+    "allow": [
+      "Bash(awk 'NR>=836 && NR<=1230' /Users/roramigator/p2p/p2p.ts)",
+      "Bash(awk 'NR>=122 && NR<=165' /Users/roramigator/p2p/p2p.ts)"
+    ]
+  }
+}
diff --git a/p2p.ts b/p2p.ts
new file mode 100644 (file)
index 0000000..e19c87b
--- /dev/null
+++ b/p2p.ts
@@ -0,0 +1,1388 @@
+// ─────────────────────────────────────────────
+//  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, '&amp;').replace(/[<]/g, '&lt;').replace(/>/g, '&gt;').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>`;