diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fb0ece..42fc924 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ jobs: - js-libp2p-example-transports - js-libp2p-example-webrtc-private-to-private - js-libp2p-example-webrtc-private-to-public + - js-libp2p-example-yjs-libp2p defaults: run: working-directory: examples/${{ matrix.project }} @@ -47,6 +48,15 @@ jobs: run: npm install - name: Install Playwright run: npx -y playwright install --with-deps + - name: Build project (if build script exists) + run: | + if npm run | grep -q "build"; then + npm run build + else + echo "No build script found, skipping build" + fi + env: + CI: true - name: Run tests run: npm run test env: @@ -64,6 +74,10 @@ jobs: run: npm install - name: Install Playwright run: npx -y playwright install --with-deps + - name: Build projects + run: npm run build + env: + CI: true - name: Run linting run: npm run lint env: @@ -97,6 +111,7 @@ jobs: - js-libp2p-example-transports - js-libp2p-example-webrtc-private-to-private - js-libp2p-example-webrtc-private-to-public + - js-libp2p-example-yjs-libp2p steps: - uses: convictional/trigger-workflow-and-wait@f69fa9eedd3c62a599220f4d5745230e237904be with: diff --git a/examples/js-libp2p-example-yjs-libp2p/.gitignore b/examples/js-libp2p-example-yjs-libp2p/.gitignore new file mode 100644 index 0000000..d1e8a6f --- /dev/null +++ b/examples/js-libp2p-example-yjs-libp2p/.gitignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +.vite/ + +# Test artifacts +test/relay-info.json +playwright-report/ +test-results/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment variables +.env +.env.local + +# Editor directories and files +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# OS files +.DS_Store +Thumbs.db diff --git a/examples/js-libp2p-example-yjs-libp2p/LICENSE b/examples/js-libp2p-example-yjs-libp2p/LICENSE new file mode 100644 index 0000000..20ce483 --- /dev/null +++ b/examples/js-libp2p-example-yjs-libp2p/LICENSE @@ -0,0 +1,4 @@ +This project is dual licensed under MIT and Apache-2.0. + +MIT: https://www.opensource.org/licenses/mit +Apache-2.0: https://www.apache.org/licenses/license-2.0 diff --git a/examples/js-libp2p-example-yjs-libp2p/LICENSE-APACHE b/examples/js-libp2p-example-yjs-libp2p/LICENSE-APACHE new file mode 100644 index 0000000..14478a3 --- /dev/null +++ b/examples/js-libp2p-example-yjs-libp2p/LICENSE-APACHE @@ -0,0 +1,5 @@ +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/examples/js-libp2p-example-yjs-libp2p/LICENSE-MIT b/examples/js-libp2p-example-yjs-libp2p/LICENSE-MIT new file mode 100644 index 0000000..72dc60d --- /dev/null +++ b/examples/js-libp2p-example-yjs-libp2p/LICENSE-MIT @@ -0,0 +1,19 @@ +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/examples/js-libp2p-example-yjs-libp2p/README.md b/examples/js-libp2p-example-yjs-libp2p/README.md new file mode 100644 index 0000000..e3a92b3 --- /dev/null +++ b/examples/js-libp2p-example-yjs-libp2p/README.md @@ -0,0 +1,166 @@ +# @libp2p/example-yjs-libp2p + +A collaborative text editor built with Yjs and libp2p, demonstrating real-time peer-to-peer document synchronization. + +## Table of Contents + +- [Overview](#overview) +- [Architecture](#architecture) +- [Setup](#setup) +- [Usage](#usage) +- [How It Works](#how-it-works) +- [Key Features](#key-features) +- [License](#license) + +## Overview + +This example demonstrates how to create a Yjs connection provider using libp2p instead of the standard y-webrtc connector. It showcases: + +- **Custom Yjs Provider**: A libp2p-based connection provider for Yjs +- **WebRTC Support**: Direct peer-to-peer connections using WebRTC +- **Circuit Relay**: NAT traversal via relay servers +- **DCUTR**: Direct Connection Upgrade through Relay (hole punching) +- **AutoNAT**: Automatic NAT detection +- **PubSub**: GossipSub for document synchronization +- **Peer Discovery**: Automatic connection to discovered peers + +## Architecture + +``` +┌─────────────┐ ┌─────────────┐ +│ Browser 1 │ │ Browser 2 │ +│ │ │ │ +│ Yjs Doc ←──┼─────────┼──→ Yjs Doc │ +│ ↕ │ WebRTC │ ↕ │ +│ libp2p │ or │ libp2p │ +│ (pubsub) │ Relay │ (pubsub) │ +└──────┬──────┘ └──────┬──────┘ + │ │ + │ ┌─────────────┐ │ + └────┤ Relay Node │────┘ + │ (relay.js) │ + └─────────────┘ +``` + +## Setup + +1. Install dependencies: +```bash +npm install +``` + +2. Start the relay server: +```bash +npm run relay +``` + +The relay will output its multiaddr, which looks like: +``` +/ip4/127.0.0.1/tcp/53472/ws/p2p/12D3KooWABC123... +``` + +3. Start the development server: +```bash +npm start +``` + +4. Open http://localhost:5173 in multiple browser tabs or windows + +## Usage + +1. Copy the relay multiaddr from the terminal output +2. Paste it into the "Relay multiaddr" field in the browser +3. Keep the default topic or enter a custom one +4. Click "Connect" +5. Start typing in the text area +6. Open another browser tab/window, connect to the same relay and topic +7. Changes will sync automatically between all connected peers + +### Debug Mode + +To enable verbose logging: + +**Relay server:** +```bash +npm run relay:debug +``` + +**Browser client:** +Add `?debug=true` to the URL: +``` +http://localhost:5173/?debug=true +``` + +## How It Works + +### Libp2p Configuration + +The browser clients are configured with: + +- **Transports**: WebSockets (for relay), WebRTC (for direct P2P), Circuit Relay +- **Security**: Noise protocol for encryption +- **Stream Muxing**: Yamux +- **Services**: + - `identify`: Peer identification + - `autoNAT`: NAT detection + - `dcutr`: Hole punching for direct connections + - `pubsub`: GossipSub for broadcasting document updates + +### Yjs Integration + +The custom `Libp2pProvider` class: + +1. **Subscribes** to a pubsub topic for the Yjs document +2. **Listens** for Yjs document updates and broadcasts them via pubsub +3. **Receives** updates from other peers and applies them to the local document +4. **Discovers** peers subscribing to the same topic +5. **Connects** directly to discovered peers (using WebRTC when possible) +6. **Syncs** initial state using Yjs's state vector protocol + +### Message Types + +The provider uses three message types: + +- `update`: Broadcasts document changes to all peers +- `sync-request`: Requests the current document state (sent on join) +- `sync-response`: Sends the current state to a requesting peer + +### Peer Discovery Flow + +1. Client connects to relay server via WebSocket +2. Client subscribes to the pubsub topic +3. Relay forwards pubsub messages between peers +4. When a peer subscribes to the same topic, both peers discover each other +5. Peers attempt direct WebRTC connections (using DCUTR for NAT traversal) +6. If direct connection fails, communication continues through the relay + +## Key Features + +### 🔗 Decentralized Architecture +No central server required - peers communicate directly when possible + +### 🌐 NAT Traversal +Automatic hole punching via DCUTR for direct connections behind NATs + +### 🔄 Real-time Sync +Changes propagate instantly to all connected peers + +### 📡 Efficient Messaging +Uses Yjs's state-based CRDT for minimal bandwidth usage + +### 🔌 Relay Fallback +Falls back to relay when direct connections aren't possible + +### 🤝 Auto-discovery +Peers automatically discover and connect to each other + +## License + +Licensed under either of + +- Apache 2.0, ([LICENSE-APACHE](LICENSE-APACHE) / ) +- MIT ([LICENSE-MIT](LICENSE-MIT) / ) + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. diff --git a/examples/js-libp2p-example-yjs-libp2p/constants.js b/examples/js-libp2p-example-yjs-libp2p/constants.js new file mode 100644 index 0000000..914b4e9 --- /dev/null +++ b/examples/js-libp2p-example-yjs-libp2p/constants.js @@ -0,0 +1,48 @@ +/** + * Configuration constants for the Yjs + libp2p application + */ + +// Debug mode - set via environment variable or query parameter +export const DEBUG = new URLSearchParams(window?.location?.search).get('debug') === 'true' || false + +// Network timeouts (milliseconds) +export const TIMEOUTS = { + RELAY_CONNECTION: 20000, + PROTOCOL_NEGOTIATION_INBOUND: 10000, + PROTOCOL_NEGOTIATION_OUTBOUND: 10000, + UPGRADE_INBOUND: 10000, + UPGRADE_OUTBOUND: 10000, + EDITOR_READY: 10000, + PEER_DISCOVERY: 15000 +} + +// Pubsub intervals (milliseconds) +export const INTERVALS = { + PUBSUB_PEER_DISCOVERY: 10000, + GOSSIPSUB_HEARTBEAT: 1000, + INITIAL_SYNC_REQUEST: 1000, + PEER_CHECK: 2000 +} + +// Relay server configuration +export const RELAY_CONFIG = { + HOP_TIMEOUT: 30000, + MAX_RESERVATIONS: 1000, + RESERVATION_TTL: 2 * 60 * 60 * 1000, // 2 hours + DEFAULT_DATA_LIMIT: BigInt(1024 * 1024 * 1024), // 1 GB + DEFAULT_DURATION_LIMIT: 2 * 60 * 1000, // 2 minutes + MAX_CONNECTIONS: 1000, + MAX_INCOMING_PENDING: 100, + MAX_PEER_ADDRS_TO_DIAL: 100, + DIAL_TIMEOUT: 30000 +} + +// Pubsub discovery configuration +export const PUBSUB_DISCOVERY = { + TOPICS: ['_peer-discovery._p2p._pubsub'] +} + +// Default values +export const DEFAULTS = { + TOPIC: 'yjs-doc-1' +} diff --git a/examples/js-libp2p-example-yjs-libp2p/index.html b/examples/js-libp2p-example-yjs-libp2p/index.html new file mode 100644 index 0000000..9ee41ae --- /dev/null +++ b/examples/js-libp2p-example-yjs-libp2p/index.html @@ -0,0 +1,37 @@ + + + + + + Yjs + libp2p Example + + + +

