<html lang="en">
<head></head>
<body>

<form id="mainForm" method="post" action="https://stackblitz.com/run" target="_self">
<input type="hidden" name="project[files][README.md]" value="# CRDT Collaborative Editor for RivetKit

Example project demonstrating real-time collaborative editing using Conflict-free Replicated Data Types (CRDTs) with [RivetKit](https://rivetkit.org).

[Learn More →](https://github.com/rivet-dev/rivetkit)

[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivetkit/issues)

## Getting Started

### Prerequisites

- Node.js 18+

### Installation

```sh
git clone https://github.com/rivet-dev/rivetkit
cd rivetkit/examples/crdt
npm install
```

### Development

```sh
npm run dev
```

Open your browser to `http://localhost:3000`

## Features

- **Real-time Collaborative Editing**: Multiple users can edit the same document simultaneously
- **Conflict Resolution**: Uses Yjs CRDTs to automatically resolve editing conflicts
- **Persistent State**: Document changes are automatically persisted
- **Multiple Documents**: Switch between different collaborative documents
- **Live Connection Status**: See when you&#39;re connected to the collaboration server

## How it works

This example demonstrates how to build a collaborative editor using:

1. **Yjs**: A high-performance CRDT implementation for building collaborative applications
2. **RivetKit Actors**: Manage document state and synchronize changes between clients
3. **Real-time Updates**: Use RivetKit&#39;s event system for instant synchronization
4. **Conflict-free Merging**: Yjs automatically handles concurrent edits without conflicts

## Usage

1. Start the development server
2. Open multiple browser tabs to `http://localhost:3000`
3. Start typing in any tab - changes will appear in real-time across all tabs
4. Try editing the same text simultaneously to see conflict resolution in action
5. Switch between different documents using the document ID field

## Architecture

- **Backend**: RivetKit actor that manages Yjs document state and broadcasts updates
- **Frontend**: React application with Yjs integration for local document management
- **Synchronization**: Binary diffs are sent between clients for efficient updates

## License

Apache 2.0">
<input type="hidden" name="project[files][package.json]" value="{&quot;name&quot;:&quot;example-crdt&quot;,&quot;version&quot;:&quot;2.0.21&quot;,&quot;private&quot;:true,&quot;type&quot;:&quot;module&quot;,&quot;scripts&quot;:{&quot;dev&quot;:&quot;concurrently \&quot;npm run dev:backend\&quot; \&quot;npm run dev:frontend\&quot;&quot;,&quot;dev:backend&quot;:&quot;tsx --watch src/backend/server.ts&quot;,&quot;dev:frontend&quot;:&quot;vite&quot;,&quot;build&quot;:&quot;vite build&quot;,&quot;check-types&quot;:&quot;tsc --noEmit&quot;,&quot;test&quot;:&quot;vitest run&quot;},&quot;devDependencies&quot;:{&quot;@types/node&quot;:&quot;^22.13.9&quot;,&quot;@types/react&quot;:&quot;^18.2.0&quot;,&quot;@types/react-dom&quot;:&quot;^18.2.0&quot;,&quot;@vitejs/plugin-react&quot;:&quot;^4.2.0&quot;,&quot;concurrently&quot;:&quot;^8.2.2&quot;,&quot;rivetkit&quot;:&quot;https://pkg.pr.new/rivet-dev/rivet/rivetkit@1f30194ba7e46670304ba95e436cef024fbc089b&quot;,&quot;tsx&quot;:&quot;^3.12.7&quot;,&quot;typescript&quot;:&quot;^5.5.2&quot;,&quot;vite&quot;:&quot;^5.0.0&quot;,&quot;vitest&quot;:&quot;^3.1.1&quot;},&quot;dependencies&quot;:{&quot;@rivetkit/react&quot;:&quot;https://pkg.pr.new/rivet-dev/rivet/@rivetkit/react@1f30194ba7e46670304ba95e436cef024fbc089b&quot;,&quot;react&quot;:&quot;^18.2.0&quot;,&quot;react-dom&quot;:&quot;^18.2.0&quot;,&quot;yjs&quot;:&quot;^13.6.20&quot;},&quot;stableVersion&quot;:&quot;0.8.0&quot;}">
<input type="hidden" name="project[files][tsconfig.json]" value="{
	&quot;compilerOptions&quot;: {
		&quot;target&quot;: &quot;esnext&quot;,
		&quot;lib&quot;: [&quot;esnext&quot;, &quot;dom&quot;],
		&quot;jsx&quot;: &quot;react-jsx&quot;,
		&quot;module&quot;: &quot;esnext&quot;,
		&quot;moduleResolution&quot;: &quot;bundler&quot;,
		&quot;types&quot;: [&quot;node&quot;, &quot;vite/client&quot;],
		&quot;resolveJsonModule&quot;: true,
		&quot;allowJs&quot;: true,
		&quot;checkJs&quot;: false,
		&quot;noEmit&quot;: true,
		&quot;isolatedModules&quot;: true,
		&quot;allowSyntheticDefaultImports&quot;: true,
		&quot;forceConsistentCasingInFileNames&quot;: true,
		&quot;strict&quot;: true,
		&quot;skipLibCheck&quot;: true
	},
	&quot;include&quot;: [&quot;src/**/*&quot;],
	&quot;exclude&quot;: [&quot;node_modules&quot;, &quot;dist&quot;]
}
">
<input type="hidden" name="project[files][turbo.json]" value="{
	&quot;$schema&quot;: &quot;https://turbo.build/schema.json&quot;,
	&quot;extends&quot;: [&quot;//&quot;]
}
">
<input type="hidden" name="project[files][vite.config.ts]" value="import react from &quot;@vitejs/plugin-react&quot;;
import { defineConfig } from &quot;vite&quot;;

export default defineConfig({
	plugins: [react()],
	root: &quot;src/frontend&quot;,
	server: {
		port: 3000,
	},
});
">
<input type="hidden" name="project[files][vitest.config.ts]" value="import { defineConfig } from &quot;vitest/config&quot;;

export default defineConfig({
	test: {
		include: [&quot;tests/**/*.test.ts&quot;],
	},
});
">
<input type="hidden" name="project[files][tests/crdt.test.ts]" value="import { setupTest } from &quot;rivetkit/test&quot;;
import { expect, test, vi } from &quot;vitest&quot;;
import { registry } from &quot;../src/backend/registry&quot;;

// Mock Yjs to avoid complex binary operations in tests
vi.mock(&quot;yjs&quot;, () =&gt; ({
	Doc: vi.fn().mockImplementation(() =&gt; ({
		getText: vi.fn().mockReturnValue({
			toString: vi.fn().mockReturnValue(&quot;&quot;),
			observe: vi.fn(),
			delete: vi.fn(),
			insert: vi.fn(),
		}),
		transact: vi.fn((fn) =&gt; fn()),
		destroy: vi.fn(),
	})),
	applyUpdate: vi.fn(),
	encodeStateAsUpdate: vi.fn().mockReturnValue(new Uint8Array([1, 2, 3, 4])),
}));

test(&quot;CRDT document can handle initial state&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const doc = client.yjsDocument.getOrCreate([&quot;test-doc&quot;]);

	// Test initial state
	const state = await doc.getState();
	expect(state.docData).toBeInstanceOf(Uint8Array);
	expect(state.docData.length).toBe(0);
	expect(state.lastModified).toBe(0);
});

