noteloom

Live collaboration

Experimental. This is deliberately not the "Google Docs in the cloud" model — there is no noteloom server, no account, and no central copy of your document sitting on somebody else's machine. Peers connect directly to each other over WebRTC, the same way a video call does, and from that point on your document never touches a third party at all. The only thing anyone else ever needs is a small, disposable signaling handshake to help two peers find each other — after that, the connection (and every edit on it) is peer-to-peer, full stop.

If that sounds like the same instinct behind offline-first, infrastructure-free apps like Bitchat (mesh messaging with no server, no phone number, no internet required) — that's the right comparison. noteloom's version trades Bluetooth mesh for WebRTC/WiFi, but the philosophy is identical: two devices in the same room (or two devices anywhere, if you want that instead) should be able to collaborate on a document without a company in the middle of it. Run entirely on a local network with zero internet connectivity, and nothing about this feature ever needs the outside world at all.

Built as a custom block-tree CRDT — not a generic text-CRDT library (like Yjs or Automerge) bolted on — so it stays true to the zero-runtime-dependency design. You bring your own signaling (a WebSocket relay, Firebase/Supabase realtime, or anything else that can pass small JSON messages between two peers) to bootstrap the connection — noteloom itself ships no server, and never runs one for you.

Try it live → — two synced editors side by side, right on this site, no setup required.

Quick start: CollabSession

jsx
import { useEditor, NoteloomEditor, CollabSession } from 'noteloom';
import { useEffect } from 'react';

function App() {
  const editor = useEditor({ doc: myDoc });

  useEffect(() => {
    // `signaling` is any object shaped like SignalingChannel:
    // { localPeerId, send(toPeerId, message), onMessage(cb) }
    const session = new CollabSession({ history: editor.store, signaling });
    session.connect(remotePeerId, { initiator: true }); // `initiator: true` on exactly one side of each pair
    return () => session.destroy();
  }, []);

  return <NoteloomEditor editor={editor} />;
}

From then on, every edit made via editor.store (typing, inserting/moving/deleting blocks, "Turn into" type conversions) is automatically broadcast to connected peers, and incoming changes merge in live — no manual diff/patch/apply step anywhere in your own code.

CollabSession API

