Skip to content
This repository was archived by the owner on Oct 22, 2025. It is now read-only.

Commit 9a3d850

Browse files
committed
chore(core): add raw ws handler example (#1374)
1 parent c182274 commit 9a3d850

File tree

12 files changed

+418
-0
lines changed

12 files changed

+418
-0
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Raw WebSocket Handler Proxy for RivetKit
2+
3+
Example project demonstrating raw WebSocket handling with [RivetKit](https://rivetkit.org).
4+
5+
[Learn More →](https://github.com/rivet-dev/rivetkit)
6+
7+
[Discord](https://rivet.dev/discord)[Documentation](https://rivetkit.org)[Issues](https://github.com/rivet-dev/rivetkit/issues)
8+
9+
## Getting Started
10+
11+
### Prerequisites
12+
13+
- Node.js 18 or later
14+
- pnpm (for monorepo management)
15+
16+
### Installation
17+
18+
```sh
19+
git clone https://github.com/rivet-dev/rivetkit
20+
cd rivetkit/examples/raw-websocket-handler-proxy
21+
npm install
22+
```
23+
24+
### Development
25+
26+
```sh
27+
npm run dev
28+
```
29+
30+
This starts both the backend server (on port 9000) and the frontend development server (on port 5173).
31+
32+
Open http://localhost:5173 in your browser to see the chat application demo.
33+
34+
### Testing
35+
36+
```sh
37+
npm test
38+
```
39+
40+
## Features
41+
42+
This example demonstrates:
43+
44+
- Creating actors with raw WebSocket handlers using `onWebsocket`
45+
- Managing WebSocket connections and broadcasting messages
46+
- Maintaining actor state across connections
47+
- Supporting multiple connection methods (direct actor connection vs proxy endpoint)
48+
- Real-time chat functionality with user presence
49+
- Message persistence and history limits
50+
- User name changes
51+
- Comprehensive test coverage
52+
53+
## License
54+
55+
Apache 2.0
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"name": "example-raw-websocket-handler",
3+
"version": "2.0.13",
4+
"private": true,
5+
"type": "module",
6+
"description": "RivetKit example demonstrating raw WebSocket handler",
7+
"keywords": [
8+
"rivetkit",
9+
"websocket",
10+
"actor",
11+
"chat",
12+
"realtime"
13+
],
14+
"scripts": {
15+
"dev": "concurrently \"npm:dev:*\"",
16+
"dev:backend": "tsx watch src/backend/server.ts",
17+
"dev:frontend": "vite",
18+
"check-types": "tsc --noEmit",
19+
"test": "vitest run"
20+
},
21+
"dependencies": {
22+
"rivetkit": "workspace:*",
23+
"@rivetkit/react": "workspace:*",
24+
"react": "^18.3.1",
25+
"react-dom": "^18.3.1",
26+
"hono": "^4.7.0"
27+
},
28+
"devDependencies": {
29+
"@types/node": "^22.10.2",
30+
"@types/react": "^18.3.16",
31+
"@types/react-dom": "^18.3.5",
32+
"@vitejs/plugin-react": "^4.3.4",
33+
"concurrently": "^9.1.0",
34+
"tsx": "^4.19.2",
35+
"typescript": "^5.7.2",
36+
"vite": "^6.0.5",
37+
"vitest": "^3.1.1"
38+
}
39+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { actor, setup } from "rivetkit";
2+
3+
export const chatRoom = actor({
4+
state: {
5+
messages: [] as Array<{
6+
id: string;
7+
text: string;
8+
timestamp: number;
9+
}>,
10+
},
11+
createVars: () => {
12+
return {
13+
sockets: new Set<any>(),
14+
};
15+
},
16+
onWebSocket(ctx, socket) {
17+
// Add socket to the set
18+
ctx.vars.sockets.add(socket);
19+
20+
// Send recent messages to new connection
21+
socket.send(
22+
JSON.stringify({
23+
type: "init",
24+
messages: ctx.state.messages,
25+
}),
26+
);
27+
28+
// Handle incoming messages
29+
socket.addEventListener("message", (event: any) => {
30+
try {
31+
const data = JSON.parse(event.data);
32+
33+
if (data.type === "message" && data.text) {
34+
const message = {
35+
id: crypto.randomUUID(),
36+
text: data.text,
37+
timestamp: Date.now(),
38+
};
39+
40+
// Add to state
41+
ctx.state.messages.push(message);
42+
ctx.saveState({});
43+
44+
// Keep only last 50 messages
45+
if (ctx.state.messages.length > 50) {
46+
ctx.state.messages.shift();
47+
}
48+
49+
// Broadcast to all connected clients
50+
const broadcast = JSON.stringify({
51+
type: "message",
52+
...message,
53+
});
54+
55+
for (const ws of ctx.vars.sockets) {
56+
if (ws.readyState === 1) {
57+
// OPEN
58+
ws.send(broadcast);
59+
}
60+
}
61+
}
62+
} catch (e) {
63+
console.error("Failed to process message:", e);
64+
}
65+
});
66+
67+
// Remove socket on close
68+
socket.addEventListener("close", () => {
69+
ctx.vars.sockets.delete(socket);
70+
});
71+
},
72+
actions: {},
73+
});
74+
75+
export const registry = setup({
76+
use: { chatRoom },
77+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { registry } from "./registry.js";
2+
3+
registry.start({
4+
cors: {
5+
origin: "http://localhost:5173",
6+
credentials: true,
7+
},
8+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import React, { useState, useEffect, useRef } from "react";
2+
import { createClient, createRivetKit } from "@rivetkit/react";
3+
import type { registry } from "../backend/registry";
4+
5+
const client = createClient<typeof registry>();
6+
const { useActor } = createRivetKit(client);
7+
8+
export default function App() {
9+
const [messages, setMessages] = useState<Array<{ id: string; text: string; timestamp: number }>>([]);
10+
const [inputText, setInputText] = useState("");
11+
const [isConnected, setIsConnected] = useState(false);
12+
13+
// Connect to the WebSocket actor
14+
const chatRoom = useActor({
15+
name: "chatRoom",
16+
key: ["random"],
17+
});
18+
19+
// Raw WS we created to connect to the actors
20+
const wsRef = useRef<WebSocket | null>(null);
21+
22+
useEffect(() => {
23+
(async () => {
24+
const ws = await chatRoom.handle?.websocket();
25+
26+
if (!ws) return;
27+
28+
ws.onopen = () => {
29+
setIsConnected(true);
30+
console.log("Connected via direct access!");
31+
};
32+
33+
ws.onmessage = (event) => {
34+
const data = JSON.parse(event.data);
35+
36+
if (data.type === "init") {
37+
setMessages(data.messages);
38+
} else if (data.type === "message") {
39+
setMessages(prev => [...prev, {
40+
id: data.id,
41+
text: data.text,
42+
timestamp: data.timestamp
43+
}]);
44+
}
45+
};
46+
47+
ws.onclose = (event) => {
48+
setIsConnected(false);
49+
console.log("WebSocket closed:", event.code, event.reason);
50+
};
51+
52+
ws.onerror = (event) => {
53+
console.error("WebSocket error:", event);
54+
};
55+
56+
wsRef.current = ws;
57+
})();
58+
59+
return () => {
60+
if (wsRef.current) {
61+
wsRef.current.close();
62+
}
63+
};
64+
}, [chatRoom.handle]);
65+
66+
const sendMessage = (e: React.FormEvent) => {
67+
e.preventDefault();
68+
if (!inputText.trim() || !wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
69+
70+
wsRef.current.send(JSON.stringify({
71+
type: "message",
72+
text: inputText.trim()
73+
}));
74+
setInputText("");
75+
};
76+
77+
return (
78+
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "20px" }}>
79+
<h1>Raw WebSocket Chat</h1>
80+
81+
<div style={{
82+
padding: "10px",
83+
background: isConnected ? "#4caf50" : "#f44336",
84+
color: "white",
85+
borderRadius: "4px",
86+
marginBottom: "20px"
87+
}}>
88+
{isConnected ? "Connected" : "Disconnected"}
89+
</div>
90+
91+
<div style={{
92+
background: "white",
93+
border: "1px solid #ddd",
94+
borderRadius: "8px",
95+
padding: "10px",
96+
height: "400px",
97+
overflowY: "auto",
98+
marginBottom: "10px"
99+
}}>
100+
{messages.map((msg) => (
101+
<div key={msg.id} style={{ marginBottom: "10px" }}>
102+
<strong>{new Date(msg.timestamp).toLocaleTimeString()}:</strong> {msg.text}
103+
</div>
104+
))}
105+
</div>
106+
107+
<form onSubmit={sendMessage} style={{ display: "flex", gap: "10px" }}>
108+
<input
109+
type="text"
110+
value={inputText}
111+
onChange={(e) => setInputText(e.target.value)}
112+
placeholder="Type a message..."
113+
style={{ flex: 1, padding: "8px", borderRadius: "4px", border: "1px solid #ddd" }}
114+
/>
115+
<button type="submit">Send</button>
116+
</form>
117+
</div>
118+
);
119+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>RivetKit Raw WebSocket Handler</title>
8+
<style>
9+
body {
10+
font-family: system-ui, -apple-system, sans-serif;
11+
background: #f5f5f5;
12+
margin: 0;
13+
padding: 0;
14+
}
15+
</style>
16+
</head>
17+
<body>
18+
<div id="root"></div>
19+
<script type="module" src="/main.tsx"></script>
20+
</body>
21+
</html>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import React from "react";
2+
import ReactDOM from "react-dom/client";
3+
import App from "./App";
4+
5+
ReactDOM.createRoot(document.getElementById("root")!).render(
6+
<React.StrictMode>
7+
<App />
8+
</React.StrictMode>,
9+
);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"module": "ESNext",
5+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
6+
"jsx": "react-jsx",
7+
"strict": true,
8+
"esModuleInterop": true,
9+
"skipLibCheck": true,
10+
"forceConsistentCasingInFileNames": true,
11+
"moduleResolution": "bundler",
12+
"resolveJsonModule": true,
13+
"isolatedModules": true,
14+
"noEmit": true,
15+
"allowImportingTsExtensions": true,
16+
"types": ["node", "vitest/globals"]
17+
},
18+
"include": ["src/**/*", "tests/**/*"],
19+
"exclude": ["node_modules", "dist"]
20+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"$schema": "https://turbo.build/schema.json",
3+
"extends": ["//"]
4+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import react from "@vitejs/plugin-react";
2+
import { defineConfig } from "vite";
3+
4+
export default defineConfig({
5+
plugins: [react()],
6+
root: "src/frontend",
7+
build: {
8+
outDir: "../../dist",
9+
},
10+
server: {
11+
host: "0.0.0.0",
12+
},
13+
});

0 commit comments

Comments
 (0)