diff --git a/docs/superpowers/plans/2026-08-01-p2p-file-transfer.md b/docs/superpowers/plans/2026-08-01-p2p-file-transfer.md new file mode 100644 index 0000000..e997819 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-p2p-file-transfer.md @@ -0,0 +1,33 @@ +# P2P File Transfer — Implementation Plan + +Spec: `docs/superpowers/specs/2026-08-01-p2p-file-transfer-design.md`. TDD on pure libs; +server + browser wiring smoke-tested on staging before production. + +## Task 1 — `src/tools/webrtc/signal.lib.ts` (pure) + tests +`SignalRole`, `SignalMessage`, `makeRoomId`, `roomLink`, `roomIdFromHash`, `parseSignal`. + +## Task 2 — `src/tools/webrtc/file-transfer.lib.ts` (pure) + tests +`CHUNK_SIZE`, `TransferMeta`, `chunkCount`, `chunkRange`, `formatBytes`, `percent`, +`encodeMeta`/`decodeMeta`. + +## Task 3 — Server signaling +- `worker/signal-room.js` — `SignalRoom` Durable Object (hibernation WS, 2-peer relay). +- `worker/index.js` — route `/api/signal/*`, re-export `SignalRoom`. +- `wrangler.jsonc` — DO binding + `new_sqlite_classes` migration (top level + staging env). + +## Task 4 — Client transport (browser, smoke) +- `signal-client.ts` (WebSocket wrapper), `peer.ts` (RTCPeerConnection + STUN + negotiation). + +## Task 5 — `useFileTransfer` hook (light tests on pure transitions) +State machine + data-channel send/receive with backpressure; resource cleanup. + +## Task 6 — Island `FileTransfer.tsx` +Ack gate → sender (room link)/receiver (from `#hash`) → connect → transfer + progress → +download. + +## Task 7 — Register + verify +Registry (`file-transfer`, Files, `Send`, beta). `vitest`/`lint`/`build` green. + +## Task 8 — Ship dev → staging → verify DO → promote prod +PR to develop; after staging deploy, run a Node 2-WebSocket signaling smoke test against +`goodwebtools-staging.workers.dev`; only then promote to main. Verify live URL. diff --git a/docs/superpowers/specs/2026-08-01-p2p-file-transfer-design.md b/docs/superpowers/specs/2026-08-01-p2p-file-transfer-design.md new file mode 100644 index 0000000..30ecac7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-p2p-file-transfer-design.md @@ -0,0 +1,153 @@ +# P2P File Transfer + WebRTC Signaling — Design + +**Date:** 2026-08-01 +**Tool:** Files → P2P File Transfer (`/tools/file-transfer`) — NEW +**Type:** New tool + first server-side component (Durable Object signaling) +**Icon:** `Send` (lucide-react) +**Category:** Files + +This is item 6 of the batch, built **first** because it is the simpler of the two WebRTC +tools (data channel only). It establishes the **shared signaling + peer-connection +infrastructure** that the Video Call (item 5) will reuse. + +## Problem + +Users want to send a file directly to another person's device (like relay.rishishah.in), +peer-to-peer, without the file passing through a server. Browsers can do this with WebRTC +data channels, but two peers must first exchange a small connection handshake — which needs +a **signaling** rendezvous. + +## Goal + +- **File bytes travel peer-to-peer** (WebRTC data channel) and never touch our server. +- A tiny **signaling server** (Cloudflare Durable Object on the existing Worker) relays only + the ~2KB SDP/ICE handshake between the two peers in a room. +- **Mandatory ack before connecting:** the user must acknowledge that connecting uses our + signaling server to introduce the two devices, before any WebSocket opens. +- **STUN-only** (public STUN servers). No TURN → if both peers are behind strict/symmetric + NAT, show a clear "couldn't connect on this network" message. + +## Architecture + +``` +Sender browser ──WS──┐ ┌──WS── Receiver browser + ▼ ▼ + Cloudflare Worker (worker/index.js) + │ /api/signal/ (WebSocket upgrade) + ▼ + Durable Object SignalRoom (relays offer/answer/ICE only) + +Sender ───────────── WebRTC DataChannel (file bytes, P2P) ───────────── Receiver +``` + +### Server (Cloudflare Worker + Durable Object) + +- **`worker/signal-room.js`** — `export class SignalRoom extends DurableObject` + (from `cloudflare:workers`), WebSocket **Hibernation API**: + - `fetch(request)`: on `Upgrade: websocket`, create a `WebSocketPair`, `ctx.acceptWebSocket(server)`. + - Room capacity **2**. If already 2 sockets → `server.send({type:'full'})` then + `close(4001,'full')`. + - First socket → role `host`; second → role `guest`. Send each a + `{type:'welcome', role}` message; when the guest joins, also send the host + `{type:'peer-joined'}` so it starts the WebRTC offer. + - `webSocketMessage(ws, msg)`: **relay** — forward `msg` verbatim to every *other* socket + in `ctx.getWebSockets()`. (This carries `offer` / `answer` / `ice`.) + - `webSocketClose(ws, ...)`: notify the remaining peer `{type:'peer-left'}`. +- **`worker/index.js`** — add, before the `/models/` branch: + ```js + if (url.pathname.startsWith('/api/signal/')) { + if (request.headers.get('Upgrade') !== 'websocket') + return new Response('Expected WebSocket', { status: 426 }); + const roomId = url.pathname.slice('/api/signal/'.length); + if (!/^[a-z0-9]{6,32}$/.test(roomId)) return new Response('Bad room', { status: 400 }); + return env.SIGNAL.getByName(roomId).fetch(request); + } + ``` + Re-export the class: `export { SignalRoom } from './signal-room.js';` +- **`wrangler.jsonc`** — add to BOTH top level and `env.staging` (named envs don't inherit): + ```jsonc + "durable_objects": { "bindings": [ { "name": "SIGNAL", "class_name": "SignalRoom" } ] }, + "migrations": [ { "tag": "v1", "new_sqlite_classes": ["SignalRoom"] } ] + ``` + `new_sqlite_classes` = the free-tier SQLite-backed Durable Object (no extra cost). + +### Client libraries (`src/tools/webrtc/`) + +- **`signal.lib.ts`** (pure, tested): + - `type SignalRole = 'host' | 'guest'` + - `type SignalMessage` — welcome / peer-joined / peer-left / full / offer / answer / ice. + - `makeRoomId(): string` — 10 lowercase-alnum chars from `crypto.getRandomValues`. + - `roomLink(origin: string, roomId: string): string` → `${origin}/tools/file-transfer#${roomId}`. + - `roomIdFromHash(hash: string): string | null` — parse `#` (validates charset). + - `parseSignal(raw: string): SignalMessage | null` — safe JSON parse + shape guard. +- **`file-transfer.lib.ts`** (pure, tested): + - `CHUNK_SIZE = 16 * 1024`. + - `interface TransferMeta { name: string; size: number; mime: string }` + - `chunkCount(size, chunkSize?): number`; `chunkRange(index, size, chunkSize?): [start, end]`. + - `formatBytes(n): string`; `percent(done, total): number`. + - `encodeMeta(meta)/decodeMeta(raw)` for the JSON control message that precedes the bytes. +- **`signal-client.ts`** (browser, smoke): thin `WebSocket` wrapper — connect, `send(msg)`, + `onMessage`, `onClose`; JSON via `signal.lib`. +- **`peer.ts`** (browser, smoke): `createPeer({ initiator, onState, onChannel, sendSignal })` + wrapping `RTCPeerConnection` with public STUN + (`stun:stun.l.google.com:19302`, `stun:stun.cloudflare.com:3478`). Perfect-negotiation-lite: + host creates the data channel + offer on `peer-joined`; guest answers. ICE candidates + flow through `sendSignal`. Exposes `applySignal(msg)`. + +### Hook (`src/hooks/useFileTransfer.ts`, browser, light tests for pure transitions) + +Orchestrates signal-client + peer + data-channel transfer. State machine: +`idle → connecting → waiting-for-peer → connected → transferring → done | error`. +- Sender: on `connected`, user picks a file → send `TransferMeta` (JSON) then chunks with + **backpressure** (`bufferedAmountLowThreshold`, pause when `bufferedAmount` high) → + progress → `done`. +- Receiver: read `TransferMeta`, accumulate `ArrayBuffer` chunks until `size` reached → + assemble `Blob` → expose for download. +- Releases the peer, data channel, and socket on unmount / reset. + +### Island (`src/islands/files/FileTransfer.tsx`, thin) + +1. **Ack gate (required):** first render shows a notice — + > "To connect two devices, GoodWebTools uses a small signaling server to exchange + > connection details (~2KB). Your files transfer **directly, peer-to-peer**, and never + > pass through our server. Connections are best-effort and may fail on very restrictive + > networks." + with a **Continue** button. Nothing connects until Continue is clicked. +2. **Role:** if the URL has `#` → **receiver** (auto-join after ack). Else → + **sender**: generate a room, show the **shareable link** + Copy button + "waiting for the + other device…". +3. On `connected`: sender gets a Dropzone/file picker; on send, a progress bar (bytes + + %). Receiver shows the incoming filename/size + progress, then a Download button + (`downloadService`). +4. Connection failures (ICE failed / peer-left / full room) → `Alert` with the specific + reason. +5. Uses `ProgressBar` (determinate) for transfer progress; plain text for "connecting". + +## Testing + +- `signal.lib.test.ts` — `makeRoomId` (length/charset, two calls differ); `roomLink`; + `roomIdFromHash` (valid `#abc123`, rejects junk/empty); `parseSignal` (valid types, + rejects malformed JSON and unknown shapes). +- `file-transfer.lib.test.ts` — `chunkCount` (exact multiple + remainder + zero); + `chunkRange` (first/middle/last clamps to size); `percent` (0/partial/100, clamps); + `formatBytes`; `encodeMeta`/`decodeMeta` round-trip + rejects bad input. +- `useFileTransfer.test.ts` — reducer/state-transition helper unit-tested with fakes where + practical (mock `RTCPeerConnection`/`WebSocket` minimally); the full media path is + **smoke on staging**. +- **Server/DO + real connection:** verified on **staging** by a Node script that opens two + WebSockets to `/api/signal/` and asserts the relay (welcome/peer-joined/echo of an + offer). This exercises the Durable Object end-to-end before promoting to production. + +## Deploy note (important) + +The Durable Object + migration deploy via the existing **Workers Builds** integration +(wrangler.jsonc). First deploy to **develop → staging** and run the signaling smoke test +against `goodwebtools-staging.workers.dev` BEFORE promoting to production. If the account +can't create DOs, the deploy fails cleanly (no user-facing harm) — surface it and stop. + +## Out of scope (this item) + +- Video call (item 5 — reuses `signal.lib`, `signal-client`, `peer`). +- TURN relay (STUN-only for now). +- Multiple files per transfer / folders (one file at a time; can send another after). +- Resumable transfers, end-to-end encryption beyond WebRTC's built-in DTLS. diff --git a/src/components/ToolGrid.tsx b/src/components/ToolGrid.tsx index 75c9326..c67a027 100644 --- a/src/components/ToolGrid.tsx +++ b/src/components/ToolGrid.tsx @@ -1,5 +1,5 @@ import { tools } from '@/registry/tools'; -import { categories, categoryColors } from '@/registry/categories'; +import { categories, categoryColors, categoryNotes } from '@/registry/categories'; /** * Static tool grid grouped by category. Rendered without a client directive so @@ -25,6 +25,9 @@ export function ToolGrid() { ({categoryTools.filter(tool => !tool.desktopOnly).length}) + {categoryNotes[category] && ( +

