尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證Schema-aware, serverless P2P and E2E encrypted CRDT state sync over WebRTC
翻譯準備中
取自儲存庫 README
正在載入完整 README。
檢視完整 README ↗Peer-to-peer data channels for the browser: one offer and one answer, carried by a file or a tiny room server, and a schema-aware CRDT that keeps a shared JSON document in sync between every peer.
ManualPeer wraps RTCPeerConnection and hands you signaling as self-contained JSON bundles: complete SDP after ICE gathering, no trickle ICE. The repository ships the room signaling server twice behind one protocol, for Node.js (Express, rooms in memory) and for Vercel (Functions with Upstash Redis). The package has no runtime dependencies.
Two peers exchange one offer bundle and one answer bundle, by hand or through the room server. Then the RTCDataChannel opens, and a CRDT document attached to it syncs state between them. From there, application traffic flows peer to peer; a signaling server never sees it (see Quick start and the live demo).
ManualPeer waits until ICE gathering is complete (15 s timeout by default), so an offer and an answer are everything two peers exchangePEER.json files by chat, email, QR code or USB stick; the demo connects two peers this way and chats without any server (browser test)server/PROTOCOL.md), two implementations, one conformance suite for bothsequenceDiagram
participant A as 🧑 Peer A (offers)
participant S as 📨 a file, or the room server
participant B as 🧑 Peer B (answers)
A->>A: createOffer(): gather ICE until complete
A->>S: offer bundle
S->>B: offer bundle
B->>B: acceptOffer(): answer, gather ICE
B->>S: answer bundle
S->>A: answer bundle
A->>A: acceptAnswer()
Note over A,B: ICE checks, DTLS: the RTCDataChannel opens, peer to peer
A->>B: CRDT hello, then the operations B lacks
B->>A: CRDT hello, then the operations A lacks
Either way, the flow underneath is the same:
RTCDataChannel opens.| Out-of-band signaling | Server-based signaling | |
|---|---|---|
| Transport | files and messages: WhatsApp, email, QR code, USB stick | HTTP rendezvous server |
| Infrastructure | none | server/ (Express) or vercel/ (Vercel Functions + Redis), both in this repo |
| Topology | one peer pair per exchange | rooms; every joiner is meshed with every member automatically |
| Setup time | a human round trip per pair | seconds |
| Access control | whoever receives the file | optional server password and per-room passwords |
| Entry point | When you reach for it | What it does |
|---|---|---|
defuss-webrtc/webrtc | connecting two browsers | ManualPeer, the bundle helpers parseSignalBundle, serializeSignalBundle, signalFileName, downloadSignalBundle, readSignalFile, and the ICE candidate parsers |
defuss-webrtc/crdt | syncing state | defineSchema, register, counter, orSet, map, object, list, createCrdtDocument, and the operation, wire and snapshot types |
defuss-webrtc | both | Re-exports both entry points, plus the JSON helpers. |
server/ | a room server on Node.js | Express, rooms in process memory, PORT and an optional SERVER_PASSWORD. |
vercel/ | a serverless room server | Vercel Functions with Upstash Redis, one Lua compare-and-swap per room write. |
npm install defuss-webrtc # or: pnpm add defuss-webrtc, bun add defuss-webrtc
Without a bundler, the published dist/ is plain ES modules, so an import map pointing at jsDelivr is enough:
<script type="importmap">
{ "imports": {
"defuss-webrtc/webrtc": "https://cdn.jsdelivr.net/npm/[email protected]/dist/webrtc/index.js",
"defuss-webrtc/crdt": "https://cdn.jsdelivr.net/npm/[email protected]/dist/crdt/index.js"
} }
</script>
Peer A creates the offer and keeps its tab open until the answer comes back:
import { ManualPeer, downloadSignalBundle, readSignalFile } from "defuss-webrtc/webrtc";
const peer = new ManualPeer();
const offer = await peer.createOffer();
downloadSignalBundle(offer); // send this JSON file to peer B
console.log(offer.publicEndpoints); // STUN-derived srflx endpoints, if any
// Later, after B returns an answer file:
const answer = await readSignalFile(answerFile);
await peer.acceptAnswer(answer);
const channel = await peer.waitForOpen();
channel.send("hello");
Peer B answers it:
import { ManualPeer, downloadSignalBundle, readSignalFile } from "defuss-webrtc/webrtc";
const offer = await readSignalFile(offerFile);
const peer = new ManualPeer();
const answer = await peer.acceptOffer(offer);
downloadSignalBundle(answer); // return this JSON file to peer A
const channel = await peer.waitForOpen();
channel.addEventListener("message", (event) => {
console.log(event.data);
});
Rooms follow one rule: the joiner offers to every existing member, members answer the offers addressed to them, and a full mesh forms within a few polls. Polling doubles as the presence heartbeat; silent members are dropped after 90 s. The package does not include a room client yet; signaling over the server is plain fetch around ManualPeer:
import { ManualPeer } from "defuss-webrtc/webrtc";
const base = "https://signal.example.com/v1/rooms/lobby";
const me = { peerId: crypto.randomUUID(), nickname: "Alice" };
const headers: Record<string, string> = { "Content-Type": "application/json" };
// Only for password-protected servers/rooms (values are percent-encoded):
// headers["X-Server-Password"] = encodeURIComponent(serverPassword);
// headers["X-Room-Password"] = encodeURIComponent(roomPassword);
async function api(method: string, path: string, body?: unknown) {
const res = await fetch(base + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json()).message);
return res.json();
}
// Join: one fresh offer per existing member.
// (To create the room instead: await api("POST", "", { ...me, password: "optional" }).)
const { members } = await api("GET", "/members");
const pending = new Map<string, ManualPeer>(); // sessionId -> peer awaiting an answer
const offers = [];
for (const member of members) {
const peer = new ManualPeer({ peerId: me.peerId });
const bundle = await peer.createOffer({ metadata: { nickname: me.nickname } });
pending.set(bundle.sessionId, peer);
offers.push({ to: member.peerId, bundle });
}
await api("POST", "/join", { ...me, offers });
// Poll: complete my offers, answer offers addressed to me.
const answered = new Set<string>();
setInterval(async () => {
const state = await api("GET", `/members?peerId=${encodeURIComponent(me.peerId)}`);
for (const answer of state.answers) {
const peer = answer.to === me.peerId ? pending.get(answer.sessionId) : undefined;
if (!peer) continue;
pending.delete(answer.sessionId);
await peer.acceptAnswer(answer.bundle);
peer.waitForOpen().then((channel) => doc.attach(channel)); // doc: a CrdtDocument, see below
}
for (const offer of state.offers) {
if (offer.to !== me.peerId || answered.has(offer.sessionId)) continue;
answered.add(offer.sessionId);
const peer = new ManualPeer({ peerId: me.peerId });
const bundle = await peer.acceptOffer(offer.bundle, { metadata: { nickname: me.nickname } });
await api("POST", "/answers", { from: me.peerId, to: offer.from, sessionId: offer.sessionId, bundle });
peer.waitForOpen().then((channel) => doc.attach(channel));
}
}, 2000);
This sketch leaves out leaving (POST /leave), error handling and mesh repair for peers that joined at the same moment (PROTOCOL.md §6.4). docs/demo/app.js is the complete reference client, which the browser suite drives.
The schema is executable metadata for the merge layer: each path has explicit conflict semantics.
import { createCrdtDocument, defineSchema, register, counter, orSet, map, object, list } from "defuss-webrtc/crdt";
const schema = defineSchema({
title: register("Untitled"), // Lamport-LWW register
votes: counter(0), // commutative delta counter
tags: orSet<string>([]), // observed-remove, add-wins set
users: map(object({ // LWW key lifecycle + nested CRDTs
name: register(""),
score: counter(0),
})),
messages: list<{ text: string }>([]), // RGA-style sequence + tombstones
});
const doc = createCrdtDocument({ id: "room-42", actorId: peer.peerId, schema });
doc.attach(await peer.waitForOpen());
doc.set(["title"], "Shared notes");
doc.increment(["votes"], 1);
doc.setAdd(["tags"], "p2p");
doc.set(["users", "alice", "name"], "Alice"); // map key auto-created
doc.listInsert(["messages"], 0, { text: "hello" });
console.log(doc.state);
The default STUN server is Cloudflare's public stun:stun.cloudflare.com:3478. Pass iceServers to override it or add TURN; it works the same for both signaling modes. No TURN credentials are bundled.
const peer = new ManualPeer({
iceServers: [
{ urls: "stun:stun.cloudflare.com:3478" },
{
urls: ["turn:turn.example.com:3478?transport=udp"],
username: "ephemeral-user",
credential: "ephemeral-password",
},
],
});
kyr0.github.io/defuss-webrtc serves docs/, built with defuss-shadcn from its CDN. The landing page explains both signaling paths and the CRDT sync in animated sequence diagrams, compares the two servers and their room data model, runs a CRDT merge explorer on the library build it ships, and starts two chat peers side by side on a click. The demo itself (docs/demo/) connects step by step (mode, signaling server, this peer, room or files), then switches to the chat; a Peer devtools tab shows the connections, the live CRDT document and the log.
bun run dev # bunx serve of docs/ on http://localhost:3000, or the free port it prints
make site # copies a fresh library build to docs/dist after changing src/
VERIFIED by e2e/browser/site.spec.mjs and e2e/browser/chat.spec.mjs (part of make e2e):
sessionStorage, so each frame keeps its identity under its own key (?peer=a, ?peer=b); the test sees two peer ids, joins both to one room through the local server and chats.docs/dist/ build, and the test checks every stated outcome: last writer wins, counters add up, add wins over a concurrent delete, both list inserts survive, a deleted map entry stays deleted.docs/README.md covers hosting on GitHub Pages and the demo's reconnection behavior.
ManualPeerOne ManualPeer is one RTCPeerConnection with one ordered data channel, for one pairing.
| Member | Returns | Semantics |
|---|---|---|
new ManualPeer(options?) | ManualPeer | Requires a WebRTC-capable browser or runtime. |
createOffer({ metadata? }) | Promise<OfferSignalBundle> | Creates the data channel and the offer; resolves after ICE gathering. metadata is a string record, for example a nickname. |
acceptOffer(offer, { metadata? }) | Promise<AnswerSignalBundle> | Takes a bundle or its JSON text and answers it. |
acceptAnswer(answer) | Promise<void> | Completes the offerer's side. |
waitForOpen(timeoutMs = 20000) | Promise<RTCDataChannel> | The data channel once it is open. |
waitForDataChannel(timeoutMs = 20000) | Promise<RTCDataChannel> | The data channel as soon as it exists, open or not. |
peerId, connection, dataChannel | properties | The logical peer identity, the RTCPeerConnection, the channel or null. |
close() | void | Closes the channel and the connection. |
| Option | Default |
|---|---|
peerId | a random id |
iceServers | [{ urls: "stun:stun.cloudflare.com:3478" }] |
rtcConfiguration | everything else of RTCConfiguration |
iceGatheringTimeoutMs | 15000; past it, createOffer() / acceptOffer() reject |
channelLabel | "defuss" |
channelOptions | { ordered: true } |
Both signaling modes carry the same JSON: protocol ("defuss-webrtc/manual-signal/1"), version, kind ("offer" or "answer"), sessionId, peerId, createdAt, description (complete SDP), candidates, publicEndpoints (STUN-derived addresses), optional metadata, and for answers replyTo. parseSignalBundle() rejects anything else.
createCrdtDocument({ id, schema, actorId?, maxMessageBytes = 65536, maxBufferedAmount = 524288 }):
| Schema node | Mutation API | Merge semantics |
|---|---|---|
register(initial) | doc.set(path, value) | Lamport-LWW; (clock, actor, seq) total-order tie break |
counter(initial) | doc.increment(path, delta) | unique immutable deltas summed once |
orSet(initial) | doc.setAdd, doc.setDelete | observed-remove/add-wins OR-set |
map(valueSchema) | nested mutations, mapEnsure, mapDelete | LWW key lifecycle; re-put starts a fresh nested generation |
object(fields) | child mutation | static field container |
list(initial) | listInsert, listSet, listDelete | RGA-style immutable element IDs + tombstones |
| Document member | Semantics |
|---|---|
state, get(path) | The materialized JSON as a fresh copy; one path of it. |
subscribe(listener) | Called with { origin: "local" | "remote" | "import", ops, state }; returns an unsubscribe function. onError(listener) reports wire and schema errors. |
batch(fn) | Several writes, one broadcast and one notification. |
attach(channel) | Syncs over a reliable, ordered RTCDataChannel, any number of them; returns detach. close() detaches all. |
export(), merge(snapshot) | The operation set as a JSON snapshot; merging returns how many operations were new. |
versionVector, operationCount | The highest contiguous seq per actor; the number of operations held. |
On a channel, both sides send hello (canonical schema, its fingerprint, version vector) when it opens, then the ops the other side lacks, then every new operation. Schema equality is checked on the exact canonical schema descriptor; schemaFingerprint is only a compact diagnostic. A different schema, document or protocol rejects the channel with an error message.
Base path /v1, JSON in and out, CORS open. server/PROTOCOL.md is normative.
| Endpoint | Body | Success |
|---|---|---|
GET /v1/rooms | room summaries by createdAt | |
POST /v1/rooms/{name} | { peerId, nickname, password? } | 201 room state; the caller is moderator |
POST …/{name}/join | { peerId, nickname, offers: [{ to, bundle }] } | room state |
GET …/{name}/members?peerId= | room state; renews the caller's lastSeen | |
POST …/{name}/offers | { from, to, bundle } | 201, a late offer for mesh repair |
POST …/{name}/answers | { from, to, sessionId, bundle } | 201; consumes the offer, idempotent |
POST …/{name}/leave | { peerId } | removes the member, its offers and answers |
SERVER_PASSWORD=…; every request must then send X-Server-Password.password when creating a room; every later request for that room must send X-Room-Password. GET /v1/rooms marks such rooms protected: true.Passwords are shared secrets, not per-member accounts (see PROTOCOL.md §2.1 and §8).
Both implementations return identical responses; they differ in where a room lives and how it expires.
server/, Node.js | vercel/, Vercel Functions | |
|---|---|---|
| Rooms live in | the memory of one process; a restart empties it | Upstash Redis: one JSON document { revision, room } per room, plus a sorted index of room names |
| Atomic writes | each request runs synchronously on one event loop | a Lua compare-and-swap on the revision, retried up to 24 times |
| Expiry | a sweep every 30 s: members after 90 s without a poll, offers and answers after 120 s | lazily on every room access and listing, plus a 180 s key TTL as a safety net |
| Run | cd server && npm install && npm run dev | Vercel project with Root Directory vercel, npx vercel |
A room holds its members (peerId, nickname, role, joinedAt, lastSeen) and the offers and answers in flight between them, keyed by sessionId. The creator is the moderator; when the moderator leaves or times out, the member with the smallest joinedAt takes over. The room is deleted with its last member. The live demo shows this model as an ER diagram.
defuss-webrtc trades a backend for your data against a signaling step and a full mesh: the server only introduces peers, and every peer holds the whole document.
PEER.json files connect thempeerId is self-assertedpeerId is the logical replica and peer identity; STUN-derived srflx addresses are transient network locators.RTCPeerConnection must stay alive while its offer waits for an answer. ICE credentials, NAT bindings and candidates belong to one session.iceServers when connectivity must be reliable.const snapshot = doc.export();
localStorage.setItem("room-42", JSON.stringify(snapshot));
const other = createCrdtDocument({ id: "room-42", actorId: "new-peer", schema });
other.merge(localStorage.getItem("room-42")!);
Snapshots contain the immutable operation set. That keeps merging simple, correct and idempotent, but the log grows with every mutation. Compaction is deliberately not implemented in 0.1, because safe compaction needs causal stability and peer-retirement semantics.
A document may attach to several reliable ordered data channels. Newly learned operations are deduplicated by (actor, seq) and forwarded to the other attached channels. Version-vector frontiers repair missing prefixes on attach and reconnect.
doc.attach(channelToB);
doc.attach(channelToC);
CrdtDocument.attach() requires a reliable ordered RTCDataChannel and rejects channels configured with maxRetransmits or maxPacketLifeTime. Messages are capped at 64 KiB by default and queued while bufferedAmount is high; both limits are configurable.
make verify # lint → test → coverage → e2e; CI runs the same after `make setup`
make lint: oxlint with --deny-warnings, and a type check of the Vercel packagemake test: 40 unit tests (13 library, 13 Express server, 14 Vercel functions), and a check that docs/dist equals a fresh buildmake coverage: line coverage over all three packages, 82.34% on 2026-10-10make e2e: the packed tarball in a clean consumer, the HTTP conformance suite against the built server (29 tests together), and 19 Playwright tests in ChromiumThe library tests cover a strict TypeScript build, deterministic convergence, OR-set observed-remove and add-wins, map generation, delete and recreate, RGA lists, anti-entropy over a test data channel, version-vector gap repair, 60 randomized three-replica simulations of 80 mutations each, and the ICE candidate and bundle parsers. tests/browser-smoke.html is a separate same-browser two-peer WebRTC check.
e2e/signaling.test.mjs is one protocol conformance suite (dependency-free, node:test + fetch) that runs against any signaling server over real HTTP. It asserts exact statuses, error codes and messages, so passing on two targets means they behave identically:
npm run test:e2e # builds and spawns the local Express server (with a server password)
E2E_SERVER_PASSWORD= npm run test:e2e # ... as an open server
E2E_BASE_URL=https://defuss-webrtc.vercel.app npm run test:e2e:remote # any deployment
| Variable | Meaning |
|---|---|
E2E_BASE_URL | Target origin. Unset: spawn the local Express server from server/dist. |
E2E_SERVER_PASSWORD | The target's server password, if it has one (enables the server-password tests). Locally: the password the spawned server gets; "" spawns an open server. |
E2E_VERCEL_BYPASS | "Protection Bypass for Automation" secret, for Vercel deployments behind Deployment Protection. |
Runs use unique room names and leave their rooms afterwards, so they are safe against a shared deployment. Member and signal expiry (90 s / 120 s) is covered by the unit tests, not here. One known platform difference: Vercel's edge rejects malformed percent-encoding in the URL path itself (plain-text 400) before the function runs; the suite accepts that and reports it as a diagnostic.
e2e/browser/chat.spec.mjs drives the chat app (docs/demo/index.html) in several isolated Chromium contexts, one per peer, each with its own identity. It connects them through a signaling server and chats over genuine WebRTC data channels: a three-peer full mesh with message fan-out, concurrent sends converging to one order, history sync for late joiners, leaving, password-protected rooms and wrong server passwords. It also covers reconnection (a dropped link healing after divergent offline writes, a reloaded tab rejoining with the same identity, a peer the server timed out rejoining automatically, hidden tabs polling slowly) and two peers that connect by swapping PEER.json files, with no server, and chat. e2e/browser/site.spec.mjs covers the landing page (docs/index.html): every section, the diagrams, the CRDT explorer, the two-peer demo chatting through the local server, the theme switch and the phone layout.
npx playwright install chromium # once, if the browser is not installed yet
npm run test:browser # local Express server (with a server password) + docs site
E2E_BASE_URL=https://defuss-webrtc.vercel.app npm run test:browser # a deployed server
The site is served from docs/ with /dist/ mapped to a fresh library build, so the tests always run the current source. E2E_SERVER_PASSWORD works as for the HTTP suite; E2E_DOCS_PORT / E2E_SIGNAL_PORT override the local ports (47173 / 47787). Chromium runs with mDNS host-candidate obfuscation disabled: .local candidates do not resolve on many CI and sandboxed hosts, so peers would never connect. The pages load defuss-shadcn from jsDelivr, so the tests need internet access. On failure, each peer's in-app log is attached to the report.
Against a deployment, requests cost money. Every open peer tab polls the signaling server about once per second, so with E2E_BASE_URL only the two @remote smoke tests run (mesh chat and protected room, roughly 50–60 requests), on one worker, stopping at the first failure; waits abort as soon as a peer logs a signaling failure instead of polling until the timeout. E2E_REMOTE_ALL=1 runs the whole suite remotely. Each run prints the number of signaling requests it sent. Hunt flakes against the local server, never with --repeat-each against a deployment.
No room client in the package yet (the sketch and the demo show the protocol), no compaction of the operation log, no media tracks. Peers must share the exact canonical schema; values are JSON, at most 64 KiB per wire message by default. Browser tests run in Chromium only. Open work, including audio, video and screen sharing, CRDT compaction, schema evolution, a realtime side channel and server hardening, is tracked in ROADMAP.md.
src/: the defuss-webrtc package: ManualPeer and signal bundles (defuss-webrtc/webrtc), and the CRDT layer (defuss-webrtc/crdt).docs/: the GitHub Pages site (bun run dev serves it locally): a landing page that explains signaling, the room server and the CRDT, with a merge explorer, plus the P2P chat demo in docs/demo/. The demo supports both signaling modes, rooms via a signaling server or the manual PEER.json file exchange, and syncs CRDT chat state over data channels in a full peer mesh. See docs/README.md.server/: Express room signaling server (rooms, offer/answer brokering, presence, optional passwords). server/PROTOCOL.md is the protocol spec.vercel/: the same protocol as Vercel Functions with Upstash Redis, for serverless deployments.ManualPeer; the CRDT layer also runs in Node.js, where its tests runengines); development is pinned to Node 24.14.0 in mise.tomlpackageManager) installs and runs the packages; Bun 1.4.2 runs bun run devIf you use defuss-webrtc in research or want to reference it, cite it as:
@misc{homberg2026defusswebrtc,
author = {Homberg, Aron},
affiliation = {Independent Researcher},
title = {defuss-webrtc: WebRTC Data Channels with File or Room Signaling and Schema-Aware CRDT Sync},
year = {2026},
version = {0.1.0},
howpublished = {\url{https://github.com/kyr0/defuss-webrtc}},
note = {MIT License}
}
MIT, see LICENSE.