p2p-portal-drop
v0.1.0
Published
Framework-agnostic, LAN-only peer-to-peer file-share Web SDK for portals. Pairs a browser with native apps (Android/desktop) via QR or a short code and transfers files directly over the browser's native WebRTC — bytes never touch a server.
Downloads
157
Maintainers
Readme
p2p-portal-drop
Framework-agnostic, LAN-only embeddable file-share Web SDK for third-party portals.
The SDK speaks the LocalSend signaling protocol over WebSocket and transfers files directly between devices using the browser's native WebRTC. File bytes travel peer to peer over the local network and never pass through the signaling server.
- Framework-agnostic — no runtime dependency on React, Vue, Svelte, or any UI library.
- No npm runtime dependencies — compression uses the browser-native Compression Streams API; crypto uses Web Crypto.
- Typed end to end — ships complete TypeScript definitions for the whole public surface.
- LAN-only by design — host-only ICE (
{ iceServers: [] }); no STUN, no TURN, no relays. - Streaming — received files stream straight to storage, so transfers can exceed tab memory.
Install
npm install p2p-portal-dropRequires a secure browser context (https:// or localhost) for Web Crypto and WebRTC.
Quick start
import { createFileShareClient } from 'p2p-portal-drop';
const client = createFileShareClient();
// Observe the typed transfer lifecycle (Requirement 8.6).
client.on('request-received', (e) => {
// Prompt the user, then accept or reject by transfer id.
client.acceptTransfer(e.transferId);
});
client.on('progress', (e) => console.log(`${e.bytesTransferred}/${e.totalBytes}`));
client.on('completed', (e) => console.log('done', e.transferId));
client.on('failed', (e) => console.warn('failed', e.transferId, e.reason));
// Join a private session from a scanned QR payload.
const session = await client.joinSession({
signalingUrl: 'wss://10.0.0.5:8080',
sessionId: '<from-qr>',
pairingToken: '<from-qr>',
clientInfo: { alias: 'My Portal', version: '2.3', token: 'stable-merge-token' },
onPeersChanged: (peers) => render(peers), // optional peer-list observer
});
// Send files to the paired peer.
const handle = await client.sendTransferRequest([
{ fileName: 'photo.jpg', fileType: 'image/jpeg', data: fileBlob },
]);
console.log('sending', handle.transferId);Public API
createFileShareClient(options?) returns a FileShareClient:
| Method | Description |
| --- | --- |
| joinSession(opts) | Connect to a private session within the configured timeout; resolves with a SessionHandle. |
| sendTransferRequest(files) | Propose a transfer to the paired peer; resolves with a TransferHandle. |
| acceptTransfer(transferId) | Accept an incoming request (all proposed files). |
| rejectTransfer(transferId) | Decline an incoming request; nothing is written. |
| on(event, handler) | Subscribe to a typed lifecycle event; returns an unsubscribe function. |
Events: request-received, progress, completed, failed, connection-error, capability-error. Every event carries its type; transfer-scoped events carry a transferId.
SessionHandle exposes sessionId, peers() (a metadata-bearing snapshot), and leave().
Creating vs joining a session
The device that displays the QR creates the session; the device that scans it joins. Set create: true in joinSession on the creating side — it registers the derived token validator with the server (the raw token never leaves the device) and joins as the first peer. The scanning side leaves create unset.
// Creator (shows the QR)
await client.joinSession({ ...opts, create: true });
// Joiner (scanned the QR)
await client.joinSession({ ...opts });Configuration
createFileShareClient({
credentials, // HandshakeCredentials — see "Security" below
storage, // FileStorage sink for received files (default: browser download)
capabilityEnvironment, // override the probed browser globals (testing)
createSignalingClient, // inject a signaling-client factory (testing)
dataChannelLabel, // default 'localsend'
generateTransferId, // deterministic ids for tests
});For files larger than tab memory, pass createDirectoryFileStorage(dirHandle) (File System Access API) instead of the default download sink.
Architecture
QR payload ──► joinSession ──► SignalingClient (WebSocket, /v1/ws)
│ HELLO / JOIN / UPDATE / LEFT / OFFER / ANSWER
▼
host-only RTCPeerConnection ◄── SDP codec (zlib + base64url)
│ RTCDataChannel
▼
handshake (nonce → signed token) ──► gated file transfer
│
▼
streaming FileStorage sinkThe SDK re-implements the LocalSend data-channel sequence (nonce → token → optional PIN → file list → file-token map → per-file header + chunked binary) so it interoperates with the native Rust clients. No file byte is sent before the nonce and signed-token exchange complete.
Security model
- LAN-only: ICE is host-only; connectivity uses local-interface candidates with no external servers.
- Pairing secret stays local: the QR carries a pairing token; the SDK derives a one-way
HMAC-SHA256validator and sends only that to the server — never the raw token. - Handshake gating: byte transfer is gated behind the nonce + signed-token exchange.
Production handshake credentials. The default
HandshakeCredentialsis a deterministic, non-cryptographic token that only satisfies the handshake's byte-sequencing so two SDK (browser) peers complete a transfer. For real signature verification and interoperability with native clients, inject the built-in Ed25519 credentials (Web Crypto), which produce and verify the exactcrypto::tokenHASH.SIGNtoken the Rust core expects:import { createFileShareClient, createTokenCredentials, generateSigningKeyPair } from 'p2p-portal-drop'; const keyPair = await generateSigningKeyPair(); // Ed25519 const client = createFileShareClient({ credentials: createTokenCredentials({ keyPair, expectedPeerPublicKey, // the paired peer's public key (out-of-band); enables verification }), });A failed signature terminates the transfer with zero file bytes. Ed25519 Web Crypto requires Node 20+, Chrome 137+, Safari 17+, or Firefox 129+.
Demo Portal
A runnable reference portal lives in demo/. It embeds this SDK to
demonstrate QR pairing, the live peer list, bidirectional transfer, and the
event-driven status log. See demo/README.md.
Develop
npm install
npm run typecheck # strict type-check, no emit
npm test # vitest unit + property tests
npm run build # emit dist/ (.js + .d.ts)Property-based tests (via fast-check) cover the signaling round-trip, the typed event surface, and capability gating.
End-to-end signaling harness
npm run build # the harness imports the built dist/
cd ../server && cargo run # start a signaling server (another shell)
npm run harness:signaling # drive the real server with two SDK clientsThe harness validates create-intent registration, join validation, mutual peer
presentation, and session-scoped OFFER/ANSWER routing against the real server.
See harness/README.md.
Browser end-to-end test (real WebRTC)
The e2e/ package runs two real Chromium pages (via Playwright)
through a genuine WebRTC data-channel transfer against a live signaling server —
the one hop the Node harness can't cover:
cd e2e
npm install && npm run install:browser
npm test # builds the SDK, starts the servers, runs the transferSee e2e/README.md.
License
MIT — see LICENSE. This SDK is original WrapDrive code and is
published independently under MIT for easy embedding. The wider WrapDrive project
(native app + server) builds on LocalSend
and is Apache-2.0; the SDK implements the LocalSend signaling/handshake protocol
(a wire format), not its code.