test(&quot;CRDT document can apply updates&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const doc = client.yjsDocument.getOrCreate([&quot;test-updates&quot;]);

	// Mock update data as binary
	const updateBytes = new Uint8Array([1, 2, 3, 4, 5]);

	// Apply an update
	await doc.applyUpdate(updateBytes);

	// Verify state was updated
	const state = await doc.getState();
	expect(state.docData.length).toBeGreaterThan(0);
	expect(state.lastModified).toBeGreaterThan(0);
});

test(&quot;CRDT document handles multiple updates&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const doc = client.yjsDocument.getOrCreate([&quot;test-multiple&quot;]);

	const update1 = new Uint8Array([1, 2, 3]);
	const update2 = new Uint8Array([4, 5, 6]);
	const update3 = new Uint8Array([7, 8, 9]);

	// Apply multiple updates
	await doc.applyUpdate(update1);
	const state1 = await doc.getState();
	const firstModified = state1.lastModified;

	await doc.applyUpdate(update2);
	const state2 = await doc.getState();
	const secondModified = state2.lastModified;

	await doc.applyUpdate(update3);
	const state3 = await doc.getState();
	const thirdModified = state3.lastModified;

	// Verify timestamps are increasing
	expect(secondModified).toBeGreaterThanOrEqual(firstModified);
	expect(thirdModified).toBeGreaterThanOrEqual(secondModified);

	// Verify state is updated
	expect(state3.docData.length).toBeGreaterThan(0);
	expect(state3.lastModified).toBe(thirdModified);
});

