https://github.com/loro-dev/peer-lease
A TypeScript library for safely reusing CRDT peer IDs without collisions
https://github.com/loro-dev/peer-lease
Last synced: 4 months ago
JSON representation
A TypeScript library for safely reusing CRDT peer IDs without collisions
- Host: GitHub
- URL: https://github.com/loro-dev/peer-lease
- Owner: loro-dev
- License: mit
- Created: 2025-09-23T07:56:32.000Z (9 months ago)
- Default Branch: main
- Last Pushed: 2025-10-03T03:42:44.000Z (8 months ago)
- Last Synced: 2025-10-13T03:34:35.220Z (8 months ago)
- Language: TypeScript
- Size: 102 KB
- Stars: 1
- Watchers: 1
- Forks: 1
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
- Agents: AGENTS.md
Awesome Lists containing this project
README
# peer-lease
`@loro-dev/peer-lease` is a TypeScript library for safely reusing CRDT peer IDs without collisions.
## Installation
```sh
pnpm add @loro-dev/peer-lease
# or
npm install @loro-dev/peer-lease
```
## Usage
```ts
import { LoroDoc } from "loro-crdt";
import { acquirePeerId, tryReuseLoroPeerId } from "@loro-dev/peer-lease";
const doc = new LoroDoc();
// ... Import local data into doc first
const lease = await acquirePeerId(
"doc-123",
() => new LoroDoc().peerIdStr,
JSON.stringify(doc.frontiers()),
(a, b) => {
const fA = JSON.parse(a);
const fB = JSON.parse(b);
return doc.cmpFrontiers(fA, fB);
},
);
try {
console.log("Using peer", lease.value);
doc.setPeerId(lease.value);
// use doc here...
} finally {
await lease.release(JSON.stringify(doc.frontiers()));
// Or use FinalizeRegistry to release the lease
// Note: release can be invoked exactly once; a second call throws.
}
// Later, when you reopen the same document, try to reuse the cached peer id
const reuseHandle = await tryReuseLoroPeerId("doc-123", doc);
try {
// doc.peerIdStr now matches the previously leased id when the cache is still valid
console.log("Reused peer", reuseHandle.value);
} finally {
await reuseHandle.release();
}
```
The first argument is the document identifier that scopes locking and cache entries, ensuring leases only coordinate with peers working on the same document.
`acquirePeerId` first tries to coordinate through the [Web Locks API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API). When that API is unavailable it falls back to a localStorage-backed mutex with a TTL, heartbeat refresh, and release notifications. A released ID is cached together with the document version that produced it and is only handed out when the caller proves their version has advanced, preventing stale edits from reusing a peer ID.
`tryReuseLoroPeerId(docId, doc)` wraps the caching flow so you can reopen a document and automatically load the most recent peer ID if the stored frontiers prove the local state is up to date. The returned handle exposes `release()`, `isReleased()`, and `value`; its `release()` method resolves once the lease is flushed and it is safe to call from synchronous lifecycle handlers thanks to internal staging of the latest document version:
```ts
window.addEventListener("pagehide", () => {
// Only synchronous work is allowed here; reuseHandle.release() stages the data right away.
reuseHandle.release();
});
window.addEventListener("pageshow", () => {
if (!reuseHandle.isReleased()) {
return;
}
// Optionally restart peer work if the page returned from BFCache.
});
```
`reuseHandle.release()` writes the lease outcome to synchronous storage before returning, so browsers terminating the page (e.g. during `pagehide` on mobile) still mark the peer ID available even if the returned promise never resolves. If the page survives the lifecycle event, you can still `await reuseHandle.release()` later; repeated calls reuse the same promise and do not restage.
To wire these lifecycle hooks without repeating boilerplate, pass the same handle to the helper exported as `attachPeerLeaseLifecycle`:
```ts
import { attachPeerLeaseLifecycle } from "@loro-dev/peer-lease";
const detachLifecycle = attachPeerLeaseLifecycle({
release: reuseHandle,
doc,
onResume: async () => {
// Re-acquire a lease or restart transports when the tab resumes from BFCache.
}
});
// Later, when tearing down the document entirely
detachLifecycle();
```
The helper stages the latest frontiers while the page is visible, calls `reuseHandle.release()` during `pagehide`, and invokes `onResume` after `pageshow` if the handle was released. Provide an `onFreeze` callback if you need to pause background work when a BFCache transition is detected.
## Coordination strategy
- **Lock negotiation** – Calls use `navigator.locks.request` in supporting browsers so the lease state is mutated under an exclusive Web Lock. Fallback tabs use a fencing localStorage record with TTL heartbeats, and wake waiters via `storage` events plus a `BroadcastChannel`.
- **Version gating** – Every lease carries document metadata. We only recycle a peer ID after the releasing tab supplies the version it used, and a future caller provides a strictly newer version according to the supplied comparator. This stops pre-load editing sessions from replaying IDs once the real document snapshot arrives.
- **Explicit release** – A lease is only recycled when the releasing tab provides its final version metadata. If a tab crashes or never releases, the ID stays reserved so it cannot be handed out again accidentally; any lease left active for 24 hours is simply discarded instead of being returned to the available pool.
### Lock implementation details
When Web Locks are available the mutex is just a thin wrapper around `navigator.locks.request`, enforcing an acquire timeout. In browsers without that API we fall back to a localStorage-backed mutex that writes a JSON record containing a token, fence, and expiry. The holder extends the expiry with a heartbeat (a `setInterval` that calls `refresh`) so long tasks don’t lose the lock, while waiters observe the fence value and `storage`/`BroadcastChannel` notifications to wake up promptly. If the tab crashes the record expires after `lockTtlMs`, letting another peer take over without manual cleanup.
The mutex implementation is exported so advanced users can coordinate other shared state:
```ts
import { createMutex, type AsyncMutex } from "@loro-dev/peer-lease";
const mutex: AsyncMutex = createMutex({
storage: window.localStorage,
lockKey: "my-lock",
fenceKey: "my-lock:fence",
channelName: "my-lock:channel",
webLockName: "my-lock:web",
options: {
lockTtlMs: 10_000,
acquireTimeoutMs: 5_000,
retryDelayMs: 40,
retryJitterMs: 60,
},
});
await mutex.runExclusive(async () => {
// critical section
});
```
You can reuse the same mutex that `acquirePeerId` does by passing the document id to keep coordination scoped per document.
## Development
- `pnpm install` – install dependencies
- `pnpm build` – produce ESM/CJS/d.ts bundles via tsdown
- `pnpm dev` – run tsdown in watch mode
- `pnpm test` – run Vitest
- `pnpm lint` – run oxlint
- `pnpm typecheck` – run the TypeScript compiler without emitting files
- `pnpm check` – type check, lint, update snapshots, and test
## Release workflow
- Push Conventional Commits to `main`; Release Please opens or updates a release PR with the changelog and semver bump.
- Merging that PR tags the release and triggers `.github/workflows/publish-on-tag.yml`, which publishes to npm using `NODE_AUTH_TOKEN` derived from the `NPM_TOKEN` secret.
- Publish provenance is enabled via `.npmrc` and `publishConfig.provenance`.
## Continuous integration
The `CI` workflow installs dependencies, lints, type-checks, runs Vitest in run mode, and builds the library on pushes and pull requests.