<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="# Raw WebSocket Handler Proxy for RivetKit

Example project demonstrating raw WebSocket handling 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 or later
- pnpm (for monorepo management)

### Installation

```sh
git clone https://github.com/rivet-dev/rivetkit
cd rivetkit/examples/raw-websocket-handler-proxy
npm install
```

### Development

```sh
npm run dev
```

This starts both the backend server (on port 9000) and the frontend development server (on port 5173).

Open http://localhost:5173 in your browser to see the chat application demo.

### Testing

```sh
npm test
```

## Features

This example demonstrates:

- Creating actors with raw WebSocket handlers using `onWebsocket`
- Managing WebSocket connections and broadcasting messages
- Maintaining actor state across connections
- Supporting multiple connection methods (direct actor connection vs proxy endpoint)
- Real-time chat functionality with user presence
- Message persistence and history limits
- User name changes
- Comprehensive test coverage

## License

Apache 2.0
">
<input type="hidden" name="project[files][package.json]" value="{&quot;name&quot;:&quot;example-raw-websocket-handler-proxy&quot;,&quot;version&quot;:&quot;2.0.20&quot;,&quot;private&quot;:true,&quot;description&quot;:&quot;RivetKit example demonstrating raw WebSocket handler&quot;,&quot;keywords&quot;:[&quot;rivetkit&quot;,&quot;websocket&quot;,&quot;actor&quot;,&quot;chat&quot;,&quot;realtime&quot;],&quot;scripts&quot;:{&quot;dev&quot;:&quot;concurrently \&quot;npm:dev:*\&quot;&quot;,&quot;dev:backend&quot;:&quot;tsx watch src/backend/server.ts&quot;,&quot;dev:frontend&quot;:&quot;vite&quot;,&quot;check-types&quot;:&quot;tsc --noEmit&quot;,&quot;test&quot;:&quot;vitest run&quot;},&quot;dependencies&quot;:{&quot;rivetkit&quot;:&quot;https://pkg.pr.new/rivet-dev/rivetkit/rivetkit@54999214372f9ecb3f4251a3be45c7a0eb8dfacf&quot;,&quot;react&quot;:&quot;^18.3.1&quot;,&quot;react-dom&quot;:&quot;^18.3.1&quot;,&quot;hono&quot;:&quot;^4.7.0&quot;},&quot;devDependencies&quot;:{&quot;@types/node&quot;:&quot;^22.10.2&quot;,&quot;@types/react&quot;:&quot;^18.3.16&quot;,&quot;@types/react-dom&quot;:&quot;^18.3.5&quot;,&quot;@vitejs/plugin-react&quot;:&quot;^4.3.4&quot;,&quot;concurrently&quot;:&quot;^9.1.0&quot;,&quot;tsx&quot;:&quot;^4.19.2&quot;,&quot;typescript&quot;:&quot;^5.7.2&quot;,&quot;vite&quot;:&quot;^6.0.5&quot;,&quot;vitest&quot;:&quot;^3.1.1&quot;}}">
<input type="hidden" name="project[files][tsconfig.json]" value="{
  &quot;compilerOptions&quot;: {
    &quot;target&quot;: &quot;ES2022&quot;,
    &quot;module&quot;: &quot;ESNext&quot;,
    &quot;lib&quot;: [&quot;ES2022&quot;, &quot;DOM&quot;, &quot;DOM.Iterable&quot;],
    &quot;jsx&quot;: &quot;react-jsx&quot;,
    &quot;strict&quot;: true,
    &quot;esModuleInterop&quot;: true,
    &quot;skipLibCheck&quot;: true,
    &quot;forceConsistentCasingInFileNames&quot;: true,
    &quot;moduleResolution&quot;: &quot;bundler&quot;,
    &quot;resolveJsonModule&quot;: true,
    &quot;isolatedModules&quot;: true,
    &quot;noEmit&quot;: true,
    &quot;allowImportingTsExtensions&quot;: true,
    &quot;types&quot;: [&quot;node&quot;, &quot;vitest/globals&quot;]
  },
  &quot;include&quot;: [&quot;src/**/*&quot;, &quot;tests/**/*&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;,
	build: {
		outDir: &quot;../../dist&quot;,
	},
	server: {
		host: &quot;0.0.0.0&quot;,
	},
});
">
<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/basic.test.ts]" value="import { setupTest } from &quot;rivetkit/test&quot;;
import { describe, expect, test } from &quot;vitest&quot;;
import { registry } from &quot;../src/backend/registry.js&quot;;