test(&quot;CRDT document handles binary data correctly&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const doc = client.yjsDocument.getOrCreate([&quot;test-encoding&quot;]);

	// Test with specific binary data
	const updateBytes = new TextEncoder().encode(&quot;Hello, collaborative world!&quot;);

	await doc.applyUpdate(updateBytes);

	const state = await doc.getState();
	expect(state.docData.length).toBeGreaterThan(0);
	expect(state.lastModified).toBeGreaterThan(0);
});
">
<input type="hidden" name="project[files][src/backend/registry.ts]" value="import { actor, setup } from &quot;rivetkit&quot;;
import * as Y from &quot;yjs&quot;;
import { applyUpdate, encodeStateAsUpdate } from &quot;yjs&quot;;

export const yjsDocument = actor({
	// Persistent state that survives restarts.
	state: {
		docData: new Uint8Array(), // Raw Yjs document snapshot
		lastModified: 0,
	},

	createVars: () =&gt; ({
		doc: new Y.Doc(),
	}),

	onWake: (c) =&gt; {
		if (c.state.docData.length &gt; 0) {
			applyUpdate(c.vars.doc, c.state.docData);
		}
	},

	// Handle client connections.
	onConnect: (c, conn) =&gt; {
		const update = encodeStateAsUpdate(c.vars.doc);
		conn.send(&quot;initialState&quot;, { update });
	},

	actions: {
		// Callable functions from clients.
		applyUpdate: (c, update: Uint8Array) =&gt; {
			applyUpdate(c.vars.doc, update);

			const fullState = encodeStateAsUpdate(
				c.vars.doc,
			) as Uint8Array&lt;ArrayBuffer&gt;;
			// State changes are automatically persisted
			c.state.docData = fullState;
			c.state.lastModified = Date.now();

			// Send events to all connected clients.
			c.broadcast(&quot;update&quot;, { update });
		},

		getState: (c) =&gt; ({
			docData: c.state.docData,
			lastModified: c.state.lastModified,
		}),
	},
});

// Register actors for use.
export const registry = setup({
	use: { yjsDocument },
});
">
<input type="hidden" name="project[files][src/backend/server.ts]" value="import { registry } from &quot;./registry&quot;;

registry.start();
">
<input type="hidden" name="project[files][src/frontend/App.tsx]" value="import { createRivetKit } from &quot;@rivetkit/react&quot;;
import { useEffect, useRef, useState } from &quot;react&quot;;
import * as Y from &quot;yjs&quot;;
import { applyUpdate, encodeStateAsUpdate } from &quot;yjs&quot;;
import type { registry } from &quot;../backend/registry&quot;;

