Peer-to-peer connection primitive for Svelte 5 apps. Connect a host (e.g. a laptop) to one or more clients (e.g. phones) over WebRTC with a single <RemoteControl /> component — no signalling server to run yourself, a QR code UI out of the box, and reactive state that syncs across peers.
Built on PeerJS for WebRTC transport, Svelte 5 runes for reactivity.
<RemoteControl /> renders a floating status indicator with QR code, copyable peer ID, and connection management.send() / onMessage().startCall() / onCall().rcState() returns a $state-like object whose value automatically syncs across all connected peers (last-write-wins).npm install svelte-remote-control
Peer dependencies: svelte >= 5.0, peerjs, qrcode.
Drop <RemoteControl /> onto a single page and share one rcState value
between the host (laptop) and any client (phone) that connects to it:
<script lang="ts">
import RemoteControl, { rcState } from 'svelte-remote-control';
const brightness = rcState('brightness', 50);
</script>
<RemoteControl />
<input type="range" min="0" max="100" bind:value={brightness.value} />
<p>Brightness: {brightness.value}</p>
Open the page on your laptop, scan the QR code with your phone or open the link in another tab, and dragging the slider on either device/tab updates the other instantly.
<RemoteControl />Renders a small floating status trigger (top-right by default) with a popover containing:
| Prop | Type | Default | Description |
|---|---|---|---|
remoteHref |
string |
current page path | Path clients should be sent to (e.g. "/remote"). Omit for same-route connections (useful for peer-to-peer symmetric apps); set when host and client interfaces are on different routes. |
config |
WebRTCConnectionOptions |
— | ICE servers and/or PeerJS broker for this instance. Merged into the bound WebRTCConnection via configure(); fields take effect on the next createOffer() / acceptOffer(). |
connection |
WebRTCConnection |
module singleton | Bind this UI to a caller-supplied WebRTCConnection. Use when you need multiple independent connections in one app. Rendering two <RemoteControl /> components against the same connection is a no-op-with-warning (each owns the connection's lifecycle). |
The component auto-detects its role from the URL: if ?id=… is present, it acts as a client and joins that peer ID; otherwise, it acts as a host and advertises its own ID.
rcState<T>(key, initial, validate?)Create a reactive value that automatically syncs to all connected peers.
import { rcState } from 'svelte-remote-control';
const brightness = rcState('brightness', 50);
// template:
<input type="range" min="0" max="100" bind:value={brightness.value} />
Reading or writing brightness.value works like any $state rune. Writes broadcast a __sync message to all peers; receivers update their local copy and rebroadcast to their remaining peers (with the sender excluded to prevent echo).
Values are persisted to sessionStorage (rc:state) so they survive page reloads within the tab.
Pass an optional type-guard to protect against malformed peers and schema changes across sessions:
const mode = rcState<'light' | 'dark'>('mode', 'light',
(v): v is 'light' | 'dark' => v === 'light' || v === 'dark');
initial.__sync messages that fail validation are dropped and not rebroadcast.rcState is last-write-wins (LWW) without causal ordering. Concurrent writes from different peers silently overwrite each other; the order of arrival on each peer determines the final value, so peers may temporarily disagree until the network settles. Suitable for UI state (slider positions, toggles, form inputs) where occasional lost updates are tolerable. Not suitable for counters, carts, or anything requiring convergence under concurrent edits.
deleteRcState(key)Remove a synced key locally and broadcast the deletion. Subsequent rcState(key, initial) calls will reset to initial. Deletion is also LWW — a concurrent write on another peer may resurrect the key.
connStatus()Returns the current connection status reactively. Call inside a $derived, $effect, or template:
const isConnected = $derived(connStatus() === 'connected');
Possible values: 'idle' | 'gathering' | 'awaiting' | 'connected' | 'disconnected' | 'error'.
send(message)Broadcast a JSON-serialisable message to all connected peers.
import { send } from 'svelte-remote-control';
send({ title: 'Hi!', urgency: 2 });
The payload is any plain object. A type field is conventional for
switch-style dispatch in onMessage, but not required — the only constraint
is that type values starting with __ are reserved for library-internal
messages (__sync, __sync_delete, __kick).
onMessage(handler)Register an incoming-message handler. Returns an unsubscribe function — wrap in a $effect for automatic cleanup:
import { onMessage } from 'svelte-remote-control';
$effect(() => onMessage((msg, fromPeerId) => {
if (msg.type === 'notification') {
console.log(`From ${fromPeerId}: ${msg.title}`);
}
}));
fromPeerId is the authoritative peer ID from the underlying DataConnection — it cannot be spoofed by the sender.
startCall(constraints): Promise<MediaStream>Acquire a local media stream via getUserMedia and call all connected peers with it. Supports audio-only, video-only, or both:
import { startCall } from 'svelte-remote-control';
await startCall({ video: true }); // video only
await startCall({ audio: true }); // audio only
await startCall({ video: true, audio: true }); // both
await startCall({ video: { facingMode: 'environment' } }); // constraints
Returns the acquired MediaStream so you can stop its tracks when disconnecting.
makeCall(stream)Lower-level: call all connected peers with a stream you acquired yourself. Use this when you want control over the timing of getUserMedia separately from the call (e.g. acquire before connection, call after).
import { makeCall } from 'svelte-remote-control';
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
// …later, once connected…
makeCall(stream);
onCall(handler)Register an incoming-stream handler. Returns an unsubscribe function — wrap in a $effect for automatic cleanup:
import { onCall } from 'svelte-remote-control';
$effect(() => onCall((stream) => {
videoEl.srcObject = stream;
videoEl.play();
}));
The singleton API covers most cases, but if you need multiple independent connections from one app (e.g. a dashboard that hosts one connection and clients on another), use the class directly:
import { WebRTCConnection } from 'svelte-remote-control';
const conn = new WebRTCConnection();
await conn.createOffer(); // host
await conn.acceptOffer(hostId); // client
conn.send({ type: 'ping' });
conn.onMessage((msg, from) => console.log(from, msg));
// Reactive `$state` fields:
conn.status; // ConnectionStatus
conn.connectedPeers; // string[]
conn.localPeerId; // string
conn.role; // 'host' | 'client' | null
conn.error; // string | null
Pass custom ICE servers if you need TURN relays:
const conn = new WebRTCConnection([
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:my-turn.example.com', username: 'u', credential: 'c' },
]);
Pass a custom PeerJS broker (e.g. a self-hosted peerjs-server) alongside optional ICE servers:
const conn = new WebRTCConnection({
iceServers: [{ urls: 'turn:my-turn.example.com', username: 'u', credential: 'c' }],
peerServer: { host: 'my-peer.example.com', port: 9000, path: '/myapp', secure: true },
});
__sync messages between clients so they stay in sync with each other.sessionStorage with the rc: prefix (rc:state, rc:hostPeerId) so the library is self-contained and won't collide with host-app keys.localhost for getUserMedia in media calls.__sync or a custom message type with a shared secret exchanged out-of-band (e.g. via the QR code).getUserMedia() requires HTTPS or localhost.onCall() before the client calls startCall(). If the stream event fires before the handler registers, the first stream is missed.ngrok, Cloudflare Tunnel, or a TLS cert).new WebRTCConnection({ iceServers: [...] }).startCall().Run npm run dev to open the playground. The home route (/) acts as the host pane; scan the QR code with a phone (or open /remote?id=… in a second tab) to connect as a client.
MIT