describe(&quot;basic websocket test&quot;, () =&gt; {
	test(&quot;should handle basic websocket connection&quot;, async (t) =&gt; {
		const { client } = await setupTest(t, registry);
		const actor = client.chatRoom.getOrCreate(&quot;test-room&quot;);

		// Connect with simple path
		const ws = await actor.websocket();

		// Wait for welcome/init message
		const initMessage = await new Promise&lt;any&gt;((resolve, reject) =&gt; {
			ws.addEventListener(
				&quot;message&quot;,
				(event: any) =&gt; {
					resolve(JSON.parse(event.data));
				},
				{ once: true },
			);
			ws.addEventListener(&quot;error&quot;, reject);
			ws.addEventListener(&quot;close&quot;, () =&gt;
				reject(new Error(&quot;Connection closed&quot;)),
			);
		});

		expect(initMessage.type).toBe(&quot;init&quot;);
		expect(initMessage.messages).toEqual([]);
		expect(initMessage.users).toBeDefined();

		// Send a message
		ws.send(JSON.stringify({ type: &quot;message&quot;, text: &quot;Hello!&quot; }));

		// Receive the broadcast
		const message = await new Promise&lt;any&gt;((resolve, reject) =&gt; {
			ws.addEventListener(
				&quot;message&quot;,
				(event: any) =&gt; {
					resolve(JSON.parse(event.data));
				},
				{ once: true },
			);
			ws.addEventListener(&quot;error&quot;, reject);
		});

		expect(message.type).toBe(&quot;message&quot;);
		expect(message.text).toBe(&quot;Hello!&quot;);

		ws.close();
	}, 10000);
});
">
<input type="hidden" name="project[files][tests/websocket.test.ts]" value="import { setupTest } from &quot;rivetkit/test&quot;;
import { describe, expect, test } from &quot;vitest&quot;;
import { registry } from &quot;../src/backend/registry.js&quot;;

describe(&quot;websocket chat&quot;, () =&gt; {
	test(&quot;should connect and receive init message&quot;, async (test) =&gt; {
		const { client } = await setupTest(test, registry);
		const actor = client.chatRoom.getOrCreate(&quot;test-room&quot;);

		const ws = await actor.websocket();

		// Wait for init message
		const initMessage = await new Promise&lt;any&gt;((resolve) =&gt; {
			ws.addEventListener(
				&quot;message&quot;,
				(event: any) =&gt; {
					resolve(JSON.parse(event.data));
				},
				{ once: true },
			);
		});

		expect(initMessage.type).toBe(&quot;init&quot;);
		expect(initMessage.messages).toEqual([]);

		ws.close();
	});

	test(&quot;should broadcast messages&quot;, async (test) =&gt; {
		const { client } = await setupTest(test, registry);
		const actor = client.chatRoom.getOrCreate(&quot;test-room-2&quot;);

		// Connect two clients
		const ws1 = await actor.websocket();
		const ws2 = await actor.websocket();

		// Skip init messages
		await new Promise((resolve) =&gt; {
			ws1.addEventListener(&quot;message&quot;, resolve, { once: true });
		});
		await new Promise((resolve) =&gt; {
			ws2.addEventListener(&quot;message&quot;, resolve, { once: true });
		});

		// Send message from client 1
		ws1.send(
			JSON.stringify({
				type: &quot;message&quot;,
				text: &quot;Hello from client 1&quot;,
			}),
		);

		// Both clients should receive it
		const [msg1, msg2] = await Promise.all([
			new Promise&lt;any&gt;((resolve) =&gt; {
				ws1.addEventListener(
					&quot;message&quot;,
					(event: any) =&gt; {
						resolve(JSON.parse(event.data));
					},
					{ once: true },
				);
			}),
			new Promise&lt;any&gt;((resolve) =&gt; {
				ws2.addEventListener(
					&quot;message&quot;,
					(event: any) =&gt; {
						resolve(JSON.parse(event.data));
					},
					{ once: true },
				);
			}),
		]);

		expect(msg1.type).toBe(&quot;message&quot;);
		expect(msg1.text).toBe(&quot;Hello from client 1&quot;);
		expect(msg2).toEqual(msg1);

		ws1.close();
		ws2.close();
	});

	test(&quot;should persist messages&quot;, async (test) =&gt; {
		const { client } = await setupTest(test, registry);
		const actor = client.chatRoom.getOrCreate(&quot;test-room-3&quot;);

		// First client sends a message
		const ws1 = await actor.websocket();
		await new Promise((resolve) =&gt; {
			ws1.addEventListener(&quot;message&quot;, resolve, { once: true });
		});

		ws1.send(
			JSON.stringify({
				type: &quot;message&quot;,
				text: &quot;Persistent message&quot;,
			}),
		);

		// Wait for the message to be processed
		await new Promise((resolve) =&gt; {
			ws1.addEventListener(&quot;message&quot;, resolve, { once: true });
		});
		ws1.close();

		// Second client connects and should see the message
		const ws2 = await actor.websocket();
		const initMessage = await new Promise&lt;any&gt;((resolve) =&gt; {
			ws2.addEventListener(
				&quot;message&quot;,
				(event: any) =&gt; {
					resolve(JSON.parse(event.data));
				},
				{ once: true },
			);
		});

		expect(initMessage.type).toBe(&quot;init&quot;);
		expect(initMessage.messages).toHaveLength(1);
		expect(initMessage.messages[0].text).toBe(&quot;Persistent message&quot;);

		ws2.close();
	});
});
">
<input type="hidden" name="project[files][src/backend/registry.ts]" value="import { actor, setup } from &quot;rivetkit&quot;;

