Peer-to-peer WebRTC file transfer. Brand is Warp (user-facing), matching the repo Ishannaik/warp. The Cloudflare Pages project is still named wrap internally — deploy uses --project-name=wrap and its Pages host is wrap-3qq.pages.dev (do not rename it). Live at https://warp.ishannaik.com and https://warp.pixalabs.net.
- Mobile-first, always. Every UI must work at ~360–430px with zero horizontal overflow. Branch layout with
useIsMobile()(web/src/lib/useIsMobile.ts). The design was ported from a desktop export, so any new component MUST add the mobile branch. Verify at phone width before shipping. - $0 and no credit card. No paid infrastructure. The canonical production deployment stays on Cloudflare free (Pages + Workers + Durable Objects), with no card on the account so nothing can ever bill. Optional frontend self-host targets documented in
docs/SELF-HOSTING.mdare allowed only when their current $0/no-card status has been verified; they must not replace the canonical Cloudflare deploy or move signaling/file bytes onto a paid relay. No TURN relay by default (STUN-only) — restrictive/cross-network NAT gets an honest error, never a silent paid relay. If a relay is ever added it must be unmetered/no-bill (self-hosted coturn, or CF TURN with no card). - Verify pricing before recommending any host. Check the provider's current free tier + "credit card" against live sources first. (We got burned assuming Koyeb/Fly were free; both now need a card.)
- The signaling server is a dumb relay. It brokers the WebRTC handshake and never sees, stores, or understands a file byte. Keep it that way.
web/— Vite + React 19 + TypeScript + Tailwind v4 (@tailwindcss/vite, CSS-first@theme). pnpm workspace@warp/web. Client router insrc/router.tsx(navigate,useRoute). Routes:/landing,/send+/r/:codetransfer flow,/receivecode entry,/howtheory.server/— Cloudflare Worker + Durable Object signaling server (src/index.js), WebSocket Hibernation,@warp/server. One DO viaidFromName('global')holds all rooms; room state lives in the live sockets'serializeAttachment(hibernation-safe).web/src/lib/warp/— the WebRTC engine:signaling.ts(WS client),peer.ts(RTCPeerConnection+ data channel, STUN-only),transfer.ts(wire protocol),receiveController.ts(durable-write receive sink),useWarpTransfer.ts(orchestration). (Dir + identifiers renamedwrap→warpfor brand consistency; the Cloudflare Pages project stayswrap.)
bg #121110, ink #efe9da, body #a8a293, muted #6f6a5d, accent var(--acc) #5360ff, amber var(--amb) #ef6a3d. Fonts: Bricolage Grotesque (display), Archivo (body), JetBrains Mono (mono/UI chrome). Hairlines rgba(239,233,218,.12–.16). Components are inline-style-heavy (faithful design port) — keep that style; use CSS vars for accent.
client→server: {type:'join'} (server mints the code) · {type:'join',room} · {type:'signal',to,data} · {type:'ping'} (keepalive).
server→client: {type:'joined',selfId,room,peers[]} · {type:'peer-joined'} · {type:'peer-left'} · {type:'signal',from,data} · {type:'error',error}.
SEND_HIGH_WATER(peer.ts) must stay well BELOW 16 MiB. Don't conflate the three SCTP size limits (docs/research-2026-07.md§1): the 64 KiB defaultmaxMessageSize(RFC 8841 §6.1), the 256 KiB SCTP association send buffer (usrsctp/Chromium default), and the 16 MiBbufferedAmountsend-queue cap (libwebrtcMaxSendQueueSize; Blink'ssend()throwsOperationErrorabove it). Backpressure keys off the last one:bufferedAmountcan never reach 16 MiB becausesend()throws first, so a ≥16 MiB high-water mark disables backpressure entirely and every large transfer dies mid-send (~40% of a 50 MB file). It's 8 MiB now; the check also counts the chunk about to be sent. (256 KiB messages intentionally exceed RFC 8831 §6.6's 16 KB SHOULD — fine for a single bulk stream, still capped to the peer's negotiated limit.)- Transfers must survive drops. Signaling auto-reconnects + rejoins; ICE restarts (initiator-only);
peer-leftkeeps a peer whose channel is still open; a dead peer is salvaged (unfinished send files auto re-offered on reconnect). Don't turn transient drops back into terminal errors. - Piece manifest (#137) verifies each piece, not just the byte count. For a fresh file in the size window (
shouldManifestinpieceManifest.ts: ≥1 MiB, ≤2 GiB), the sender rides a per-piece SHA-256 manifest infile-begin; the receiver feeds plaintext through aPieceVerifierand appends only VERIFIED pieces to the sink, re-requesting a bad index viapiece-request/piece(one piece, never a full resend). Tiny/huge files and resumed receives keep the legacy trust-the-stream path. The manifest frame must stay under the negotiatedmaxMessageSize, sochoosePieceSizegrows the piece size to cap the hash count atMAX_PIECES(512). The channel is reliable+DTLS, so this targets storage/implementation corruption, not an adversarial wire. - Never mint room codes on the client. The server owns codes (6 chars,
^[A-HJ-KM-NP-Z2-9]{6}$). The sender connects with NO room and usesjoined.room. (A localWRAP-…code got rejected asbad-roomand broke every transfer.) - Keep the keepalive ping (every 8s). Without it the DO hibernates after 10s idle and drops a waiting room before the peer joins. Don't remove it.
pnpm install
pnpm dev # web on :5173
pnpm dev:server # wrangler dev on :8787
pnpm --filter @warp/web build # always lint + typecheck + build before deploy
pnpm --filter @warp/server test # signaling e2e
# deploy (Cloudflare) — a stale CLOUDFLARE_API_TOKEN in the env 403s; strip it so
# wrangler uses the OAuth login (which auto-refreshes): prefix with `env -u CLOUDFLARE_API_TOKEN`
cd web && env -u CLOUDFLARE_API_TOKEN wrangler pages deploy dist --project-name=wrap --branch=main --commit-dirty=true
pnpm --filter @warp/server run deploy # `run` avoids pnpm's `deploy` builtinCustom domains: both ishannaik.com and pixalabs.net use Cloudflare DNS. warp in each zone is a proxied CNAME → wrap-3qq.pages.dev, and both hosts are attached to the wrap Pages project. (Until 2026-10-10 warp.ishannaik.com pointed at a stale Vercel deploy and warp.pixalabs.net had no record, so Pages deploys never reached users. After a deploy, confirm the live domain serves the same assets/index-*.js name as your local web/dist/index.html; a server: cloudflare header alone doesn't prove which build is live.) pixalabs.net also carries live Zoho email — edit single records via the API, never bulk-replace the zone.
Post-merge CI: Travis runs build → (opt-in) deploy → live Playwright against the real domain, plus BrowserStack when BROWSERSTACK_USERNAME is set in Travis. See docs/CI.md.