{categoryNotes[category]}

+ )}
{categoryTools.map(tool => { const Icon = tool.icon; diff --git a/src/hooks/useFileTransfer.ts b/src/hooks/useFileTransfer.ts new file mode 100644 index 0000000..554252b --- /dev/null +++ b/src/hooks/useFileTransfer.ts @@ -0,0 +1,239 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { connectSignal, type SignalClient } from '@/tools/webrtc/signal-client'; +import { createPeer, type PeerHandles } from '@/tools/webrtc/peer'; +import { createManualConnection, type ManualConnection } from '@/tools/webrtc/manual'; +import { + CHUNK_SIZE, + chunkCount, + chunkRange, + percent, + encodeMeta, + decodeMeta, + type TransferMeta, +} from '@/tools/webrtc/file-transfer.lib'; + +export type TransferStatus = + | 'idle' + | 'connecting' + | 'waiting' + | 'connected' + | 'transferring' + | 'done' + | 'error'; + +export type TransferMode = 'send' | 'receive'; + +const HIGH_WATER = 8 * 1024 * 1024; // pause sending above this bufferedAmount + +export function useFileTransfer() { + const signalRef = useRef(null); + const peerRef = useRef(null); + const manualRef = useRef(null); + const channelRef = useRef(null); + const modeRef = useRef('send'); + const iceRef = useRef(undefined); + const recvRef = useRef<{ meta: TransferMeta | null; chunks: ArrayBuffer[]; received: number }>({ + meta: null, + chunks: [], + received: 0, + }); + + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(''); + const [progress, setProgress] = useState(0); + const [incoming, setIncoming] = useState<{ name: string; size: number } | null>(null); + const [receivedBlob, setReceivedBlob] = useState(null); + + const cleanup = useCallback(() => { + channelRef.current?.close(); + peerRef.current?.close(); + manualRef.current?.close(); + signalRef.current?.close(); + channelRef.current = null; + peerRef.current = null; + manualRef.current = null; + signalRef.current = null; + recvRef.current = { meta: null, chunks: [], received: 0 }; + }, []); + + const handleState = useCallback((state: RTCPeerConnectionState) => { + if (state === 'failed' || state === 'disconnected') { + setError('Could not connect — the network may be too restrictive (no relay server).'); + setStatus('error'); + } + }, []); + + const handleRecv = useCallback((data: string | ArrayBuffer) => { + const r = recvRef.current; + if (typeof data === 'string') { + const meta = decodeMeta(data); + if (meta) { + r.meta = meta; + r.chunks = []; + r.received = 0; + setIncoming({ name: meta.name, size: meta.size }); + setStatus('transferring'); + setProgress(0); + } + return; + } + if (!r.meta) return; + r.chunks.push(data); + r.received += data.byteLength; + setProgress(percent(r.received, r.meta.size)); + if (r.received >= r.meta.size) { + setReceivedBlob(new Blob(r.chunks, { type: r.meta.mime || 'application/octet-stream' })); + setStatus('done'); + } + }, []); + + const wireChannel = useCallback((channel: RTCDataChannel) => { + channel.binaryType = 'arraybuffer'; + channelRef.current = channel; + if (modeRef.current === 'receive') { + channel.onmessage = e => handleRecv(e.data as string | ArrayBuffer); + } + channel.onopen = () => setStatus('connected'); + channel.onclose = () => { /* transfer completion is driven by byte count */ }; + // A channel arriving via ondatachannel can already be open, so onopen won't fire. + if (channel.readyState === 'open') setStatus('connected'); + }, [handleRecv]); + + const setupPeer = useCallback((initiator: boolean) => { + if (peerRef.current || !signalRef.current) return; + const signal = signalRef.current; + peerRef.current = createPeer({ + initiator, + iceServers: iceRef.current, + sendSignal: msg => signal.send(msg), + onState: handleState, + onChannel: wireChannel, + }); + }, [wireChannel, handleState]); + + // --- Automatic signaling (via our server) --- + const connect = useCallback((mode: TransferMode, roomId: string, iceServers?: RTCIceServer[]) => { + cleanup(); + modeRef.current = mode; + iceRef.current = iceServers; + setError(''); + setProgress(0); + setReceivedBlob(null); + setIncoming(null); + setStatus('connecting'); + + signalRef.current = connectSignal(roomId, { + onMessage: msg => { + switch (msg.type) { + case 'welcome': + if (msg.role === 'guest') setupPeer(false); + else setStatus('waiting'); + break; + case 'peer-joined': + setupPeer(true); + break; + case 'peer-left': + setError('The other device disconnected.'); + setStatus('error'); + break; + case 'full': + setError('This transfer room is already full.'); + setStatus('error'); + break; + case 'offer': + case 'answer': + case 'ice': + peerRef.current?.applySignal(msg); + break; + } + }, + onError: () => { setError('Signaling connection failed.'); setStatus('error'); }, + }); + }, [cleanup, setupPeer]); + + // --- Manual signaling (serverless copy-paste) --- + const manualCreateOffer = useCallback(async (iceServers?: RTCIceServer[]): Promise => { + cleanup(); + modeRef.current = 'send'; + setError(''); + setProgress(0); + setReceivedBlob(null); + setIncoming(null); + setStatus('connecting'); + const conn = createManualConnection({ initiator: true, iceServers, onState: handleState, onChannel: wireChannel }); + manualRef.current = conn; + const code = await conn.createOfferCode(); + setStatus('waiting'); + return code; + }, [cleanup, wireChannel, handleState]); + + const manualAcceptAnswer = useCallback(async (answerCode: string): Promise => { + await manualRef.current?.acceptAnswer(answerCode); + }, []); + + const manualAcceptOffer = useCallback(async (offerCode: string, iceServers?: RTCIceServer[]): Promise => { + cleanup(); + modeRef.current = 'receive'; + setError(''); + setProgress(0); + setReceivedBlob(null); + setIncoming(null); + setStatus('connecting'); + const conn = createManualConnection({ initiator: false, iceServers, onState: handleState, onChannel: wireChannel }); + manualRef.current = conn; + const answer = await conn.acceptOfferReturnAnswer(offerCode); + setStatus('waiting'); + return answer; + }, [cleanup, wireChannel, handleState]); + + const sendFile = useCallback(async (file: File) => { + const channel = channelRef.current; + if (!channel || channel.readyState !== 'open') return; + setStatus('transferring'); + setProgress(0); + channel.bufferedAmountLowThreshold = 1024 * 1024; + channel.send(encodeMeta({ name: file.name, size: file.size, mime: file.type || 'application/octet-stream' })); + + const total = chunkCount(file.size); + for (let i = 0; i < total; i++) { + const [start, end] = chunkRange(i, file.size); + const buf = await file.slice(start, end).arrayBuffer(); + channel.send(buf); + setProgress(percent(end, file.size)); + if (channel.bufferedAmount > HIGH_WATER) { + await new Promise(resolve => { + const onLow = () => { channel.removeEventListener('bufferedamountlow', onLow); resolve(); }; + channel.addEventListener('bufferedamountlow', onLow); + }); + } + } + setProgress(100); + setStatus('done'); + }, []); + + const reset = useCallback(() => { + cleanup(); + setStatus('idle'); + setError(''); + setProgress(0); + setIncoming(null); + setReceivedBlob(null); + }, [cleanup]); + + useEffect(() => () => cleanup(), [cleanup]); + + return { + status, + error, + progress, + incoming, + receivedBlob, + connect, + manualCreateOffer, + manualAcceptAnswer, + manualAcceptOffer, + sendFile, + reset, + CHUNK_SIZE, + }; +} diff --git a/src/islands/files/FileTransfer.tsx b/src/islands/files/FileTransfer.tsx new file mode 100644 index 0000000..a22c04a --- /dev/null +++ b/src/islands/files/FileTransfer.tsx @@ -0,0 +1,319 @@ +import { useEffect, useRef, useState } from 'react'; +import { Send, Download, ShieldCheck, Settings } from 'lucide-react'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { ProgressBar } from '@/components/ui/ProgressBar'; +import { CopyButton } from '@/components/ui/CopyButton'; +import { downloadService } from '@/services/download'; +import { useFileTransfer } from '@/hooks/useFileTransfer'; +import { makeRoomId, roomLink, roomIdFromHash } from '@/tools/webrtc/signal.lib'; +import { formatBytes } from '@/tools/webrtc/file-transfer.lib'; +import { effectiveIceServers } from '@/tools/webrtc/ice.lib'; + +type Signaling = 'auto' | 'manual'; +type ManualRole = 'send' | 'receive'; + +const ICE_KEY = 'gwt.webrtc.ice'; +const SIGNALING_KEY = 'gwt.webrtc.signaling'; + +export default function FileTransfer() { + const t = useFileTransfer(); + const [acked, setAcked] = useState(false); + const [signaling, setSignaling] = useState('auto'); + const [iceText, setIceText] = useState(''); + const [showAdvanced, setShowAdvanced] = useState(false); + + // Auto mode + const [joining, setJoining] = useState(false); // arrived via a shared link → receiver + const [roomId, setRoomId] = useState(''); + const [link, setLink] = useState(''); + + // Manual mode + const [manualRole, setManualRole] = useState(null); + const [offerCode, setOfferCode] = useState(''); + const [answerCode, setAnswerCode] = useState(''); + const [pastedAnswer, setPastedAnswer] = useState(''); + const [pastedOffer, setPastedOffer] = useState(''); + const [manualBusy, setManualBusy] = useState(false); + const [manualErr, setManualErr] = useState(''); + + const sentName = useRef(''); + + // Restore saved settings + decide role from the URL hash. + useEffect(() => { + try { + setIceText(localStorage.getItem(ICE_KEY) ?? ''); + const savedSig = localStorage.getItem(SIGNALING_KEY); + if (savedSig === 'manual' || savedSig === 'auto') setSignaling(savedSig); + } catch { /* ignore */ } + + const fromHash = roomIdFromHash(window.location.hash); + if (fromHash) { + setJoining(true); + setSignaling('auto'); // a shared link is always automatic signaling + setRoomId(fromHash); + } else { + const id = makeRoomId(); + setRoomId(id); + setLink(roomLink(window.location.origin, id)); + } + }, []); + + const persistIce = (text: string) => { + setIceText(text); + try { localStorage.setItem(ICE_KEY, text); } catch { /* ignore */ } + }; + const persistSignaling = (s: Signaling) => { + setSignaling(s); + try { localStorage.setItem(SIGNALING_KEY, s); } catch { /* ignore */ } + }; + + const ice = () => effectiveIceServers(iceText); + + const start = () => { + setAcked(true); + if (signaling === 'auto') { + t.connect(joining ? 'receive' : 'send', roomId, ice()); + } + // manual: wait for the user to pick a role + }; + + const onDrop = (files: File[]) => { + const f = files[0]; + if (f) { sentName.current = f.name; t.sendFile(f); } + }; + + const downloadReceived = () => { + if (t.receivedBlob && t.incoming) downloadService.download(t.receivedBlob, t.incoming.name); + }; + + // Manual: sender + const chooseSend = async () => { + setManualRole('send'); + setManualErr(''); + setManualBusy(true); + try { + setOfferCode(await t.manualCreateOffer(ice())); + } catch (e) { + setManualErr(e instanceof Error ? e.message : 'Could not create the offer.'); + } finally { + setManualBusy(false); + } + }; + const submitAnswer = async () => { + setManualErr(''); + try { await t.manualAcceptAnswer(pastedAnswer.trim()); } + catch (e) { setManualErr(e instanceof Error ? e.message : 'Invalid answer code.'); } + }; + + // Manual: receiver + const chooseReceive = () => { setManualRole('receive'); setManualErr(''); }; + const submitOffer = async () => { + setManualErr(''); + setManualBusy(true); + try { + setAnswerCode(await t.manualAcceptOffer(pastedOffer.trim(), ice())); + } catch (e) { + setManualErr(e instanceof Error ? e.message : 'Invalid offer code.'); + } finally { + setManualBusy(false); + } + }; + + const isSending = signaling === 'auto' ? !joining : manualRole === 'send'; + + // ---------- Ack + settings gate ---------- + if (!acked) { + return ( +
+
+

+ Before you connect +

+

+ {signaling === 'auto' ? ( + <>To introduce your two devices, GoodWebTools uses a small signaling server to + exchange connection details (about 2 KB). Your files transfer directly, + peer-to-peer, and never pass through our server. + ) : ( + <>Manual mode uses no server at all. You'll copy-paste a connection code to the + other person yourself. Files transfer directly, peer-to-peer. + )}{' '} + Connections are best-effort and may fail on restrictive networks unless you add your own TURN server. +

+ + + + {showAdvanced && ( +
+ {!joining && ( +
+ Connection method +
+ + +
+
+ )} +