export const chatRoom = actor({
	state: {
		messages: [] as Array&lt;{
			id: string;
			text: string;
			timestamp: number;
		}&gt;,
	},
	createVars: () =&gt; {
		return {
			sockets: new Set&lt;any&gt;(),
		};
	},
	onWebSocket(ctx, socket) {
		// Add socket to the set
		ctx.vars.sockets.add(socket);

		// Send recent messages to new connection
		socket.send(
			JSON.stringify({
				type: &quot;init&quot;,
				messages: ctx.state.messages,
			}),
		);

		// Handle incoming messages
		socket.addEventListener(&quot;message&quot;, (event: any) =&gt; {
			try {
				const data = JSON.parse(event.data);

				if (data.type === &quot;message&quot; &amp;&amp; data.text) {
					const message = {
						id: crypto.randomUUID(),
						text: data.text,
						timestamp: Date.now(),
					};

					// Add to state
					ctx.state.messages.push(message);
					ctx.saveState({});

					// Keep only last 50 messages
					if (ctx.state.messages.length &gt; 50) {
						ctx.state.messages.shift();
					}

					// Broadcast to all connected clients
					const broadcast = JSON.stringify({
						type: &quot;message&quot;,
						...message,
					});

					for (const ws of ctx.vars.sockets) {
						if (ws.readyState === 1) {
							// OPEN
							ws.send(broadcast);
						}
					}
				}
			} catch (e) {
				console.error(&quot;Failed to process message:&quot;, e);
			}
		});

		// Remove socket on close
		socket.addEventListener(&quot;close&quot;, () =&gt; {
			ctx.vars.sockets.delete(socket);
		});
	},
	actions: {},
});

export const registry = setup({
	use: { chatRoom },
});
">
<input type="hidden" name="project[files][src/backend/server.ts]" value="import { serve } from &quot;@hono/node-server&quot;;
import { createNodeWebSocket } from &quot;@hono/node-ws&quot;;
import { Hono } from &quot;hono&quot;;
import { registry } from &quot;./registry.js&quot;;

const { client } = registry.start({
	cors: {
		origin: &quot;http://localhost:5173&quot;,
		credentials: true,
	},
});

const app = new Hono();
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });

// Forward WebSocket connections to actor&#39;s WebSocket handler
app.get(
	&quot;/ws/:name&quot;,
	upgradeWebSocket(async (c) =&gt; {
		const name = c.req.param(&quot;name&quot;);

		// Connect to actor WebSocket
		const actor = client.chatRoom.getOrCreate(name);
		const actorWs = await actor.websocket(&quot;/&quot;);

		return {
			onOpen: async (_evt, ws) =&gt; {
				// Bridge actor WebSocket to client WebSocket
				actorWs.addEventListener(&quot;message&quot;, (event: MessageEvent) =&gt; {
					ws.send(event.data);
				});

				actorWs.addEventListener(&quot;close&quot;, () =&gt; {
					ws.close();
				});
			},
			onMessage: (evt) =&gt; {
				// Forward message to actor WebSocket
				if (actorWs &amp;&amp; typeof evt.data === &quot;string&quot;) {
					actorWs.send(evt.data);
				}
			},
			onClose: () =&gt; {
				// Forward close to actor WebSocket
				if (actorWs) {
					actorWs.close();
				}
			},
		};
	}),
);

const server = serve({ fetch: app.fetch, port: 8080 });
injectWebSocket(server);
console.log(&quot;Listening on port 8080&quot;);
">
<input type="hidden" name="project[files][src/frontend/App.tsx]" value="import React, { useState, useEffect, useRef } from &quot;react&quot;;