MemberSignatureWhat it does
new CollabSession(...){ history, signaling, presenceThrottleMs? }history is editor.store (a History-wrapped store — collaboration needs undo/redo's change tracking). presenceThrottleMs defaults to 100.
connect(remotePeerId, { initiator }) => voidEstablishes (or re-establishes) a connection to one peer. initiator: true must be set on exactly one side of each pair — see the tie-break pattern in Signaling options.
disconnect(remotePeerId) => voidCloses the connection to one specific peer.
destroy() => voidTears down every connection and stops listening on signaling — call on unmount, as shown above.
setLocalPresence(data) => voidSee Presence / awareness below.
getPresence() => Map<peerId, data>Snapshot of every other peer's current presence data. Prefer the reactive usePresence(session) hook in React.
onPresenceChange(cb) => unsubscribeFires whenever any peer's presence changes, or a peer disconnects.
connect() re-establishing a dropped connection is a transport-layer concern left to your own app — CollabSession just drops a peer on close. Reconnection itself (retry/backoff policy, deciding when to call connect() again) is up to you; a fresh connect() always starts with a full resync, so there's no dependency on resuming from wherever a dropped connection left off.

Signaling options

CollabSession only needs something that can pass small JSON messages between two peers to bootstrap their WebRTC connection — it never needs to touch the internet itself beyond that handshake. Two ready-to-use signaling backends ship with the package:

Same-browser demo, zero server

The package's own examples/collab/ uses the native BroadcastChannel API so every tab open on the same machine can find and sync with each other — good for trying the feature out; only works within one browser, not across devices.

Real multi-device collaboration — LAN or internet

createWebSocketSignaling() connects to a small relay server that only ever sees connection-setup messages, never document content:

js
import { createWebSocketSignaling, CollabSession } from 'noteloom';

const signaling = createWebSocketSignaling({
  url: 'ws://192.168.1.5:8080', // a relay on your LAN — or any host, for internet-wide instead
  roomId: 'my-document-id',     // anyone using the same roomId ends up in the same room
  peerId: crypto.randomUUID(),
});
const session = new CollabSession({ history: editor.store, signaling });

signaling.onPeerDiscovered((remotePeerId) => {
  const initiator = signaling.localPeerId > remotePeerId; // deterministic tie-break
  session.connect(remotePeerId, { initiator });
});
OptionTypeWhat it does
urlstringYour relay's WebSocket URL — same-WiFi/LAN with no internet required, or a public host for internet-wide collaboration.
roomIdstringAnyone connecting with the same roomId ends up in the same room (typically your document's id).
peerIdstringThis client's own id — crypto.randomUUID() is a reasonable default.
WebSocketImplWebSocket constructorOptional override — default is the global WebSocket. Useful for a non-browser runtime (Node/tests) with a polyfill.

Returns a SignalingChannel plus two relay-specific extras a peer-to-peer-only backend (like BroadcastChannel) can't offer:

  • onPeerDiscovered(cb) — tells you who's already in the room, and who joins afterward (the relay knows the full roster).
  • Automatic disconnect notification — the relay tells everyone else when a peer's connection drops.
A minimal reference relay server (Node, ws-based, ~80 lines, not part of the npm package) lives in tools/lan-relay-server/ in the package repo — see its own README for how to run it and the wire protocol. A full runnable example wiring it up is in examples/lan-collab/.

Presence / awareness (live cursors, who's online)

CollabSession also carries ephemeral "here's where I am" data alongside the document sync — entirely separate from the document CRDT (never persisted, never merge-conflicted, just "whatever the last message said"):

js
import { usePresence } from 'noteloom';

// broadcast your own position (throttled automatically, ~100ms by default)
session.setLocalPresence({ runId: caret.runId, offset: caret.offset, name: 'Alex' });

// react to everyone else's, reactively
function PeerCursors({ session }) {
  const presence = usePresence(session); // Map<peerId, data>, re-renders on change
  return [...presence.entries()].map(([peerId, data]) => /* render however you like */);
}

What presence contains is entirely up to you — a cursor position, a display name, a color, a "currently viewing" flag — CollabSession only relays the data, it never inspects or interprets it. A peer's entry disappears from usePresence's map the instant they disconnect, and a newly-joining peer receives everyone's already-set presence immediately rather than waiting for their next move. The package's examples/collab/ renders this as live colored carets with peer-id labels, resolving { runId, offset } to an on-screen position the same way the editor's own selection code does (via the [data-run-id] DOM convention).

How conflicts resolve

ScenarioResult
Concurrent inserts, even at the same positionBoth survive, converging to the same order on every peer.
Concurrent delete vs. edit of the same blockThe delete wins.
Concurrent type-conversion of the same block ("Turn into")One type wins deterministically (the same one, on every peer) — not two duplicate blocks.
Concurrent edits to a run's textWhole-value last-write-wins — the newer edit replaces the older one entirely. Character-level interleaving is not implemented.

Tombstone garbage collection

Deleted blocks/runs are kept as "tombstones" rather than actually removed — necessary so a concurrent operation that references a since-deleted item (an insert anchored to it, say) can still resolve correctly no matter when it arrives. Left alone, this grows without bound over a long enough session.

js
import { useEditor, createPeriodicTombstoneGC } from 'noteloom';

const editor = useEditor({ doc: myDoc });
const gc = createPeriodicTombstoneGC({
  store: editor.store,
  intervalMs: 60 * 60 * 1000,  // hourly sweep (default)
  maxAgeMs: 24 * 60 * 60 * 1000, // 24h retention (default)
  onPrune: (removed) => console.log(`Pruned ${removed} tombstones`),
  onError: (err) => console.error(err),
});

// later, when the store is no longer in use:
gc.stop();
OptionTypeDefault
storeEditorStore | Historyrequired
intervalMsnumber3600000 (1 hour)
maxAgeMsnumber86400000 (24 hours)
onPrune(removedCount) => voidoptional
onError(err) => voidoptional

Returns { stop }. Or call store.pruneTombstones({ maxAgeMs }) yourself on whatever schedule you want — createPeriodicTombstoneGC is just a thin timer wrapper around it. store.getTombstoneCount() tells you how many are currently being retained, if you want to observe growth before deciding on a policy. Both work identically whether store is a plain EditorStore or a History wrapping one, and pruning is never itself an undo step (it doesn't change the visible document — the pruned content was already invisible).

Why a time-based threshold is safe here specifically: this only works because of how CollabSession reconnects — a peer rejoining after any absence gets a full document snapshot, never a replay of the ops it missed. That means a peer offline longer than the GC threshold never needs an old tombstone to resolve a stale reference; it just adopts the current state directly. The only residual risk is a single already-connected peer somehow stalling for exactly as long as the threshold and then delivering a queued message afterward — implausible for a live, reliable, ordered WebRTC data channel (which disconnects long before that under any real interruption), but not impossible, which is why this is opt-in rather than automatic.

Known limitations — read before relying on this in production

  • Undo is local-only, and can overwrite a peer's edit to the same run. Your undo/redo never touches a peer's changes directly — but because text merges as whole-value LWW (see above), undoing your own past edit to a run replays an old full-string snapshot, which will clobber anything a peer has since typed into that same run. Avoid undoing text you know a peer may have touched; a true fix requires character-level text merging, a deliberately larger, not-yet-built change.
  • Deleted content isn't garbage-collected automatically, but can be — opt-in. See Tombstone garbage collection above.
  • A peer joining with their own existing (different) document does not merge with yours. CollabSession only adopts a peer's document wholesale when your own side is still empty (the common "open a shared link and get the document" flow). Reconciling two independently-created, already-diverged documents on first contact is a fundamentally harder problem (no shared id space) and isn't attempted.
  • Reconnecting after a dropped connection re-syncs the full document, not just what was missed — simple and correct, at the cost of O(document size) traffic per reconnect.
  • Only structural block changes and field edits (props, type, run text) are collaboration-aware. A few coarse "resync" operations (used for DOM-reconciliation escape hatches like paste-into-contentEditable or IME composition) remain local-only for now.
  • Large single messages (an embedded video/file's data: URL, or a full-document sync for a big document) are transparently fragmented, flow-controlled against the data channel's own backpressure, and reassembled under the hood — nothing for you to do, but very large embeds mean more individual send calls and somewhat higher latency to fully arrive.

Real-world scenarios, worked through

The mechanics above answer "how does it work" — this section answers "what actually happens to me." Three people, A, B, and C, share one document. Every scenario below was actually built and tested this way, not just reasoned about.

A, B, and C, all editing at once

There is no "host" device that the others route through. When C joins a room where A and B are already connected, C opens two separate, direct WebRTC connections — one to A, one to B — not one connection to whoever happened to be first. Every device holds its own complete copy of the document; an edit on any one of them broadcasts straight to the other two, who each merge it into their own copy. Three people editing at once is three pairs of direct connections (A↔B, A↔C, B↔C), not a hub and two spokes.

A drops offline — B and C keep going

Because B and C were never routed through A, A disappearing changes nothing for them — their own direct connection to each other was never A's responsibility. They keep editing, keep merging each other's changes, with zero interruption. There is nothing to explicitly handle here: it falls directly out of the mesh topology above.

A comes back a few hours later

If B or C is still online in that same document when A reconnects, A automatically catches up on everything it missed — every edit made while it was away — with no manual action. This only works with a specific, deliberate reconnect strategy: a returning peer that kept its own content intact (the right default, so a network blip doesn't wipe a solo editing session) never re-triggers CollabSession's own "adopt a peer's snapshot" logic, which only fires for a genuinely empty store. The fix — reset back to empty first, but only on a real reconnect, and only if nothing was typed locally in the gap — is shown as a complete pattern in examples/lan-collab, alongside the watchdog that makes reconnecting happen automatically in the first place (see "Reconnecting reliably" in the package README).

If A also typed something while disconnected, those local edits are always kept — never silently thrown away — which means A will miss whatever B/C changed concurrently. There's no safe way to both preserve A's edits and adopt someone else's snapshot without a real CRDT merge of two independently- diverged documents, which isn't attempted (see "Known limitations" above).

A reconnects, but B and C are offline too

Then A gets nothing — and this has nothing to do with reconnect logic; it's the whole model. There is no server anywhere holding "the real document." The only place any edit ever exists is on whichever devices are currently online. If nobody who has B and C's changes is reachable at that moment, A stays on what it last knew until one of them comes back and A can connect to them.

C is "online," but editing a different document

Still nothing for A — being online in general doesn't help. Collaboration rooms are scoped per document (a note's own id doubles as the room id), and the relay keeps every room's peer list completely separate from every other room's. C's connection only counts for whatever document C currently has open. For A to catch up on the document A/B/C were sharing, someone needs that specific document open at the moment A reconnects — not just the app, open to something else.

Someone's WiFi blips mid-session

Handled the same way as "A comes back a few hours later" above, just faster — a watchdog notices the dropped connection and silently reconnects (and catches up) the moment the network returns, with no dialog, no button, no lost work. The only visible sign is a status indicator flipping from "connected" to "reconnecting" and back.

Seeing where A, B, and C each are

Presence (see above) is what makes three people editing at once feel like three people in a room instead of three people typing blind — each peer's cursor renders live, with whatever name/color you choose to attach, and disappears the instant that peer actually disconnects.

Same WiFi, zero internet, the whole time

Every scenario above works completely unplugged from the internet — the signaling relay just needs to be reachable on the same local network, which is exactly what makes this different from "cloud collaboration that happens to also work on WiFi." Two (or three) people in the same room, on the same WiFi with the router itself offline from the wider internet, can still open a shared document and edit it together live — the relay only ever sees connection-setup metadata, never document content, and once WebRTC connects, even the relay drops out of the picture entirely.

Setting this up permanently for a whole office/team

The scenarios above assume someone is running a signaling relay for the duration of a session (a laptop, a quick npm run script). For a real, always-on setup — every laptop/PC/phone on one office WiFi, collaborating live, with zero internet needed at any point, indefinitely — the pattern is the same every time, regardless of which app you're building this into:

  1. Put the relay (your own copy of the pattern in tools/lan-relay-server/, adapted to your app) on a machine that's actually on all the time — an old PC, a NAS, a Raspberry Pi. It's a tiny process; nothing about it needs real hardware.
  2. Give that machine a fixed local IP — a DHCP reservation in your router's admin page is the least fiddly way — so the address doesn't change out from under everyone on the next reboot.
  3. Run the relay under a process manager (pm2, a systemd unit, an NSSM-wrapped Windows service) instead of a bare terminal, so it survives reboots and doesn't die when someone closes a window.
  4. Point your app's createWebSocketSignaling({ url }) at that fixed local address (ws://192.168.1.50:8080, say) — baked in wherever your build process reads configuration from — and serve your app itself from somewhere on the same LAN too.
This site's own My Notes has a complete, real worked example of exactly this — a permanent office/LAN deployment guide (fixed IP, pm2, the works) lives at relay-server/OFFICE-LAN-DEPLOYMENT.md in this project's source, including the one gotcha that trips people up most: guest WiFi networks are usually isolated from the main network by the router, so a relay reachable from one can be completely unreachable from the other even though both say "connected to WiFi."
Collaboration and offline persistence compose freely — wire both usePersistedDocument and CollabSession to the same store, and a document keeps working (and saving) solo, then picks up live sync the moment a peer connects.