turn-taker
v0.1.1
Published
This repository explores managed cross‑tab turn‑taking (via SharedWorker) for coordinating access to shared resources within a single origin, including OPFS‑backed workflows.
Readme
🚧 Experimental / Research Notice
This repository explores managed cross‑tab turn‑taking (via SharedWorker) for coordinating access to shared resources within a single origin, including OPFS‑backed workflows.
In most cases, you don’t need an extra arbiter for OPFS writes: OPFS enforces exclusive write access per file. Only one context can hold a write handle to the same file at a time, so concurrent writers to a single file are prevented by the platform.
What this means for you:
- Not production‑ready and likely to remain research‑only.
- No stability guarantees; APIs and behavior may change.
- Limited hardening for adversarial or crashy environments.
- Best used for learning, prototyping, and comparing approaches.
When can a turn‑taking layer still help?
- Coordinating multi‑file operations you want to treat as a unit (ordering/fairness).
- Scheduling expensive work across tabs to avoid thrash or starvation.
- Applying global fairness across logical resources beyond a single file.
- Bridging OPFS with non‑atomic operations where a single queue improves UX.
If you need cross‑tab coordination in production, start with OPFS’s per‑file exclusion. For higher‑level scheduling, consider the Web Locks API (with logical resource keys) or a small BroadcastChannel + leader‑election pattern; reach for a custom turn‑taking layer only if those aren’t sufficient.
TurnTaker Core (turn-taker)
Type-safe coordination for exclusive access across tabs using a SharedWorker. Ideal for OPFS, shared state, or any mutual-exclusion workflow.
Monorepo note: this file documents the published library package. For repo-wide setup, dev, and the demo app, see the workspace root README.md.
Features
- 🔄 Fair turn-taking (FIFO) with optional priority and preemption
- 🌐 Multi-tab coordination via SharedWorker
- 🧵 Multiple concurrent requests per participant via awaitable RequestHandles
- ⚡ Real-time updates: global + per-request events
- 🛡️ Structured error codes with end-to-end propagation
- 🫶 Metadata on grant/release for richer auditing
- 🫡 Heartbeat monitoring and stale participant cleanup
- 🛰️ TURN_CHANGE broadcasts so all tabs update immediately
Installation
npm install turn-takerCopy the prebuilt SharedWorker into your app's public folder (so the browser can load it):
npx turntaker-copy-worker ./publicQuick Start
1. Set up the SharedWorker
You can integrate the SharedWorker in two ways:
Option A — Static URL (SSR/static hosting)
- Copy the prebuilt worker file somewhere your app can serve it and pass that URL to the client.
- You can use the provided bin to copy the file:
npx turntaker-copy-worker ./public/turntaker-worker.js
import { TurnTakerWorker } from "turn-taker/worker";
// If you need to customize, you can also create your own worker script.Option B — Inline Blob URL (no static hosting assumption)
Generate a Blob URL at runtime and use that as the worker URL:
import { createInlineSharedWorkerURL } from "turn-taker";
const workerUrl = createInlineSharedWorkerURL();
// pass workerUrl to the clientIf you want to roll your own worker script, here’s a canonical version:
import { TurnTakerWorker } from "turn-taker/worker";
// Initialize with optional configuration
const worker = new TurnTakerWorker({
turnTimeout: 30000, // 30 seconds max turn time
enablePriority: true, // Enable priority queuing
enablePreemption: false, // Disable turn preemption
maxQueueSize: 100, // Max 100 pending requests
heartbeatInterval: 5000, // 5 second heartbeat
});
// Tip (tests): append ?wid=... to isolate worker instances per test run.
// The worker uses this to name its global scope.2. Use the Client Library
import { TurnTakerClient, createInlineSharedWorkerURL } from "turn-taker";
const client = new TurnTakerClient({
// Either a static URL you serve, or the inline blob URL generated at runtime
url: createInlineSharedWorkerURL(),
// optional isolation knobs:
// scope: "app",
// resourceId: "file-42",
});
client.addEventListener("connected", async () => {
// Request a turn (awaitable RequestHandle)
const handle = client.requestTurn({ priority: 1 });
// Per-request events
handle.on("queued", (e) => console.log("position", e.detail.position));
handle.on("granted", (e) => console.log("granted", e.detail));
handle.on("released", () => console.log("released"));
// Await grant
await handle;
// Do exclusive work here
await performExclusiveOperation();
// Release the granted request via its handle
await handle.release({ result: "success" });
});
// Global events
client.addEventListener("turn-change", (e) => {
console.log("Current turn:", (e as CustomEvent).detail.currentTurn);
});
client.addEventListener("queue-update", (e) => {
console.log("Queue:", (e as CustomEvent).detail.queueStatus);
});
client.addEventListener("tt-error", (e) => {
console.warn("TurnTaker error:", (e as CustomEvent).detail);
});Real-World Example: OPFS File Coordination
import { TurnTakerClient } from "turn-taker";
class SafeFileManager {
constructor() {
this.client = new TurnTakerClient("/turntaker-worker.js");
this.setupEventHandlers();
}
setupEventHandlers() {
this.client.addEventListener("connected", () => {
console.log(`Connected as ${this.client.getParticipantId()}`);
});
this.client.addEventListener("turn-revoked", (event) => {
console.warn("Turn was revoked:", event.detail.reason);
// Clean up any open file handles
});
}
async writeFile(filename, data) {
try {
// Request exclusive access
const handle = this.client.requestTurn({
priority: 1,
metadata: { operation: "write", filename },
});
await handle;
// Now we have exclusive access to OPFS
const opfsRoot = await navigator.storage.getDirectory();
const fileHandle = await opfsRoot.getFileHandle(filename, {
create: true,
});
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
console.log(`Successfully wrote ${data.length} bytes to ${filename}`);
// Release the request via its handle
await handle.release({
operation: "write",
filename,
bytesWritten: data.length,
});
return true;
} catch (error) {
// Always cleanup on error
if (this.client.hasTurn()) await this.client.releaseAll();
throw error;
}
}
async readFile(filename) {
try {
const handle = this.client.requestTurn({
priority: 0, // Lower priority for reads
metadata: { operation: "read", filename },
});
await handle;
const opfsRoot = await navigator.storage.getDirectory();
const fileHandle = await opfsRoot.getFileHandle(filename);
const file = await fileHandle.getFile();
const content = await file.text();
await handle.release({
operation: "read",
filename,
bytesRead: content.length,
});
return content;
} catch (error) {
if (this.client.hasTurn()) await this.client.releaseAll();
throw error;
}
}
}
// Usage
const fileManager = new SafeFileManager();
await fileManager.writeFile("data.txt", "Hello World!");
const content = await fileManager.readFile("data.txt");API Reference
TurnTakerClient
Constructor
new TurnTakerClient(options?: {
url?: string; // worker URL (default: "/turntaker-worker.js")
scope?: string; // SharedWorker name for isolation (optional)
resourceId?: string;// route to a specific queue inside the worker (optional)
})Methods
requestTurn(options?) => RequestHandle
Request exclusive access to the shared resource.
const handle = client.requestTurn({
priority?: number, // higher = sooner (default: 0)
timeout?: number, // ms; overrides config.turnTimeout
metadata?: any, // echoed back on grant/release
});
// RequestHandle helpers
await handle; // resolves on grant -> { requestId, grantedAt, timeout?, metadata? }
handle.getId(); // number | undefined (once queued)
await handle.cancel(); // cancel if still queued
handle.status(); // snapshot: pending|queued|granted|released|revoked|error
handle.on(type, listener); // per-request events: queued|granted|released|revoked|error
handle.off(type, listener);withTurn(options, fn, hooks?) => Promise<T>
Run a function while safely holding the turn. Always releases in a finally block.
const result = await client.withTurn(
{ priority: 5, metadata: { op: "write" } },
async (handle) => {
// handle is the RequestHandle for this request (events, status, etc.)
// This function runs only while you hold the turn.
await performExclusiveOperation();
return "ok" as const;
},
{
// Optional: payload to include on release
release: { result: "ok" },
// Optional: invoked if your turn is revoked (e.g., preempted)
onRevoked(info) {
console.warn("Turn revoked:", info.reason);
// cleanup, flush partial work, etc.
},
}
);Notes:
- Ensures release even if
fnthrows; the error is rethrown after releasing. - If the turn is revoked during execution,
onRevokedruns beforewithTurnrejects. handlesupports per-request events:queued|granted|released|revoked|error.
handle.release(result?)
Release the currently granted request using its handle.
await handle.release({
result?: any, // Result data from your operation
metadata?: any // Additional metadata
});Note: Release is done via the request handle; TurnTakerClient.releaseTurn() has been removed.
releaseAll()
Release the current turn (if held) and cancel all queued requests for this client.
const summary = await client.releaseAll();
// { released: boolean, canceled: number }configure(config)
Update the worker configuration.
client.configure({
turnTimeout: 60000,
enablePriority: true,
maxQueueSize: 50,
});getStatus()
Get current system status.
const status = await client.getStatus();
console.log(status.currentTurn); // Current turn holder info
console.log(status.queueStatus); // Queue information
console.log(status.participantCount); // Number of connected tabsUtility Methods
client.hasTurn(); // Returns true if you currently have the turn
client.isConnected(); // Returns true if connected to worker
client.getParticipantId(); // Returns your unique participant ID
client.releaseIfHeld(result?); // Attempts a safe release only if you currently hold the turn
client.disconnect(options?); // Disconnect from the workerdisconnect(options?)
client.disconnect({
releaseIfHeld: true, // if holding a turn, release first to avoid dangling locks
});Events
Listen for real-time updates:
client.addEventListener("connected", (event) => {
console.log("Connected to TurnTaker");
});
client.addEventListener("turn-granted", (event) => {
console.log("Turn granted:", event.detail);
});
client.addEventListener("turn-released", (event) => {
console.log("Turn released:", event.detail);
});
client.addEventListener("turn-revoked", (event) => {
console.log("Turn was revoked:", event.detail.reason);
});
client.addEventListener("queue-update", (event) => {
console.log("Queue status:", event.detail.queueStatus);
});
client.addEventListener("turn-change", (event) => {
console.log("Turn changed hands:", event.detail.currentTurn);
});
client.addEventListener("tt-error", (event) => {
console.error("TurnTaker error:", (event as CustomEvent).detail);
});Configuration Options
interface TurnTakerConfig {
turnTimeout: number; // Max time a turn can be held (default: 30000ms)
enablePriority: boolean; // Enable priority-based queuing (default: false)
enablePreemption: boolean; // Allow high-priority requests to preempt (default: false)
maxQueueSize: number; // Maximum number of queued requests (default: 100)
heartbeatInterval: number; // Heartbeat interval for connection monitoring (default: 5000ms)
}
Notes:
- Changing `heartbeatInterval` takes effect immediately in the worker’s monitor.
- `enablePriority` reorders only queued requests; with `enablePreemption` true,
a higher-priority request can revoke the current holder.Advanced Usage
Priority-Based Queuing
When enablePriority is true, requests with higher priority numbers are processed first:
// Low priority background task
await client.requestTurn({ priority: 0 });
// Normal user action
await client.requestTurn({ priority: 5 });
// Critical system operation
await client.requestTurn({ priority: 10 });Turn Preemption
When enablePreemption is true, higher priority requests can interrupt lower priority ones:
// Configure worker with preemption
client.configure({
enablePriority: true,
enablePreemption: true,
});
// Critical request will preempt lower priority turns
await client.requestTurn({
priority: 10,
metadata: { urgent: true },
});Timeout Handling
Set custom timeouts for specific operations:
// Long-running operation with extended timeout
await client.requestTurn({
timeout: 120000, // 2 minutes
metadata: { operation: "bulk-import" },
});Error Handling & Recovery
Errors are structured and propagated via the global tt-error event and per-request error events. Example error code:
E_CANNOT_CANCEL_GRANTED— attempting to cancel a request that’s already active.
Branch on code to shape UX:
client.addEventListener("tt-error", (e) => {
const err = (e as CustomEvent).detail;
if (err.code === "E_CANNOT_CANCEL_GRANTED") {
// disable cancel, show a message, etc.
}
});Metadata Propagation
Attach context to your requests and receive it back on grant/release:
await client.requestTurn({
priority: 2,
metadata: { operation: "sync", batchId: "b42" },
});
client.addEventListener("turn-granted", (e) => {
console.log("granted with metadata", (e as CustomEvent).detail.metadata);
});Heartbeats & Stale Cleanup
The client sends periodic heartbeats. If a tab stops heartbeating, the worker removes it
after a grace period, advancing the queue automatically. You can also call disconnect()
to leave explicitly; if you hold the turn, release before disconnecting.
TURN_CHANGE Broadcasts
Every time the current turn changes, the worker emits a broadcast so all tabs can update their UI without polling.
Patterns
Multiple concurrent requests
const a = client.requestTurn({ metadata: { name: "A" } });
const b = client.requestTurn({ metadata: { name: "B" } });
a.on("queued", (e) => console.log("A queued at", e.detail.position));
b.on("queued", (e) => console.log("B queued at", e.detail.position));
await a; // whichever is granted first
await a.release();
// Optionally, cancel remaining queued
await client.releaseAll();withTurn helper
async function withTurn<T>(client: any, fn: () => Promise<T>) {
const handle = client.requestTurn();
await handle;
try {
return await fn();
} finally {
if (client.hasTurn()) await handle.release();
}
}Scope vs ResourceId
- scope: SharedWorker name; isolates worker instances at the browser level. Use for test isolation or multi-tenant separation.
- resourceId: logical queue key inside one worker. Use to shard by file/document/etc.
Examples:
// Same worker (scope), different queues (resourceId)
const a = new TurnTakerClient({
url: "/turntaker-worker.js",
scope: "app",
resourceId: "doc:1",
});
const b = new TurnTakerClient({
url: "/turntaker-worker.js",
scope: "app",
resourceId: "doc:2",
});
// Different workers (different scope)
const test1 = new TurnTakerClient({
url: "/turntaker-worker.js",
scope: "wid-123",
});
const test2 = new TurnTakerClient({
url: "/turntaker-worker.js",
scope: "wid-456",
});Architecture
TurnTaker uses a clean separation of concerns:
- TurnTakerCore: Pure turn-taking logic (testable, framework-agnostic)
- TurnTakerWorker: SharedWorker integration layer
- TurnTakerClient: Browser client with event-driven API
This architecture enables:
- ✅ Comprehensive unit testing of core logic
- ✅ Easy integration with different transport layers
- ✅ Clear separation between business logic and web APIs
- ✅ Framework-agnostic design
Worker bundling
The package ships both the library and a pre-bundled SharedWorker script under
packages/core/dist/worker-bundle/turntaker-worker.js. The demo app copies this to
apps/web/public/turntaker-worker.js via apps/web/scripts/copy-worker.mjs.
If you host the worker yourself, ensure it’s served as a standalone JS file and referenced by URL (same-origin is recommended for SharedWorker).
Performance
TurnTaker is designed for high performance:
- Lightweight: ~15KB minified + gzipped
- Efficient: O(log n) queue operations with priority support
- Scalable: Tested with 1000+ concurrent participants
- Memory-conscious: Automatic cleanup of stale participants
Browser Support
- Chrome 80+ (SharedWorker, OPFS support)
- Firefox 79+ (SharedWorker support)
- Safari 16.4+ (SharedWorker support)
- Edge 80+ (SharedWorker support)
TypeScript Support
TurnTaker is written in TypeScript and includes full type definitions:
import { TurnTakerClient, TurnTakerConfig } from "turn-taker";
const client: TurnTakerClient = new TurnTakerClient("/worker.js");
const config: TurnTakerConfig = {
turnTimeout: 30000,
enablePriority: true,
enablePreemption: false,
maxQueueSize: 100,
heartbeatInterval: 5000,
};Testing
TurnTaker includes a comprehensive test suite. The separated architecture makes unit testing straightforward:
import { TurnTakerCore } from "turn-taker/core";
describe("TurnTaker Core", () => {
it("should grant turns in FIFO order", () => {
const core = new TurnTakerCore();
core.addParticipant("p1");
core.addParticipant("p2");
const request1 = core.requestTurn("p1");
const request2 = core.requestTurn("p2");
expect(request1.position).toBe(0); // Granted immediately
expect(request2.position).toBe(1); // Queued
});
});
### End-to-end (Playwright)
The web app under `apps/web` exposes a `/turns` test page. Tests spin up multiple pages
to simulate tabs and isolate the SharedWorker per test by adding `?wid=<unique>` to the
URL (the page passes this through to the worker URL). This eliminates cross-test leakage
and greatly improves stability across Chromium/Firefox/WebKit.
Tips for reliable tests:
- Prefer explicit disconnect over closing the page to trigger immediate cleanup.
- Allow longer timeouts on slower engines (WebKit/Firefox) for worker spin-up.
- Wait for “position” to be set before asserting handoffs from timeouts.Common Use Cases
OPFS File Coordination
Prevent "file already open" errors when multiple tabs access the same files.
Database Transactions
Coordinate access to IndexedDB or other browser storage mechanisms.
Shared State Management
Ensure atomic updates to shared application state.
Resource-Intensive Operations
Limit concurrent execution of CPU/memory intensive tasks.
Network Request Coordination
Prevent duplicate API requests from multiple tabs.
Best Practices
- Always release turns: Use try/finally blocks to ensure turns are released
- Set appropriate timeouts: Match timeouts to your operation duration
- Use metadata: Add context to help with debugging and monitoring
- Handle revocation gracefully: Always listen for turn-revoked events
- Consider priority carefully: Use priority sparingly to maintain fairness
- Monitor queue status: Watch for queue buildup that might indicate issues
License
MIT License - see LICENSE file for details.
Contributing
Contributions are welcome! Please read CONTRIBUTING.md for guidelines.
Changelog
See CHANGELOG.md for version history and updates.
