TechSetupGuides
AdvancedSupabaseYjsTauriSvelteKitWebRTCCRDTReal-timeCollaboration

Real-Time Collaborative Stack: Supabase + Yjs + Tauri 2 + SvelteKit

Build production-ready real-time collaborative applications with Supabase, Yjs CRDTs, Tauri 2 desktop framework, and SvelteKit. Includes real-time sync, offline-first strategies, and multi-platform packaging.

  1. Step 1

    Project Initialization

    Set up a monorepo structure with separate packages for the web app, desktop app, and shared types. Use pnpm workspaces for efficient dependency management.

    mkdir collaborative-app && cd collaborative-app
    pnpm init
    pnpm add -wD pnpm-workspace
    
    echo 'packages:' > pnpm-workspace.yaml
    echo '  - packages/*' >> pnpm-workspace.yaml
    
    mkdir -p packages/{web,desktop,shared}
  2. Step 2

    Initialize SvelteKit Web App

    Create a SvelteKit 2 project with TypeScript and Tailwind CSS. This will serve as the foundation for the web application.

    cd packages/web
    pnpm create svelte@latest . --template skeleton --typescript --prettier --eslint --playwright --vite
    cd ..
    
    pnpm install sveltekit-superforms @hookform/svelte
    pnpm install zod @tanstack/svelte-query
    pnpm install -D prettier prettier-plugin-svelte svelte-check
  3. Step 3

    Install Yjs and Supabase Provider

    Add Yjs CRDT library and Supabase for real-time synchronization. Yjs provides conflict-free replicated data types essential for collaborative editing.

    cd packages/web
    pnpm install yjs lib0
    pnpm install @supabase/supabase-js
    pnpm install y-indexeddb
    ⚠ Heads up: Yjs has multiple packages. Install 'yjs' for the core CRDT library and 'y-indexeddb' for offline persistence.
  4. Step 4

    Configure Supabase Real-Time Database

    Set up Supabase with real-time subscriptions and database tables for Yjs document storage and awareness.

    -- Enable necessary extensions
    create extension if not exists "uuid-ossp";
    
    -- Storage for Yjs documents
    create table y_docs (
      id uuid primary key default uuid_generate_v4(),
      doc_name text unique not null,
      content text,
      updated_at timestamptz default now(),
      created_at timestamptz default now()
    );
    
    -- Awareness storage for user presence
    create table y_awareness (
      id uuid primary key default uuid_generate_v4(),
      doc_name text not null references y_docs(doc_name),
      user_id uuid not null,
      awareness text,
      updated_at timestamptz default now(),
      expires_at timestamptz not null
    );
    
    -- Index for expired awareness cleanup
    create index y_awareness_expires_idx on y_awareness(expires_at);
    
    -- Enable real-time subscriptions
    alter publication supabase_realtime add table y_docs;
    alter publication supabase_realtime add table y_awareness;
    
    -- Enable Row Level Security
    alter table y_docs enable row level security;
    alter table y_awareness enable row level security;
    
    -- RLS Policies
    create policy "Public read access to documents"
      on y_docs for select using (true);
    
    create policy "Authenticated users can modify documents"
      on y_docs for all using (auth.role() = 'authenticated');
  5. Step 5

    Create Yjs Supabase Provider

    Implement Yjs document synchronization with Supabase for real-time collaboration.

    // packages/shared/supabase-provider.ts
    import * as Y from 'yjs'
    import * as syncProtocol from 'y-protocols/sync'
    import * as awarenessProtocol from 'y-protocols/awareness'
    import type { SupabaseClient } from '@supabase/supabase-js'
    
    export class SupabaseYjsProvider {
      public ydoc: Y.Doc
      public awareness: awarenessProtocol.Awareness
      private _supabase: SupabaseClient
      private _docName: string
    
      constructor(options: {
        supabase: SupabaseClient
        docName: string
        userId: string
      }) {
        this._supabase = options.supabase
        this._docName = options.docName
        this.ydoc = new Y.Doc()
        this.ydoc.on('update', (update) => this._saveUpdate(update))
        
        this.awareness = new awarenessProtocol.Awareness(this.ydoc, {
          connectionId: options.userId,
          localState: {}
        }, null)
    
        this.awareness.setLocalStateField('user', {
          name: options.userId,
          color: '#' + Math.floor(Math.random()*16777215).toString(16)
        })
    
        this._loadDoc()
        this._subscribeToUpdates()
      }
    
      private async _loadDoc() {
        const { data, error } = await this._supabase
          .from('y_docs')
          .select('content')
          .eq('doc_name', this._docName)
          .maybeSingle()
    
        if (error || !data?.content) return
        const update = this._hexToUint8(data.content)
        syncProtocol.applyUpdate(this.ydoc, update)
      }
    
      private async _saveUpdate(update: Uint8Array) {
        const hex = this._uint8ToHex(update)
        await this._supabase.from('y_docs').upsert({
          doc_name: this._docName,
          content: hex,
          updated_at: new Date().toISOString()
        })
      }
    
      private _subscribeToUpdates() {
        this._supabase
          .channel(`doc:${this._docName}`)
          .on('postgres_changes', {
            event: '*',
            schema: 'public',
            table: 'y_docs',
            filter: { doc_name: this._docName }
          }, async (payload) => {
            if (['INSERT', 'UPDATE'].includes(payload.eventType)) {
              const update = this._hexToUint8(payload.new.content)
              syncProtocol.applyUpdate(this.ydoc, update)
            }
          })
          .subscribe()
      }
    
      private _hexToUint8(hex: string): Uint8Array {
        const bytes = new Uint8Array(hex.length / 2)
        for (let i = 0; i < hex.length / 2; i++) {
          bytes[i] = parseInt(hex.substr(i * 2, 2), 16)
        }
        return bytes
      }
    
      private _uint8ToHex(arr: Uint8Array): string {
        return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('')
      }
    
      destroy() {
        this._supabase.removeChannel(`doc:${this._docName}`)
        this.ydoc.destroy()
      }
    }
  6. Step 6

    Build Collaborative Editor in SvelteKit

    Create a real-time collaborative text editor component using SvelteKit and Yjs.

    <script lang="ts">
      import { onMount } from 'svelte'
      import * as Y from 'yjs'
      import { createBrowserClient } from '@supabase/ssr'
      import { SupabaseYjsProvider } from '@shared/supabase-provider'
    
      export let docName = 'shared-doc'
      export let userId = 'user-1'
    
      let provider: SupabaseYjsProvider
      let yText: Y.Text
      let content = $state('')
      let collaborators = $state<Map<string, any>>(new Map())
    
      $effect(() => {
        if (yText) content = yText.toString()
      })
    
      onMount(() => {
        const supabase = createBrowserClient(
          import.meta.env.VITE_SUPABASE_URL,
          import.meta.env.VITE_SUPABASE_ANON_KEY
        )
        provider = new SupabaseYjsProvider({ supabase, docName, userId })
        yText = provider.ydoc.getText('content')
        
        provider.awareness.on('update', () => {
          const states = provider.awareness.getStates()
          collaborators = new Map(Object.entries(states))
        })
        
        return () => provider.destroy()
      })
    </script>
    
    <div class="collaborative-editor">
      <div class="presence-bar">
        <span>Present:</span>
        {#each collaborators as [id, state]}
          {#if state.user}
            <span class="collaborator" style="background: {state.user.color}">
              {state.user.name}
            </span>
          {/if}
        {/each}
      </div>
      <textarea bind:value={content} placeholder="Start collaborating..." />
    </div>
    
    <style>
      .collaborative-editor { border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
      .presence-bar { background: #f9fafb; padding: 0.5rem 1rem; display: flex; gap: 0.5rem; align-items: center; }
      .collaborator { padding: 2px 8px; border-radius: 10px; color: white; font-size: 0.75rem; }
      textarea { width: 100%; min-height: 300px; padding: 1rem; border: none; font-family: inherit; resize: vertical; }
    </style>
  7. Step 7

    Initialize Tauri 2 Desktop Application

    Set up Tauri 2 for desktop applications with native integrations.

    cd packages/desktop
    pnpm create tauri-app@latest . --template svelte-kit --manager pnpm
    rustc --version
    cargo --version
    tauri info
    ⚠ Heads up: Tauri requires Rust. Install via rustup.rs and ensure proper toolchain is set up.
  8. Step 8

    Configure Tauri Multi-Platform Build

    Set up build configurations for Windows, macOS, and Linux distributions.

    {
      "$schema": "https://schema.tauri.app/config/2",
      "productName": "Collaborative Editor",
      "version": "0.1.0",
      "identifier": "com.collaborative.app",
      "build": {
        "frontendDist": "../web/build",
        "beforeDevCommand": "pnpm dev",
        "beforeBuildCommand": "pnpm build"
      },
      "tauri": {
        "windows": [{
          "title": "Collaborative Editor",
          "width": 1200,
          "height": 800,
          "resizable": true
        }],
        "bundle": {
          "active": true,
          "targets": ["app", "dmg", "msi", "deb"],
          "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png"]
        }
      }
    }
  9. Step 9

    Add WebRTC for P2P Communication

    Implement WebRTC for peer-to-peer real-time communication alongside the central sync.

    // packages/shared/webrtc.ts
    export class WebRTCPeer {
      private pc: RTCPeerConnection
    
      constructor(config: RTCConfiguration = {}) {
        this.pc = new RTCPeerConnection({
          iceServers: [
            { urls: ['stun:stun.l.google.com:19302'] },
            ...(config.iceServers || [])
          ]
        })
        this.pc.onconnectionstatechange = () => {
          console.log('Connection state:', this.pc.connectionState)
        }
      }
    
      createDataChannel(label: string) {
        return this.pc.createDataChannel(label, { ordered: false })
      }
    
      async createOffer(): Promise<RTCSessionDescriptionInit> {
        const offer = await this.pc.createOffer()
        await this.pc.setLocalDescription(offer)
        return offer.toJSON()
      }
    
      async createAnswer(offer: RTCSessionDescriptionInit) {
        await this.pc.setRemoteDescription(new RTCSessionDescription(offer))
        const answer = await this.pc.createAnswer()
        await this.pc.setLocalDescription(answer)
        return answer.toJSON()
      }
    
      addIceCandidate(candidate: RTCIceCandidateInit) {
        this.pc.addIceCandidate(new RTCIceCandidate(candidate))
      }
    
      close() { this.pc.close() }
    }
    ⚠ Heads up: For production, add TURN servers for NAT traversal when STUN fails.
  10. Step 10

    Implement Offline-First Sync

    Build offline-first capabilities with IndexedDB persistence and queue-based sync.

    // packages/shared/offline-provider.ts
    import * as Y from 'yjs'
    import { IndexeddbPersistence } from 'y-indexeddb'
    
    export class OfflineYjsProvider {
      private ydoc: Y.Doc
      private storage: IndexeddbPersistence
      private syncQueue: Uint8Array[] = []
    
      constructor(docName: string) {
        this.ydoc = new Y.Doc()
        this.storage = new IndexeddbPersistence(docName, this.ydoc)
        window.addEventListener('online', () => this.syncQueuedUpdates())
      }
    
      get yText(): Y.Text { return this.ydoc.getText('content') }
    
      private async syncQueuedUpdates() {
        if (navigator.onLine && this.syncQueue.length > 0) {
          const merged = Y.mergeUpdates(this.syncQueue)
          this.syncQueue = []
          // Send to server...
        }
      }
    
      queueUpdate(update: Uint8Array) {
        if (!navigator.onLine) { this.syncQueue.push(update) }
      }
    
      destroy() {
        this.storage.close()
        this.ydoc.destroy()
      }
    }
  11. Step 11

    Scalability Optimizations

    Implement batching, compression, and connection pooling for large teams.

    // packages/shared/scalability.ts
    import * as Y from 'yjs'
    
    export class UpdateBatcher {
      private updates: Uint8Array[] = []
      private timeout: ReturnType<typeof setTimeout>
      private batchSize: number
      private flushFn: (update: Uint8Array) => void
    
      constructor(batchSize: number, flushFn: (update: Uint8Array) => void) {
        this.batchSize = batchSize
        this.flushFn = flushFn
        this.scheduleFlush()
      }
    
      addUpdate(update: Uint8Array) {
        this.updates.push(update)
        if (this.updates.length >= this.batchSize) { this.flush() }
        else if (!this.timeout) { this.scheduleFlush() }
      }
    
      private scheduleFlush() {
        this.timeout = setTimeout(() => this.flush(), 1000)
      }
    
      private flush() {
        if (this.timeout) { clearTimeout(this.timeout); this.timeout = undefined }
        if (this.updates.length > 0) {
          const merged = Y.mergeUpdates(this.updates)
          this.updates = []
          this.flushFn(merged)
        }
      }
    
      destroy() {
        if (this.timeout) { clearTimeout(this.timeout) }
        this.flush()
      }
    }
    ⚠ Heads up: Large documents (>10MB) may cause browser memory issues. Split documents into blocks or use virtual scrolling.

Feature requests

Sign in to suggest features or vote on existing ones.

No feature requests yet.

Discussion

0 people marked this as worked·Sign in to mark your own.

Sign in to join the discussion.

No comments yet.