ProjectPeer-to-peer Systems · 2026
SafeShare
Decentralized, end-to-end encrypted peer-to-peer file transfer over WebRTC.
- Next.js
- Electron
- Go
- Fastify
- WebRTC
- WebTorrent
Peer-to-peer Systems · Project
SafeShare moves a file straight from one machine to another over WebRTC, encrypted end to end, with no copy landing on a server in between. There is a browser client and a desktop client, and both speak to the same signaling service. The usual alternative is uploading the file somewhere and sending a link.
That alternative puts the entire payload on someone else’s disk for as long as their retention policy says, and often longer. I wanted the coordination to be central, because two peers do have to find each other, and the bytes not to be. Those are two different jobs, and most tools blur them.
pnpm dev:backend-fastifysignaling listening on :3001 pnpm dev:webweb client on http://localhost:3000# signaling coordinates, it never sees the payloadpeer b7f2 offer -> peer 4c19ice connected, datachannel openholiday-photos.zip streaming peer to peertransfer complete, signaling stored 0 bytesCoordination and transport
The monorepo draws that line and keeps it visible. apps/web is Next.js with shadcn/ui, apps/electron is Electron with React and Vite, and both live in one pnpm workspace beside a Go module in apps/backend-go. The signaling service helps two peers exchange an offer, an answer and ICE candidates, then steps out while the data channel carries the file. Nothing about the transfer passes through it once the connection is open. Each app carries its own README and .env.example, so the four surfaces can be run one at a time rather than as one lump.
Sending a file
apps/web/lib/webrtc.ts holds the whole client protocol. A peer connection is created against five Google STUN servers, with no TURN server configured anywhere in the repository. The sender calls createOffer, the receiver answers, and candidates are added as they arrive. Once the data channel opens, the sender pushes a JSON metadata frame with name, size and MIME type. It then reads the file in 16 KB slices through a FileReader and sends each one as an ArrayBuffer. Progress is the running offset over the file size, and a final complete frame closes it out. The receiver collects chunks into an array and assembles a File at the end, so a large transfer sits in its memory.
Two backends, two contracts
There are two signaling implementations, and they are not interchangeable. The Fastify service is the smaller one. POST /initiate opens a session, /update-torrent records what the client seeded, GET /session/:sessionId hands the receiver what it needs, and a WebSocket carries the rest. It is built around WebTorrent, with libsodium behind crypto_box_easy in utils/encryption.ts. The Go service is a different shape entirely: Gin routes, JWT middleware, sqlc queries over Postgres, Redis, RabbitMQ, goose migrations and Swagger generated from handler comments. Its signaling is explicit, moving through POST /webrtc/rooms, /rooms/:roomID/join and /signal, with a webrtc_sessions row tracking created, joined, connected, completed or error.
The relay path
The Go backend also carries something the tagline does not cover. internal/storage/ implements a store and forward route. POST /transfers opens an upload, chunks land at /transfers/:sessionID/chunks/:chunkIndex, and ChunkManager writes each one megabyte chunk into Redis under chunk:<transferID>:<index> with a 24 hour expiry. A recipient pulls it back with an access token at /transfers/:accessToken/download. MAX_FILE_SIZE defaults to 5 GB and FILE_EXPIRATION to 24 hours. The peer to peer path stores nothing. This one stores everything, for a day.
What this costs
- Writing the boundary twice was meant to make the contract explicit, and it did the opposite. The two services answer different routes and assume different clients, so treating them as alternatives is aspirational. That is what I would fix first.
- STUN with no TURN. Every transfer that succeeds is genuinely direct, and none can quietly become a relay. Peers behind symmetric NAT just fail, and the client has no fallback to offer them.
- Redis as the chunk store keeps the relay path short and gives expiry for free. It also holds whole files in server memory, which is the opposite of the property the project is named for.
- A
.envis committed underapps/backend-gowith a placeholder JWT secret, and the Makefile embeds a Postgres password in its migration targets. A first run becomes one command. It is also the kind of convenience that survives into a real deployment.
Running it
Two commands bring up a local pair.
pnpm dev:backend-fastify # signaling on 3001
pnpm dev:web # client on 3000
Two browser tabs are then enough to move a file. The Go backend runs from apps/backend-go, where make docker-up starts its compose dependencies and make run starts the service. The README says make up, which the Makefile does not define. make migrate-up applies the goose migrations and make swagger regenerates the API documentation. The Go module carries no _test.go files, so make test runs an empty suite. It is open source under MIT.
Next projectAgentic AI