Yjs + libp2p

+
+ + + +
+ +
+ +
+

+    
+  
+
diff --git a/examples/js-libp2p-example-yjs-libp2p/index.js b/examples/js-libp2p-example-yjs-libp2p/index.js
new file mode 100644
index 0000000..2afd139
--- /dev/null
+++ b/examples/js-libp2p-example-yjs-libp2p/index.js
@@ -0,0 +1,320 @@
+import { gossipsub } from '@chainsafe/libp2p-gossipsub'
+import { noise } from '@chainsafe/libp2p-noise'
+import { yamux } from '@chainsafe/libp2p-yamux'
+import { autoNAT } from '@libp2p/autonat'
+import { circuitRelayTransport } from '@libp2p/circuit-relay-v2'
+import { dcutr } from '@libp2p/dcutr'
+import { identify, identifyPush } from '@libp2p/identify'
+import { pubsubPeerDiscovery } from '@libp2p/pubsub-peer-discovery'
+import { webRTC } from '@libp2p/webrtc'
+import { webSockets } from '@libp2p/websockets'
+import * as filters from '@libp2p/websockets/filters'
+import { multiaddr } from '@multiformats/multiaddr'
+import { createLibp2p } from 'libp2p'
+import * as Y from 'yjs'
+import { DEBUG, TIMEOUTS, INTERVALS, PUBSUB_DISCOVERY } from './constants.js'
+import { Libp2pProvider } from './yjs-libp2p-provider.js'
+
+// UI elements
+const relayInput = document.getElementById('relay')
+const topicInput = document.getElementById('topic')
+const connectBtn = document.getElementById('connect')
+const editor = document.getElementById('editor')
+const logEl = document.getElementById('log')
+const peersEl = document.getElementById('peers')
+const peerCountEl = document.getElementById('peer-count')
+const peerListEl = document.getElementById('peer-list')
+
+let libp2pNode
+let yjsDoc
+let provider
+let text
+
+/**
+ * Logs a message to both console and UI.
+ *
+ * @param {string} message - Message to log
+ * @param {boolean} [isError] - Whether this is an error message
+ */
+const log = (message, isError = false) => {
+  if (DEBUG) {
+    // eslint-disable-next-line no-console
+    console.log(message)
+  }
+  logEl.textContent += message + '\n'
+  logEl.scrollTop = logEl.scrollHeight
+
+  if (isError) {
+    logEl.style.color = '#d32f2f'
+  } else {
+    logEl.style.color = 'inherit'
+  }
+}
+
+/**
+ * Updates the peer display UI with current connections.
+ */
+const updatePeerDisplay = () => {
+  if (!libp2pNode) {
+    return
+  }
+
+  const connections = libp2pNode.getConnections()
+  const peerMap = new Map()
+
+  // Group connections by peer
+  for (const conn of connections) {
+    const peerId = conn.remotePeer.toString()
+    if (!peerMap.has(peerId)) {
+      peerMap.set(peerId, [])
+    }
+
+    const remoteAddr = conn.remoteAddr.toString()
+    let transport = 'unknown'
+
+    if (remoteAddr.includes('/p2p-circuit')) {
+      transport = 'relay'
+    } else if (remoteAddr.includes('/webrtc')) {
+      transport = 'webrtc'
+    } else if (remoteAddr.includes('/wss') || remoteAddr.includes('/tls/ws')) {
+      transport = 'websocket-secure'
+    } else if (remoteAddr.includes('/ws')) {
+      transport = 'websocket'
+    }
+
+    peerMap.get(peerId).push({ transport, addr: remoteAddr })
+  }
+
+  // Update count
+  peerCountEl.textContent = peerMap.size
+
+  // Show/hide peers section
+  if (peerMap.size > 0) {
+    peersEl.style.display = 'block'
+  } else {
+    peersEl.style.display = 'none'
+  }
+
+  // Update peer list
+  peerListEl.innerHTML = ''
+  for (const [peerId, transports] of peerMap) {
+    const peerDiv = document.createElement('div')
+    peerDiv.className = 'peer'
+
+    const peerIdSpan = document.createElement('div')
+    peerIdSpan.className = 'peer-id'
+    peerIdSpan.textContent = peerId
+    peerDiv.appendChild(peerIdSpan)
+
+    const transportDiv = document.createElement('div')
+
+    // Show each connection with its transport
+    for (const { transport, addr } of transports) {
+      const badge = document.createElement('span')
+      badge.className = 'transport'
+      badge.textContent = transport
+      badge.title = addr // Show full address on hover
+      transportDiv.appendChild(badge)
+    }
+
+    peerDiv.appendChild(transportDiv)
+
+    peerListEl.appendChild(peerDiv)
+  }
+}
+
+/**
+ * Validates a multiaddr string format.
+ *
+ * @param {string} addr - Multiaddr to validate
+ * @returns {boolean}
+ */
+const isValidMultiaddr = (addr) => {
+  try {
+    multiaddr(addr)
+    return true
+  } catch {
+    return false
+  }
+}
+
+// Connect button handler
+connectBtn.onclick = async () => {
+  if (libp2pNode) {
+    log('Already connected')
+    return
+  }
+
+  const relayAddr = relayInput.value.trim()
+  if (!relayAddr) {
+    log('Please enter a relay multiaddr', true)
+    return
+  }
+
+  if (!isValidMultiaddr(relayAddr)) {
+    log('Invalid multiaddr format', true)
+    return
+  }
+
+  const topic = topicInput.value.trim()
+  if (!topic) {
+    log('Please enter a topic', true)
+    return
+  }
+
+  try {
+    connectBtn.disabled = true
+    log('Creating libp2p node...')
+
+    // Create libp2p node with WebRTC, relay, and pubsub
+    libp2pNode = await createLibp2p({
+      addresses: {
+        listen: ['/p2p-circuit', '/webrtc']
+      },
+      transports: [
+        webSockets({ filter: filters.all }),
+        webRTC({
+          rtcConfiguration: {
+            iceServers: [
+              { urls: ['stun:stun.l.google.com:19302'] },
+              { urls: ['stun:stun1.l.google.com:19302'] }
+            ]
+          }
+        }),
+        circuitRelayTransport({
+          reservationCompletionTimeout: TIMEOUTS.RELAY_CONNECTION
+        })
+      ],
+      connectionEncrypters: [noise()],
+      streamMuxers: [yamux()],
+      connectionManager: {
+        inboundStreamProtocolNegotiationTimeout: TIMEOUTS.PROTOCOL_NEGOTIATION_INBOUND,
+        inboundUpgradeTimeout: TIMEOUTS.UPGRADE_INBOUND,
+        outboundStreamProtocolNegotiationTimeout: TIMEOUTS.PROTOCOL_NEGOTIATION_OUTBOUND,
+        outboundUpgradeTimeout: TIMEOUTS.UPGRADE_OUTBOUND
+      },
+      connectionGater: {
+        denyDialMultiaddr: () => false
+      },
+      peerDiscovery: [
+        pubsubPeerDiscovery({
+          interval: INTERVALS.PUBSUB_PEER_DISCOVERY,
+          topics: PUBSUB_DISCOVERY.TOPICS,
+          listenOnly: false
+        })
+      ],
+      services: {
+        identify: identify(),
+        identifyPush: identifyPush(),
+        autoNAT: autoNAT(),
+        dcutr: dcutr(),
+        pubsub: gossipsub({
+          emitSelf: false,
+          allowPublishToZeroTopicPeers: true,
+          heartbeatInterval: INTERVALS.GOSSIPSUB_HEARTBEAT,
+          directPeers: [],
+          floodPublish: true
+        })
+      }
+    })
+
+    log(`libp2p node created with id: ${libp2pNode.peerId.toString().slice(0, 12)}...`)
+
+    // Expose for testing
+    window.libp2pNode = libp2pNode
+
+    // Connect to relay
+    log('Connecting to relay...')
+    try {
+      const ma = multiaddr(relayAddr)
+      await libp2pNode.dial(ma)
+      log('Connected to relay!')
+    } catch (err) {
+      throw new Error(`Failed to connect to relay: ${err.message}`)
+    }
+
+    // Create Yjs document
+    yjsDoc = new Y.Doc()
+    text = yjsDoc.getText('content')
+
+    // Set up Yjs provider with libp2p
+    log(`Setting up Yjs provider with topic: ${topic}`)
+    provider = new Libp2pProvider(topic, yjsDoc, libp2pNode)
+
+    // Bind editor to Yjs text
+    text.observe(() => {
+      const currentText = text.toString()
+      if (editor.value !== currentText) {
+        const cursorPos = editor.selectionStart
+        editor.value = currentText
+        editor.setSelectionRange(cursorPos, cursorPos)
+      }
+    })
+
+    editor.oninput = () => {
+      const newText = editor.value
+      const currentText = text.toString()
+
+      if (newText !== currentText) {
+        yjsDoc.transact(() => {
+          text.delete(0, currentText.length)
+          text.insert(0, newText)
+        })
+      }
+    }
+
+    editor.disabled = false
+    log('Ready! Open this page in another browser tab or window to see collaborative editing.')
+
+    // Initial peer display update
+    updatePeerDisplay()
+
+    // Update peer display on connection events
+    libp2pNode.addEventListener('peer:connect', (evt) => {
+      updatePeerDisplay()
+      if (DEBUG) {
+        log(`Connected to peer: ${evt.detail.toString().slice(0, 12)}...`)
+      }
+    })
+
+    libp2pNode.addEventListener('peer:disconnect', (evt) => {
+      updatePeerDisplay()
+      if (DEBUG) {
+        log(`Disconnected from peer: ${evt.detail.toString().slice(0, 12)}...`)
+      }
+    })
+  } catch (err) {
+    log(`Error: ${err.message}`, true)
+    // eslint-disable-next-line no-console
+    console.error('Connection error:', err)
+    connectBtn.disabled = false
+
+    // Clean up on error
+    if (libp2pNode) {
+      try {
+        await libp2pNode.stop()
+      } catch (stopErr) {
+        // eslint-disable-next-line no-console
+        console.error('Error stopping libp2p:', stopErr)
+      }
+      libp2pNode = null
+    }
+  }
+}
+
+/**
+ * Cleanup resources on page unload.
+ */
+window.addEventListener('beforeunload', async () => {
+  try {
+    if (provider) {
+      await provider.destroy()
+    }
+    if (libp2pNode) {
+      await libp2pNode.stop()
+    }
+  } catch (err) {
+    // eslint-disable-next-line no-console
+    console.error('Cleanup error:', err)
+  }
+})
diff --git a/examples/js-libp2p-example-yjs-libp2p/package.json b/examples/js-libp2p-example-yjs-libp2p/package.json
new file mode 100644
index 0000000..24fb349
--- /dev/null
+++ b/examples/js-libp2p-example-yjs-libp2p/package.json
@@ -0,0 +1,39 @@
+{
+  "name": "@libp2p/example-yjs-libp2p",
+  "version": "1.0.0",
+  "description": "A Yjs collaborative editor using libp2p for peer-to-peer connectivity",
+  "type": "module",
+  "scripts": {
+    "start": "vite",
+    "build": "vite build",
+    "relay": "node relay.js",
+    "relay:debug": "DEBUG=true node relay.js",
+    "test:firefox": "playwright test --project=firefox",
+    "test:chrome": "playwright test --project=chromium",
+    "test": "playwright test"
+  },
+  "dependencies": {
+    "@chainsafe/libp2p-gossipsub": "^14.0.0",
+    "@chainsafe/libp2p-noise": "^16.0.0",
+    "@chainsafe/libp2p-yamux": "^7.0.0",
+    "@libp2p/autonat": "^2.0.0",
+    "@libp2p/circuit-relay-v2": "^3.0.0",
+    "@libp2p/dcutr": "^2.0.0",
+    "@libp2p/identify": "^3.0.1",
+    "@libp2p/ping": "^2.0.0",
+    "@libp2p/pubsub-peer-discovery": "^11.0.0",
+    "@libp2p/tcp": "^10.0.0",
+    "@libp2p/webrtc": "^5.0.0",
+    "@libp2p/websockets": "^9.0.0",
+    "@multiformats/multiaddr": "^12.0.0",
+    "it-pushable": "^3.2.0",
+    "libp2p": "^2.0.0",
+    "uint8arrays": "^5.1.0",
+    "vite": "^6.0.3",
+    "yjs": "^13.6.18"
+  },
+  "devDependencies": {
+    "@playwright/test": "^1.56.1",
+    "test-ipfs-example": "^1.0.0"
+  }
+}
diff --git a/examples/js-libp2p-example-yjs-libp2p/playwright.config.js b/examples/js-libp2p-example-yjs-libp2p/playwright.config.js
new file mode 100644
index 0000000..f3c625f
--- /dev/null
+++ b/examples/js-libp2p-example-yjs-libp2p/playwright.config.js
@@ -0,0 +1,33 @@
+import { defineConfig, devices } from '@playwright/test'
+
+export default defineConfig({
+  testDir: './test',
+  timeout: 60000,
+  fullyParallel: false,
+  forbidOnly: Boolean(process.env.CI),
+  retries: process.env.CI ? 2 : 0,
+  workers: 1,
+  reporter: 'list',
+  globalSetup: './test/global-setup.js',
+  globalTeardown: './test/global-teardown.js',
+  use: {
+    baseURL: 'http://localhost:5173',
+    trace: 'on-first-retry'
+  },
+  projects: [
+    {
+      name: 'chromium',
+      use: { ...devices['Desktop Chrome'] }
+    },
+    {
+      name: 'firefox',
+      use: { ...devices['Desktop Firefox'] }
+    }
+  ],
+  webServer: {
+    command: 'npx vite preview --port 5173',
+    port: 5173,
+    reuseExistingServer: !process.env.CI,
+    timeout: 120000
+  }
+})
diff --git a/examples/js-libp2p-example-yjs-libp2p/relay-constants.js b/examples/js-libp2p-example-yjs-libp2p/relay-constants.js
new file mode 100644
index 0000000..b8c6a97
--- /dev/null
+++ b/examples/js-libp2p-example-yjs-libp2p/relay-constants.js
@@ -0,0 +1,45 @@
+/**
+ * Configuration constants for the relay server
+ */
+
+// Debug mode - set via environment variable
+export const DEBUG = process.env.DEBUG === 'true' || false
+
+// Relay server timeouts (milliseconds)
+export const RELAY_TIMEOUTS = {
+  HOP_TIMEOUT: 30000,
+  PROTOCOL_NEGOTIATION_INBOUND: 30000,
+  PROTOCOL_NEGOTIATION_OUTBOUND: 30000,
+  UPGRADE_INBOUND: 30000,
+  UPGRADE_OUTBOUND: 30000,
+  DIAL_TIMEOUT: 30000
+}
+
+// Relay server reservation configuration
+export const RELAY_RESERVATIONS = {
+  MAX_RESERVATIONS: 1000,
+  RESERVATION_TTL: 2 * 60 * 60 * 1000, // 2 hours
+  DEFAULT_DATA_LIMIT: BigInt(1024 * 1024 * 1024), // 1 GB
+  DEFAULT_DURATION_LIMIT: 2 * 60 * 1000 // 2 minutes
+}
+
+// Connection manager configuration
+export const CONNECTION_CONFIG = {
+  MAX_CONNECTIONS: 1000,
+  MAX_INCOMING_PENDING: 100,
+  MAX_PEER_ADDRS_TO_DIAL: 100
+}
+
+// Peer discovery configuration
+export const DISCOVERY_CONFIG = {
+  INTERVAL: 5000,
+  TOPICS: ['_peer-discovery._p2p._pubsub']
+}
+
+// Monitoring intervals (milliseconds)
+export const MONITORING = {
+  TOPIC_STATUS_INTERVAL: 10000
+}
+
+// Default Yjs topic to subscribe
+export const DEFAULT_TOPIC = 'yjs-doc-1'
diff --git a/examples/js-libp2p-example-yjs-libp2p/relay.js b/examples/js-libp2p-example-yjs-libp2p/relay.js
new file mode 100644
index 0000000..01a5e7e
--- /dev/null
+++ b/examples/js-libp2p-example-yjs-libp2p/relay.js
@@ -0,0 +1,205 @@
+import { gossipsub } from '@chainsafe/libp2p-gossipsub'
+import { noise } from '@chainsafe/libp2p-noise'
+import { yamux } from '@chainsafe/libp2p-yamux'
+import { autoNAT } from '@libp2p/autonat'
+import { circuitRelayServer, circuitRelayTransport } from '@libp2p/circuit-relay-v2'
+import { dcutr } from '@libp2p/dcutr'
+import { identify, identifyPush } from '@libp2p/identify'
+import { pubsubPeerDiscovery } from '@libp2p/pubsub-peer-discovery'
+import { tcp } from '@libp2p/tcp'
+import { webRTC, webRTCDirect } from '@libp2p/webrtc'
+import { webSockets } from '@libp2p/websockets'
+import * as filters from '@libp2p/websockets/filters'
+import { createLibp2p } from 'libp2p'
+import {
+  DEBUG,
+  RELAY_TIMEOUTS,
+  RELAY_RESERVATIONS,
+  CONNECTION_CONFIG,
+  DISCOVERY_CONFIG,
+  MONITORING,
+  DEFAULT_TOPIC
+} from './relay-constants.js'
+
+const server = await createLibp2p({
+  addresses: {
+    listen: [
+      '/ip4/0.0.0.0/tcp/9091',
+      '/ip4/0.0.0.0/tcp/9092/ws',
+      '/ip4/0.0.0.0/udp/9093/webrtc-direct'
+    ]
+  },
+  transports: [
+    tcp(),
+    webSockets({
+      filter: filters.all
+    }),
+    webRTC(),
+    webRTCDirect(),
+    circuitRelayTransport()
+  ],
+  peerDiscovery: [
+    pubsubPeerDiscovery({
+      interval: DISCOVERY_CONFIG.INTERVAL,
+      topics: DISCOVERY_CONFIG.TOPICS,
+      listenOnly: false
+    })
+  ],
+  connectionEncrypters: [noise()],
+  streamMuxers: [yamux()],
+  connectionManager: {
+    inboundStreamProtocolNegotiationTimeout: RELAY_TIMEOUTS.PROTOCOL_NEGOTIATION_INBOUND,
+    inboundUpgradeTimeout: RELAY_TIMEOUTS.UPGRADE_INBOUND,
+    outboundStreamProtocolNegotiationTimeout: RELAY_TIMEOUTS.PROTOCOL_NEGOTIATION_OUTBOUND,
+    outboundUpgradeTimeout: RELAY_TIMEOUTS.UPGRADE_OUTBOUND,
+    maxConnections: CONNECTION_CONFIG.MAX_CONNECTIONS,
+    maxIncomingPendingConnections: CONNECTION_CONFIG.MAX_INCOMING_PENDING,
+    maxPeerAddrsToDial: CONNECTION_CONFIG.MAX_PEER_ADDRS_TO_DIAL,
+    dialTimeout: RELAY_TIMEOUTS.DIAL_TIMEOUT
+  },
+  connectionGater: {
+    denyDialMultiaddr: () => false
+  },
+  services: {
+    identify: identify(),
+    identifyPush: identifyPush(),
+    autoNAT: autoNAT(),
+    dcutr: dcutr(),
+    pubsub: gossipsub({
+      emitSelf: false,
+      allowPublishToZeroTopicPeers: true,
+      canRelayMessage: true,
+      floodPublish: true
+    }),
+    relay: circuitRelayServer({
+      hopTimeout: RELAY_TIMEOUTS.HOP_TIMEOUT,
+      reservations: {
+        maxReservations: RELAY_RESERVATIONS.MAX_RESERVATIONS,
+        reservationTtl: RELAY_RESERVATIONS.RESERVATION_TTL,
+        defaultDataLimit: RELAY_RESERVATIONS.DEFAULT_DATA_LIMIT,
+        defaultDurationLimit: RELAY_RESERVATIONS.DEFAULT_DURATION_LIMIT
+      }
+    })
+  }
+})
+
+// Set up peer discovery listener to dial discovered peers
+server.addEventListener('peer:discovery', async (evt) => {
+  const peer = evt.detail
+  const connections = server.getConnections(peer.id)
+
+  if (connections && connections.length > 0) {
+    return
+  }
+
+  try {
+    await server.dial(peer.id)
+    if (DEBUG) {
+      // eslint-disable-next-line no-console
+      console.log(`Dialed peer: ${peer.id.toString().slice(0, 12)}...`)
+    }
+  } catch (error) {
+    if (DEBUG) {
+      // eslint-disable-next-line no-console
+      console.warn(`Failed to dial peer: ${error.message}`)
+    }
+  }
+})
+
+server.addEventListener('peer:connect', (evt) => {
+  if (DEBUG) {
+    // eslint-disable-next-line no-console
+    console.log(`Peer connected: ${evt.detail.toString().slice(0, 12)}...`)
+  }
+})
+
+server.addEventListener('peer:disconnect', (evt) => {
+  if (DEBUG) {
+    // eslint-disable-next-line no-console
+    console.log(`Peer disconnected: ${evt.detail.toString().slice(0, 12)}...`)
+  }
+})
+
+// Log pubsub messages when debug mode is enabled
+if (DEBUG) {
+  server.services.pubsub.addEventListener('message', (evt) => {
+    try {
+      const msgStr = new TextDecoder().decode(evt.detail.data)
+      const msg = JSON.parse(msgStr)
+      // eslint-disable-next-line no-console
+      console.log(`📨 ${msg.type} on ${evt.detail.topic} from ${evt.detail.from.toString().slice(0, 12)}...`)
+    } catch {
+      // eslint-disable-next-line no-console
+      console.log(`📨 Message on ${evt.detail.topic} (${evt.detail.data.length} bytes)`)
+    }
+  })
+}
+
+// Subscribe to topics dynamically as we see them
+const subscribedTopics = new Set()
+
+server.services.pubsub.addEventListener('subscription-change', async (evt) => {
+  const subscriptions = evt.detail.subscriptions
+  if (!subscriptions || !Array.isArray(subscriptions)) {
+    return
+  }
+
+  for (const sub of subscriptions) {
+    if (!sub || !sub.topic) {
+      continue
+    }
+
+    const topic = sub.topic
+    const shouldSubscribe = (topic.startsWith('yjs-') || topic.startsWith('test-')) && !subscribedTopics.has(topic)
+    if (!shouldSubscribe) {
+      continue
+    }
+
+    subscribedTopics.add(topic)
+    try {
+      await server.services.pubsub.subscribe(topic)
+      if (DEBUG) {
+        // eslint-disable-next-line no-console
+        console.log(`📡 Auto-subscribed to: ${topic}`)
+      }
+    } catch (err) {
+      // eslint-disable-next-line no-console
+      console.error('Failed to subscribe:', err)
+    }
+  }
+})
+
+// Subscribe to default Yjs topic
+await server.services.pubsub.subscribe(DEFAULT_TOPIC)
+subscribedTopics.add(DEFAULT_TOPIC)
+// eslint-disable-next-line no-console
+console.log(`📡 Relay subscribed to default topic: ${DEFAULT_TOPIC}`)
+
+// Periodically log active topics and subscribers in debug mode
+if (DEBUG) {
+  setInterval(() => {
+    const topics = server.services.pubsub.getTopics()
+    if (topics.length > 0) {
+      // eslint-disable-next-line no-console
+      console.log('\n📋 Active topics:', topics)
+      for (const topic of topics) {
+        const subscribers = server.services.pubsub.getSubscribers(topic)
+        if (subscribers.length > 0) {
+          // eslint-disable-next-line no-console
+          console.log(`  👥 ${topic}: ${subscribers.length} subscribers`)
+        }
+      }
+    }
+  }, MONITORING.TOPIC_STATUS_INTERVAL)
+}
+
+// eslint-disable-next-line no-console
+console.info('\nThe relay node is running and listening on the following multiaddrs:')
+// eslint-disable-next-line no-console
+console.info('')
+// eslint-disable-next-line no-console
+console.info(server.getMultiaddrs().map((ma) => ma.toString()).join('\n'))
+// eslint-disable-next-line no-console
+console.info('')
+// eslint-disable-next-line no-console
+console.info('Copy one of the above multiaddrs and use it in the browser client')
diff --git a/examples/js-libp2p-example-yjs-libp2p/test/global-setup.js b/examples/js-libp2p-example-yjs-libp2p/test/global-setup.js
new file mode 100644
index 0000000..0bae29a
--- /dev/null
+++ b/examples/js-libp2p-example-yjs-libp2p/test/global-setup.js
@@ -0,0 +1,64 @@
+/* eslint-disable no-console, no-unused-vars */
+
+import { spawn } from 'child_process'
+import { writeFileSync } from 'fs'
+import path from 'path'
+
+export default async function globalSetup () {
+  console.log('Starting relay server...')
+
+  return new Promise((resolve, reject) => {
+    // Start relay server as a child process
+    const relayProcess = spawn('node', ['relay.js'], {
+      cwd: path.resolve(process.cwd()),
+      stdio: ['ignore', 'pipe', 'pipe']
+    })
+
+    let relayMultiaddr = null
+    let output = ''
+
+    relayProcess.stdout.on('data', (data) => {
+      const text = data.toString()
+      output += text
+      console.log(text)
+
+      // Extract the first multiaddr (WebSocket address)
+      const match = text.match(/\/ip4\/127\.0\.0\.1\/tcp\/\d+\/ws\/p2p\/[A-Za-z0-9]+/)
+      if (match && !relayMultiaddr) {
+        relayMultiaddr = match[0]
+        console.log(`Relay server started with multiaddr: ${relayMultiaddr}`)
+
+        // Store relay info for tests
+        const relayInfo = {
+          multiaddr: relayMultiaddr,
+          pid: relayProcess.pid
+        }
+
+        writeFileSync(
+          path.resolve(process.cwd(), 'test/relay-info.json'),
+          JSON.stringify(relayInfo, null, 2)
+        )
+
+        // Give the relay a moment to fully initialize
+        setTimeout(() => resolve(), 1000)
+      }
+    })
+
+    relayProcess.stderr.on('data', (data) => {
+      console.error('Relay stderr:', data.toString())
+    })
+
+    relayProcess.on('error', (error) => {
+      console.error('Failed to start relay:', error)
+      reject(error)
+    })
+
+    // Timeout if relay doesn't start
+    setTimeout(() => {
+      if (!relayMultiaddr) {
+        relayProcess.kill()
+        reject(new Error('Relay server failed to start within timeout'))
+      }
+    }, 10000)
+  })
+}
diff --git a/examples/js-libp2p-example-yjs-libp2p/test/global-teardown.js b/examples/js-libp2p-example-yjs-libp2p/test/global-teardown.js
new file mode 100644
index 0000000..89d1775
--- /dev/null
+++ b/examples/js-libp2p-example-yjs-libp2p/test/global-teardown.js
@@ -0,0 +1,23 @@
+/* eslint-disable no-console */
+
+import { readFileSync, unlinkSync } from 'fs'
+import path from 'path'
+
+export default async function globalTeardown () {
+  console.log('Stopping relay server...')
+
+  try {
+    const relayInfoPath = path.resolve(process.cwd(), 'test/relay-info.json')
+    const relayInfo = JSON.parse(readFileSync(relayInfoPath, 'utf8'))
+
+    if (relayInfo.pid) {
+      process.kill(relayInfo.pid, 'SIGTERM')
+      console.log(`Relay server (PID ${relayInfo.pid}) stopped`)
+    }
+
+    // Clean up the relay info file
+    unlinkSync(relayInfoPath)
+  } catch (error) {
+    console.error('Error stopping relay server:', error.message)
+  }
+}
diff --git a/examples/js-libp2p-example-yjs-libp2p/test/index.spec.js b/examples/js-libp2p-example-yjs-libp2p/test/index.spec.js
new file mode 100644
index 0000000..ddbbe8d
--- /dev/null
+++ b/examples/js-libp2p-example-yjs-libp2p/test/index.spec.js
@@ -0,0 +1,133 @@
+/* eslint-disable no-console */
+
+import { readFileSync } from 'fs'
+import path from 'path'
+import { test, expect } from '@playwright/test'
+
+const url = 'http://localhost:5173'
+
+// Helper to connect a page to the relay
+async function connectToRelay (page, relayMultiaddr, topic = 'test-topic') {
+  await page.fill('#relay', relayMultiaddr)
+  await page.fill('#topic', topic)
+  await page.click('#connect')
+
+  // Wait for connection to establish
+  await page.waitForFunction(
+    () => document.getElementById('editor').disabled === false,
+    { timeout: 10000 }
+  )
+}
+
+test.describe('Yjs + libp2p example', () => {
+  let relayMultiaddr
+
+  test.beforeAll(() => {
+    // Load relay multiaddr from global setup
+    const relayInfo = JSON.parse(
+      readFileSync(path.resolve(process.cwd(), 'test/relay-info.json'), 'utf8')
+    )
+    relayMultiaddr = relayInfo.multiaddr
+  })
+
+  test('should load page in two browsers', async ({ browser }) => {
+    const context1 = await browser.newContext()
+    const context2 = await browser.newContext()
+
+    const page1 = await context1.newPage()
+    const page2 = await context2.newPage()
+
+    await page1.goto(url)
+    await page2.goto(url)
+
+    const heading1 = await page1.locator('h1').textContent()
+    const heading2 = await page2.locator('h1').textContent()
+
+    expect(heading1).toBe('Yjs + libp2p')
+    expect(heading2).toBe('Yjs + libp2p')
+
+    await context1.close()
+    await context2.close()
+  })
+
+  test('should sync text between two browsers', async ({ browser }) => {
+    const context1 = await browser.newContext()
+    const context2 = await browser.newContext()
+
+    const page1 = await context1.newPage()
+    const page2 = await context2.newPage()
+
+    // Enable console logging for debugging
+    page1.on('console', msg => console.log('Page1:', msg.text()))
+    page2.on('console', msg => console.log('Page2:', msg.text()))
+
+    await page1.goto(url)
+    await page2.goto(url)
+
+    // Connect both pages to the relay with the same topic
+    const testTopic = `test-${Date.now()}`
+    await connectToRelay(page1, relayMultiaddr, testTopic)
+    await connectToRelay(page2, relayMultiaddr, testTopic)
+
+    // Wait for both to be connected to relay and ready
+    // The log shows "Ready!" when connection is established
+    await page1.waitForFunction(
+      () => document.getElementById('log').textContent.includes('Ready!'),
+      { timeout: 10000 }
+    )
+    await page2.waitForFunction(
+      () => document.getElementById('log').textContent.includes('Ready!'),
+      { timeout: 10000 }
+    )
+
+    // Wait for peers to discover each other via pubsub
+    // Even without direct P2P connection, pubsub through relay should work
+    // Wait for gossipsub mesh to form and sync-request/response to complete
+    // Gossipsub heartbeat is 1000ms, give it more time to propagate subscriptions
+    await page1.waitForTimeout(15000)
+
+    // Check if peers can see each other in gossipsub
+    const page1Peers = await page1.evaluate(() => {
+      return window.libp2pNode?.getConnections().length || 0
+    })
+    const page2Peers = await page2.evaluate(() => {
+      return window.libp2pNode?.getConnections().length || 0
+    })
+    console.log(`Page1 connections: ${page1Peers}, Page2 connections: ${page2Peers}`)
+
+    // Click into editor and type in page 1
+    await page1.click('#editor')
+    const testText = 'Hello!'
+    await page1.type('#editor', testText)
+
+    // Wait for text to sync to page 2 via pubsub (through relay)
+    await page2.waitForFunction(
+      (text) => document.getElementById('editor').value.includes(text),
+      testText,
+      { timeout: 10000 }
+    )
+
+    // Verify text synced
+    const page2Text = await page2.inputValue('#editor')
+    expect(page2Text).toBe(testText)
+
+    // Type additional text in page 2
+    await page2.click('#editor')
+    const additionalText = ' Bye!'
+    await page2.type('#editor', additionalText)
+
+    // Wait for sync back to page 1
+    const expectedText = testText + additionalText
+    await page1.waitForFunction(
+      (text) => document.getElementById('editor').value === text,
+      expectedText,
+      { timeout: 5000 }
+    )
+
+    const page1Text = await page1.inputValue('#editor')
+    expect(page1Text).toBe(expectedText)
+
+    await context1.close()
+    await context2.close()
+  })
+})
diff --git a/examples/js-libp2p-example-yjs-libp2p/vite.config.js b/examples/js-libp2p-example-yjs-libp2p/vite.config.js
new file mode 100644
index 0000000..25eabdf
--- /dev/null
+++ b/examples/js-libp2p-example-yjs-libp2p/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+  server: {
+    open: true
+  }
+})
diff --git a/examples/js-libp2p-example-yjs-libp2p/yjs-libp2p-provider.js b/examples/js-libp2p-example-yjs-libp2p/yjs-libp2p-provider.js
new file mode 100644
index 0000000..e912418
--- /dev/null
+++ b/examples/js-libp2p-example-yjs-libp2p/yjs-libp2p-provider.js
@@ -0,0 +1,339 @@
+import { fromString, toString } from 'uint8arrays'
+import * as Y from 'yjs'
+import { DEBUG, INTERVALS } from './constants.js'
+
+/**
+ * Yjs connection provider using libp2p for peer-to-peer connectivity.
+ * This replaces y-webrtc and uses libp2p's pubsub for synchronization.
+ */
+export class Libp2pProvider {
+  /**
+   * Creates a new Libp2pProvider for Yjs document synchronization.
+   *
+   * @param {string} topic - The pubsub topic to use for this document
+   * @param {Y.Doc} doc - The Yjs document to sync
+   * @param {import('libp2p').Libp2p} libp2p - The libp2p instance
+   * @param {object} [options] - Provider options
+   * @param {object} [options.awareness] - Yjs awareness instance for cursor/selection sharing
+   * @throws {Error} If topic is empty or libp2p node is not initialized
+   */
+  constructor (topic, doc, libp2p, options = {}) {
+    if (!topic || typeof topic !== 'string') {
+      throw new Error('Topic must be a non-empty string')
+    }
+    if (!doc || !(doc instanceof Y.Doc)) {
+      throw new Error('doc must be a valid Yjs document')
+    }
+    if (!libp2p) {
+      throw new Error('libp2p node must be provided')
+    }
+    this.topic = topic
+    this.doc = doc
+    this.libp2p = libp2p
+    this.awareness = options.awareness
+    this.synced = false
+    this.connected = false
+
+    // Track connected peers
+    this.connectedPeers = new Set()
+
+    // Bind event handlers
+    this._onUpdate = this._handleDocUpdate.bind(this)
+    this._onPubsubMessage = this._handlePubsubMessage.bind(this)
+    this._onPeerDiscovered = this._handlePeerDiscovered.bind(this)
+
+    // Subscribe to document updates
+    this.doc.on('update', this._onUpdate)
+
+    // Subscribe to pubsub topic
+    this._subscribeToPubsub()
+
+    // Set up peer discovery
+    this._setupPeerDiscovery()
+
+    // Request initial state from peers
+    this._requestInitialState()
+  }
+
+  /**
+   * Subscribe to the pubsub topic for this document.
+   *
+   * @private
+   * @returns {Promise}
+   */
+  async _subscribeToPubsub () {
+    try {
+      await this.libp2p.services.pubsub.subscribe(this.topic)
+      this.libp2p.services.pubsub.addEventListener('message', this._onPubsubMessage)
+      this.connected = true
+
+      if (DEBUG) {
+        // eslint-disable-next-line no-console
+        console.log(`✅ Subscribed to Yjs topic: ${this.topic}`)
+        const topics = this.libp2p.services.pubsub.getTopics()
+        // eslint-disable-next-line no-console
+        console.log('All subscribed topics:', topics)
+
+        // Check peers as gossipsub mesh forms
+        const checkPeers = () => {
+          const peers = this.libp2p.services.pubsub.getSubscribers(this.topic)
+          // eslint-disable-next-line no-console
+          console.log(`Peers on ${this.topic}:`, peers.map((p) => p.toString()))
+        }
+
+        setTimeout(checkPeers, INTERVALS.PEER_CHECK)
+        setTimeout(checkPeers, INTERVALS.PEER_CHECK * 2.5)
+        setTimeout(checkPeers, INTERVALS.PEER_CHECK * 5)
+      }
+    } catch (err) {
+      // eslint-disable-next-line no-console
+      console.error('Failed to subscribe to pubsub topic:', err)
+      throw err
+    }
+  }
+
+  /**
+   * Set up peer discovery to connect to discovered peers.
+   *
+   * @private
+   */
+  _setupPeerDiscovery () {
+    this.libp2p.addEventListener('peer:discovery', this._onPeerDiscovered)
+
+    this.libp2p.addEventListener('peer:connect', (evt) => {
+      const peerId = evt.detail.toString()
+      this.connectedPeers.add(peerId)
+      if (DEBUG) {
+        // eslint-disable-next-line no-console
+        console.log(`Connected to peer: ${peerId}`)
+      }
+    })
+
+    this.libp2p.addEventListener('peer:disconnect', (evt) => {
+      const peerId = evt.detail.toString()
+      this.connectedPeers.delete(peerId)
+      if (DEBUG) {
+        // eslint-disable-next-line no-console
+        console.log(`Disconnected from peer: ${peerId}`)
+      }
+    })
+  }
+
+  /**
+   * Handle peer discovery events.
+   *
+   * @private
+   * @param {CustomEvent} evt - Peer discovery event
+   * @returns {Promise}
+   */
+  async _handlePeerDiscovered (evt) {
+    const peer = evt.detail
+    const peerId = peer.id.toString()
+
+    if (this.libp2p.peerId.equals(peer.id)) {
+      return
+    }
+
+    const connections = this.libp2p.getConnections(peer.id)
+    if (connections && connections.length > 0) {
+      if (DEBUG) {
+        // eslint-disable-next-line no-console
+        console.log(`Already connected to peer: ${peerId}`)
+      }
+      return
+    }
+
+    try {
+      if (DEBUG) {
+        // eslint-disable-next-line no-console
+        console.log(`Dialing peer: ${peerId}`)
+      }
+      await this.libp2p.dial(peer.id)
+    } catch (error) {
+      if (DEBUG) {
+        // eslint-disable-next-line no-console
+        console.warn(`Failed to dial peer ${peerId}:`, error.message)
+      }
+    }
+  }
+
+  /**
+   * Request initial document state from connected peers.
+   *
+   * @private
+   * @returns {Promise}
+   */
+  async _requestInitialState () {
+    setTimeout(() => {
+      const stateVector = Y.encodeStateVector(this.doc)
+      this._publishMessage({
+        type: 'sync-request',
+        stateVector: toString(stateVector, 'base64')
+      }).catch((err) => {
+        if (DEBUG) {
+          // eslint-disable-next-line no-console
+          console.error('Failed to send sync request:', err)
+        }
+      })
+    }, INTERVALS.INITIAL_SYNC_REQUEST)
+  }
+
+  /**
+   * Handle Yjs document updates.
+   *
+   * @private
+   * @param {Uint8Array} update - The document update
+   * @param {any} origin - Origin of the update
+   */
+  _handleDocUpdate (update, origin) {
+    if (origin === this) {
+      return
+    }
+
+    this._publishMessage({
+      type: 'update',
+      update: toString(update, 'base64')
+    }).catch((err) => {
+      // eslint-disable-next-line no-console
+      console.error('Failed to broadcast update:', err)
+    })
+  }
+
+  /**
+   * Handle incoming pubsub messages.
+   *
+   * @private
+   * @param {CustomEvent} evt - Pubsub message event
+   */
+  _handlePubsubMessage (evt) {
+    if (evt.detail.topic !== this.topic || this.libp2p.peerId.equals(evt.detail.from)) {
+      return
+    }
+
+    try {
+      const message = JSON.parse(toString(evt.detail.data, 'utf8'))
+
+      if (DEBUG) {
+        // eslint-disable-next-line no-console
+        console.log(`Received ${message.type} from ${evt.detail.from.toString()}`)
+      }
+
+      switch (message.type) {
+        case 'update':
+          this._applyUpdate(message.update)
+          break
+        case 'sync-request':
+          this._handleSyncRequest(message.stateVector)
+          break
+        case 'sync-response':
+          this._handleSyncResponse(message.update)
+          break
+        default:
+          if (DEBUG) {
+            // eslint-disable-next-line no-console
+            console.warn(`Unknown message type: ${message.type}`)
+          }
+      }
+    } catch (err) {
+      // eslint-disable-next-line no-console
+      console.error('Failed to process pubsub message:', err)
+    }
+  }
+
+  /**
+   * Apply an update to the document.
+   *
+   * @private
+   * @param {string} updateBase64 - Base64-encoded Yjs update
+   */
+  _applyUpdate (updateBase64) {
+    const update = fromString(updateBase64, 'base64')
+    Y.applyUpdate(this.doc, update, this)
+
+    if (!this.synced) {
+      this.synced = true
+      if (DEBUG) {
+        // eslint-disable-next-line no-console
+        console.log('Document synced with network')
+      }
+    }
+  }
+
+  /**
+   * Handle sync request from a peer.
+   *
+   * @private
+   * @param {string} stateVectorBase64 - Base64-encoded state vector
+   */
+  _handleSyncRequest (stateVectorBase64) {
+    const stateVector = fromString(stateVectorBase64, 'base64')
+    const update = Y.encodeStateAsUpdate(this.doc, stateVector)
+
+    this._publishMessage({
+      type: 'sync-response',
+      update: toString(update, 'base64')
+    }).catch((err) => {
+      // eslint-disable-next-line no-console
+      console.error('Failed to send sync response:', err)
+    })
+  }
+
+  /**
+   * Handle sync response from a peer.
+   *
+   * @private
+   * @param {string} updateBase64 - Base64-encoded Yjs update
+   */
+  _handleSyncResponse (updateBase64) {
+    this._applyUpdate(updateBase64)
+  }
+
+  /**
+   * Publish a message to the pubsub topic.
+   *
+   * @private
+   * @param {object} message - Message object to publish
+   * @param {string} message.type - Message type (update, sync-request, sync-response)
+   * @returns {Promise}
+   */
+  async _publishMessage (message) {
+    try {
+      const data = fromString(JSON.stringify(message), 'utf8')
+
+      if (DEBUG) {
+        const subscribers = this.libp2p.services.pubsub.getSubscribers(this.topic)
+        // eslint-disable-next-line no-console
+        console.log(`Publishing ${message.type} to ${this.topic} (${subscribers.length} subscribers)`)
+      }
+
+      await this.libp2p.services.pubsub.publish(this.topic, data)
+    } catch (err) {
+      // eslint-disable-next-line no-console
+      console.error('Failed to publish message:', err)
+      throw err
+    }
+  }
+
+  /**
+   * Destroy the provider and clean up resources.
+   *
+   * @returns {Promise}
+   */
+  async destroy () {
+    try {
+      this.doc.off('update', this._onUpdate)
+      this.libp2p.services.pubsub.removeEventListener('message', this._onPubsubMessage)
+      this.libp2p.removeEventListener('peer:discovery', this._onPeerDiscovered)
+
+      await this.libp2p.services.pubsub.unsubscribe(this.topic)
+
+      this.connected = false
+      this.synced = false
+      this.connectedPeers.clear()
+    } catch (err) {
+      // eslint-disable-next-line no-console
+      console.error('Error during provider cleanup:', err)
+      throw err
+    }
+  }
+}