const { useActor } = createRivetKit&lt;typeof registry&gt;(&quot;http://localhost:8080&quot;);

function YjsEditor({ documentId }: { documentId: string }) {
	const yjsDocument = useActor({
		name: &quot;yjsDocument&quot;,
		key: [documentId],
	});

	const [isLoading, setIsLoading] = useState(true);
	const [text, setText] = useState(&quot;&quot;);

	const yDocRef = useRef&lt;Y.Doc | null&gt;(null);
	const updatingFromServer = useRef(false);
	const updatingFromLocal = useRef(false);
	const observationInitialized = useRef(false);

	useEffect(() =&gt; {
		const yDoc = new Y.Doc();
		yDocRef.current = yDoc;
		setIsLoading(false);

		return () =&gt; {
			yDoc.destroy();
		};
	}, [yjsDocument.connection]);

	useEffect(() =&gt; {
		const yDoc = yDocRef.current;
		if (!yDoc || observationInitialized.current) return;

		const yText = yDoc.getText(&quot;content&quot;);

		yText.observe(() =&gt; {
			if (!updatingFromServer.current) {
				setText(yText.toString());

				if (yjsDocument.connection &amp;&amp; !updatingFromLocal.current) {
					updatingFromLocal.current = true;

					const update = encodeStateAsUpdate(yDoc);
					yjsDocument.connection.applyUpdate(update).finally(() =&gt; {
						updatingFromLocal.current = false;
					});
				}
			}
		});

		observationInitialized.current = true;
	}, [yjsDocument.connection]);

	yjsDocument.useEvent(&quot;initialState&quot;, ({ update }: { update: Uint8Array }) =&gt; {
		const yDoc = yDocRef.current;
		if (!yDoc) return;

		updatingFromServer.current = true;

		try {
			applyUpdate(yDoc, update);

			const yText = yDoc.getText(&quot;content&quot;);
			setText(yText.toString());
		} catch (error) {
			console.error(&quot;Error applying initial update:&quot;, error);
		} finally {
			updatingFromServer.current = false;
		}
	});

	yjsDocument.useEvent(&quot;update&quot;, ({ update }: { update: Uint8Array }) =&gt; {
		const yDoc = yDocRef.current;
		if (!yDoc) return;

		updatingFromServer.current = true;

		try {
			applyUpdate(yDoc, update);

			const yText = yDoc.getText(&quot;content&quot;);
			setText(yText.toString());
		} catch (error) {
			console.error(&quot;Error applying update:&quot;, error);
		} finally {
			updatingFromServer.current = false;
		}
	});

	const handleTextChange = (e: React.ChangeEvent&lt;HTMLTextAreaElement&gt;) =&gt; {
		if (!yDocRef.current) return;

		const newText = e.target.value;
		const yText = yDocRef.current.getText(&quot;content&quot;);

		if (newText !== yText.toString()) {
			updatingFromLocal.current = true;

			yDocRef.current.transact(() =&gt; {
				yText.delete(0, yText.length);
				yText.insert(0, newText);
			});

			updatingFromLocal.current = false;
		}
	};

	if (isLoading) {
		return &lt;div className=&quot;loading&quot;&gt;Loading collaborative document...&lt;/div&gt;;
	}

	return (
		&lt;div className=&quot;editor-container&quot;&gt;
			&lt;div className=&quot;editor-header&quot;&gt;
				&lt;h3&gt;Document: {documentId}&lt;/h3&gt;
				&lt;div className={`connection-status ${yjsDocument.connection ? &#39;connected&#39; : &#39;disconnected&#39;}`}&gt;
					{yjsDocument.connection ? &#39;Connected&#39; : &#39;Disconnected&#39;}
				&lt;/div&gt;
			&lt;/div&gt;
			&lt;textarea
				value={text}
				onChange={handleTextChange}
				placeholder=&quot;Start typing... All changes are synchronized in real-time with other users!&quot;
				className=&quot;collaborative-textarea&quot;
			/&gt;
		&lt;/div&gt;
	);
}

export function App() {
	const [documentId, setDocumentId] = useState(&quot;shared-doc&quot;);
	const [inputDocId, setInputDocId] = useState(&quot;shared-doc&quot;);

	const switchDocument = () =&gt; {
		setDocumentId(inputDocId);
	};

	return (
		&lt;div className=&quot;app-container&quot;&gt;
			&lt;div className=&quot;header&quot;&gt;
				&lt;h1&gt;CRDT Collaborative Editor&lt;/h1&gt;
				&lt;p&gt;Real-time collaborative text editing powered by Yjs and RivetKit&lt;/p&gt;
			&lt;/div&gt;

			&lt;div className=&quot;info-box&quot;&gt;
				&lt;h4&gt;How it works&lt;/h4&gt;
				&lt;p&gt;
					This editor uses Conflict-free Replicated Data Types (CRDTs) with Yjs to enable
					real-time collaborative editing. Open multiple browser tabs or share the URL
					with others to see live collaboration in action!
				&lt;/p&gt;
			&lt;/div&gt;

			&lt;div className=&quot;document-controls&quot;&gt;
				&lt;label&gt;Document ID:&lt;/label&gt;
				&lt;input
					type=&quot;text&quot;
					value={inputDocId}
					onChange={(e) =&gt; setInputDocId(e.target.value)}
					placeholder=&quot;Enter document ID&quot;
				/&gt;
				&lt;button onClick={switchDocument}&gt;
					Switch Document
				&lt;/button&gt;
			&lt;/div&gt;

			&lt;YjsEditor key={documentId} documentId={documentId} /&gt;
		&lt;/div&gt;
	);
}
">
<input type="hidden" name="project[files][src/frontend/index.html]" value="&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;title&gt;CRDT Collaborative Editor&lt;/title&gt;
    &lt;style&gt;
        body {
            font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, sans-serif;
            margin: 0;
            padding: 20px;
            background-color: #f5f5f5;
        }
        .app-container {
            max-width: 1200px;
            margin: 0 auto;
        }
        .header {
            text-align: center;
            margin-bottom: 30px;
        }
        .header h1 {
            color: #333;
            margin-bottom: 10px;
        }
        .header p {
            color: #666;
            font-size: 1.1em;
        }
        .document-controls {
            background: white;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            margin-bottom: 20px;
            display: flex;
            gap: 15px;
            align-items: center;
        }
        .document-controls label {
            font-weight: 500;
        }
        .document-controls input {
            padding: 8px 12px;
            border: 1px solid #ddd;
            border-radius: 4px;
            font-size: 16px;
        }
        .document-controls button {
            padding: 8px 16px;
            background: #007bff;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 16px;
        }
        .document-controls button:hover {
            background: #0056b3;
        }
        .editor-container {
            background: white;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            overflow: hidden;
        }
        .editor-header {
            background: #f8f9fa;
            padding: 15px 20px;
            border-bottom: 1px solid #eee;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        .editor-header h3 {
            margin: 0;
            color: #333;
        }
        .connection-status {
            padding: 4px 8px;
            border-radius: 4px;
            font-size: 0.9em;
            font-weight: 500;
        }
        .connection-status.connected {
            background: #d4edda;
            color: #155724;
        }
        .connection-status.disconnected {
            background: #f8d7da;
            color: #721c24;
        }
        .collaborative-textarea {
            width: 100%;
            min-height: 400px;
            padding: 20px;
            border: none;
            font-family: &#39;Monaco&#39;, &#39;Menlo&#39;, &#39;Ubuntu Mono&#39;, monospace;
            font-size: 14px;
            line-height: 1.5;
            resize: vertical;
            outline: none;
        }
        .loading {
            text-align: center;
            padding: 40px;
            color: #666;
            font-style: italic;
        }
        .info-box {
            background: #e3f2fd;
            border: 1px solid #2196f3;
            border-radius: 8px;
            padding: 15px;
            margin-bottom: 20px;
        }
        .info-box h4 {
            margin: 0 0 10px 0;
            color: #1976d2;
        }
        .info-box p {
            margin: 0;
            color: #333;
        }
    &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
    &lt;div id=&quot;root&quot;&gt;&lt;/div&gt;
    &lt;script type=&quot;module&quot; src=&quot;/main.tsx&quot;&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;">
<input type="hidden" name="project[files][src/frontend/main.tsx]" value="import { StrictMode } from &quot;react&quot;;
import { createRoot } from &quot;react-dom/client&quot;;
import { App } from &quot;./App&quot;;

const root = document.getElementById(&quot;root&quot;);
if (!root) throw new Error(&quot;Root element not found&quot;);

createRoot(root).render(
	&lt;StrictMode&gt;
		&lt;App /&gt;
	&lt;/StrictMode&gt;
);">
<input type="hidden" name="project[description]" value="generated by https://pkg.pr.new">
<input type="hidden" name="project[template]" value="node">
<input type="hidden" name="project[title]" value="example-crdt">
</form>
<script>document.getElementById("mainForm").submit();</script>

</body></html>