export default function App() {
	const [messages, setMessages] = useState&lt;Array&lt;{ id: string; text: string; timestamp: number }&gt;&gt;([]);
	const [inputText, setInputText] = useState(&quot;&quot;);
	const [isConnected, setIsConnected] = useState(false);
	const wsRef = useRef&lt;WebSocket | null&gt;(null);

	useEffect(() =&gt; {
		// Direct WebSocket connection to actor
		const protocols = [
			`query.${encodeURIComponent(JSON.stringify({
				getOrCreateForKey: { name: &quot;chatRoom&quot;, key: &quot;main&quot; }
			}))}`,
			`encoding.json`,
			`conn_params.${encodeURIComponent(JSON.stringify({ apiKey: &quot;your-api-key&quot; }))}`
		];

		const ws = new WebSocket(&quot;ws://localhost:8080/registry/actors/chatRoom/ws/&quot;, protocols);
		
		ws.onopen = () =&gt; {
			setIsConnected(true);
			console.log(&quot;Connected via direct access!&quot;);
		};

		ws.onmessage = (event) =&gt; {
			const data = JSON.parse(event.data);
			
			if (data.type === &quot;init&quot;) {
				setMessages(data.messages);
			} else if (data.type === &quot;message&quot;) {
				setMessages(prev =&gt; [...prev, {
					id: data.id,
					text: data.text,
					timestamp: data.timestamp
				}]);
			}
		};

		ws.onclose = (event) =&gt; {
			setIsConnected(false);
			console.log(&quot;WebSocket closed:&quot;, event.code, event.reason);
		};

		ws.onerror = (event) =&gt; {
			console.error(&quot;WebSocket error:&quot;, event);
		};

		wsRef.current = ws;

		return () =&gt; {
			ws.close();
		};
	}, []);

	const sendMessage = (e: React.FormEvent) =&gt; {
		e.preventDefault();
		if (!inputText.trim() || !wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;

		wsRef.current.send(JSON.stringify({
			type: &quot;message&quot;,
			text: inputText.trim()
		}));
		setInputText(&quot;&quot;);
	};

	return (
		&lt;div style={{ maxWidth: &quot;800px&quot;, margin: &quot;0 auto&quot;, padding: &quot;20px&quot; }}&gt;
			&lt;h1&gt;Raw WebSocket Chat&lt;/h1&gt;
			
			&lt;div style={{
				padding: &quot;10px&quot;,
				background: isConnected ? &quot;#4caf50&quot; : &quot;#f44336&quot;,
				color: &quot;white&quot;,
				borderRadius: &quot;4px&quot;,
				marginBottom: &quot;20px&quot;
			}}&gt;
				{isConnected ? &quot;Connected&quot; : &quot;Disconnected&quot;}
			&lt;/div&gt;

			&lt;div style={{
				background: &quot;white&quot;,
				border: &quot;1px solid #ddd&quot;,
				borderRadius: &quot;8px&quot;,
				padding: &quot;10px&quot;,
				height: &quot;400px&quot;,
				overflowY: &quot;auto&quot;,
				marginBottom: &quot;10px&quot;
			}}&gt;
				{messages.map((msg) =&gt; (
					&lt;div key={msg.id} style={{ marginBottom: &quot;10px&quot; }}&gt;
						&lt;strong&gt;{new Date(msg.timestamp).toLocaleTimeString()}:&lt;/strong&gt; {msg.text}
					&lt;/div&gt;
				))}
			&lt;/div&gt;

			&lt;form onSubmit={sendMessage} style={{ display: &quot;flex&quot;, gap: &quot;10px&quot; }}&gt;
				&lt;input
					type=&quot;text&quot;
					value={inputText}
					onChange={(e) =&gt; setInputText(e.target.value)}
					placeholder=&quot;Type a message...&quot;
					style={{ flex: 1, padding: &quot;8px&quot;, borderRadius: &quot;4px&quot;, border: &quot;1px solid #ddd&quot; }}
				/&gt;
				&lt;button type=&quot;submit&quot;&gt;Send&lt;/button&gt;
			&lt;/form&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;link rel=&quot;icon&quot; type=&quot;image/svg+xml&quot; href=&quot;/vite.svg&quot; /&gt;
		&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
		&lt;title&gt;RivetKit Raw WebSocket Handler&lt;/title&gt;
		&lt;style&gt;
			body {
				font-family: system-ui, -apple-system, sans-serif;
				background: #f5f5f5;
				margin: 0;
				padding: 0;
			}
		&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 React from &quot;react&quot;;
import ReactDOM from &quot;react-dom/client&quot;;
import App from &quot;./App&quot;;

ReactDOM.createRoot(document.getElementById(&quot;root&quot;)!).render(
	&lt;React.StrictMode&gt;
		&lt;App /&gt;
	&lt;/React.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-raw-websocket-handler-proxy">
</form>
<script>document.getElementById("mainForm").submit();</script>

</body></html>