Basic Usage

1. Set Up the Sync Provider

Initialize the sync engine in your application entry point (or before using any synced stores):

// index.tsx
import { configureSyncEngine } from "@tonk/keepsync";
import { BrowserWebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";

const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.host}/sync`;
const wsAdapter = new BrowserWebSocketClientAdapter(wsUrl);
const storage = new IndexedDBStorageAdapter();

const url =
  window.location.host.indexOf("localhost") === 0
    ? "http://localhost:7777"
    : `${window.location.protocol}//${window.location.host}`;

configureSyncEngine({
  url,
  network: [wsAdapter as any],
  storage,
});

2. Create a Synced Store with the Middleware

Use the sync middleware to create stores that automatically synchronize with other clients:

// stores/counterStore.ts
import { create } from 'zustand';
import { sync, DocumentId } from '@tonk/keepsync';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

export const useCounterStore = create<CounterState>(
  sync(
    // The store implementation
    (set) => ({
      count: 0,

      // Increment the counter
      increment: () => {
        set((state) => ({ count: state.count + 1 }));
      },

      // Decrement the counter
      decrement: () => {
        set((state) => ({ count: Math.max(0, state.count - 1) }));
      },

      // Reset the counter
      reset: () => {
        set({ count: 0 });
      },
    }),
    // Sync configuration
    { 
      docId: 'counter' as DocumentId,
      // Optional: configure initialization timeout
      initTimeout: 30000,
      // Optional: handle initialization errors
      onInitError: (error) => console.error('Sync initialization error:', error) 
    }
  )
);

3. Use the Store in React Components

// components/Counter.tsx
import React from 'react';
import { useCounterStore } from '../stores/counterStore';

export function Counter() {
  // Use the store hook directly - sync is handled by the middleware
  const { count, increment, decrement, reset } = useCounterStore();

  return (
    <div>
      <h2>Collaborative Counter: {count}</h2>
      <div>
        <button onClick={decrement}>-</button>
        <button onClick={increment}>+</button>
        <button onClick={reset}>Reset</button>
      </div>
      <p>
        <small>
          Open this app in multiple windows to see real-time collaboration in action.
        </small>
      </p>
    </div>
  );
}

Directly reading and writing documents

You can also directly read and write documents and address them using paths similar to a filesystem. This is useful for when you need more fine-grained control over document access and a zustand store is too cumbersome (e.g. when you want each document to have its own space and be directly addressable);

import { readDoc, writeDoc, ls, mkDir, rm, listenToDoc } from "@tonk/keepsync";

/**
 * Reads a document from keepsync
 *
 * This function retrieves a document at the specified path in your sync engine.
 * It returns the document content if found, or undefined if the document doesn't exist.
 *
 * @param path - The path identifying the document to read
 * @returns Promise resolving to the document content or undefined if not found
 * @throws Error if the SyncEngine is not properly initialized
 */
readDoc = async <T>(path: string): Promise<T | undefined>;

/**
 * Writes content to a document to keepsync
 *
 * This function creates or updates a document at the specified path.
 * If the document doesn't exist, it creates a new one.
 * If the document already exists, it updates it with the provided content.
 *
 * @param path - The path identifying the document to write
 * @param content - The content to write to the document
 * @throws Error if the SyncEngine is not properly initialized
 */
writeDoc = async <T>(path: string, content: T);

/**
 * Lists documents at a specified path
 *
 * This function retrieves a list of documents at the specified directory path.
 * It returns an array of document names found at that path.
 *
 * @param path - The directory path to list documents from
 * @returns Promise resolving to an array of document names
 * @throws Error if the SyncEngine is not properly initialized
 */
ls = async (path: string): Promise<string[]>;

/**
 * Creates a directory at the specified path
 *
 * This function creates a new directory at the specified path.
 * If the directory already exists, it does nothing.
 *
 * @param path - The path where the directory should be created
 * @throws Error if the SyncEngine is not properly initialized
 */
mkDir = async (path: string): Promise<void>;

/**
 * Removes a document or directory at the specified path
 *
 * This function deletes a document or directory at the specified path.
 * If removing a directory, it will remove all documents within it.
 *
 * @param path - The path of the document or directory to remove
 * @param recursive - Whether to recursively remove directories (default: false)
 * @throws Error if the SyncEngine is not properly initialized
 */
rm = async (path: string, recursive?: boolean): Promise<void>;

/**
 * Listens for changes to a document
 *
 * This function sets up a listener for changes to a document at the specified path.
 * The callback will be called whenever the document changes with detailed patch information.
 *
 * @param path - The path of the document to listen to
 * @param callback - Function to call when the document changes, receives payload with doc, patches, patchInfo, and handle
 * @returns A function that can be called to stop listening
 * @throws Error if the SyncEngine is not properly initialized
 */
listenToDoc = <T>(path: string, callback: (payload: { doc: T; patches: any[]; patchInfo: any; handle: DocHandle<T> }) => void): Promise<() => void>;

File System Operations Example

Here's an example of how to use the file system operations:

import { ls, mkDir, rm, readDoc, writeDoc, listenToDoc } from "@tonk/keepsync";

// Create a directory structure
await mkDir("/users");

// Write a document
await writeDoc("/users/user1", { name: "Alice", age: 30 });
await writeDoc("/users/user2", { name: "Bob", age: 25 });

// List documents in a directory
const users = await ls("/users");
console.log(users); // ["user1", "user2"]

// Read a document
const user1 = await readDoc<{ name: string, age: number }>("/users/user1");
console.log(user1); // { name: "Alice", age: 30 }

// Listen for changes to a document
const unsubscribe = await listenToDoc<{ name: string, age: number }>("/users/user1", (payload) => {
  const { doc: user, patches, patchInfo, handle } = payload;
  if (user) {
    console.log(`User updated: ${user.name}, ${user.age}`);
    console.log("Patches:", patches);
    console.log("Patch info:", patchInfo);
  }
});

// Update the document (will trigger the listener)
await writeDoc("/users/user1", { name: "Alice", age: 31 });

// Stop listening when done
unsubscribe();

// Remove a document
await rm("/users/user2");

// Remove a directory and all its contents
await rm("/users", true);