diff --git a/examples/kitchen-sink/README.md b/examples/kitchen-sink/README.md new file mode 100644 index 000000000..66221e34c --- /dev/null +++ b/examples/kitchen-sink/README.md @@ -0,0 +1,36 @@ +# Kitchen Sink Example for RivetKit + +Example project demonstrating all RivetKit features 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 Bun +- RivetKit development environment + +### Installation + +```sh +git clone https://github.com/rivet-dev/rivetkit +cd rivetkit/examples/kitchen-sink +npm install +``` + +### Development + +```sh +npm run dev +``` + +This will start both the backend server (port 8080) and frontend development server (port 5173). + +Open [http://localhost:5173](http://localhost:5173) in your browser. + +## License + +Apache 2.0 diff --git a/examples/kitchen-sink/SPEC.md b/examples/kitchen-sink/SPEC.md new file mode 100644 index 000000000..ebbc7f3b1 --- /dev/null +++ b/examples/kitchen-sink/SPEC.md @@ -0,0 +1,126 @@ +# Kitchen Sink Example Specification + +## Overview +Comprehensive example demonstrating all RivetKit features with a simple React frontend. + +## UI Structure + +### Header (Always visible) + +**Configuration Row 1:** +- **Transport**: WebSocket ↔ SSE toggle +- **Encoding**: JSON ↔ CBOR ↔ Bare dropdown +- **Connection Mode**: Handle ↔ Connection toggle + +**Actor Management Row 2:** +- **Actor Name**: dropdown +- **Key**: input +- **Region**: input +- **Input JSON**: textarea +- **Buttons**: `create()` | `get()` | `getOrCreate()` | `getForId()` (when no actor connected) +- **OR Button**: `dispose()` (when actor connected) + +**Status Row 3:** +- Connection status indicator with actor info (Name | Key | Actor ID | Status) + +### Main Content (8 Tabs - disabled until actor connected) + +#### 1. Actions +- Action name dropdown/input +- Arguments JSON textarea +- Call button +- Response display + +#### 2. Events +- Event listener controls +- Live event feed with timestamps +- Event type filter + +#### 3. Schedule +- `schedule.at()` with timestamp input +- `schedule.after()` with delay input +- Scheduled task history +- Custom alarm data input + +#### 4. Sleep +- `sleep()` button +- Sleep timeout configuration +- Lifecycle event display (`onStart`, `onStop`) + +#### 5. Connections (Only visible in Connection Mode) +- Connection state display +- Connection info (ID, transport, encoding) +- Connection-specific event history + +#### 6. Raw HTTP +- HTTP method dropdown +- Path input +- Headers textarea +- Body textarea +- Send button & response display + +#### 7. Raw WebSocket +- Message input (text/binary toggle) +- Send button +- Message history (sent/received with timestamps) + +#### 8. Metadata +- Actor name, tags, region display + +## User Flow +1. Configure transport/encoding/connection mode +2. Fill actor details (name, key, region, input) +3. Click create/get/getOrCreate/getForId +4. Status shows connection info, tabs become enabled +5. Use tabs to test features +6. Click dispose() to disconnect and return to step 1 + +## Actors + +### 1. `demo` - Main comprehensive actor +- All action types (sync/async/promise) +- Events and state management +- Scheduling capabilities (`schedule.at()`, `schedule.after()`) +- Sleep functionality with configurable timeout +- Connection state support +- Lifecycle hooks (`onStart`, `onStop`, `onConnect`, `onDisconnect`) +- Metadata access + +### 2. `http` - Raw HTTP handling +- `onFetch()` handler +- Multiple endpoint examples +- Various HTTP methods support + +### 3. `websocket` - Raw WebSocket handling +- `onWebSocket()` handler +- Text and binary message support +- Connection lifecycle management + +## Features Demonstrated + +### Core Features +- **Actions**: sync, async, promise-based with various input types +- **Events**: broadcasting to connected clients +- **State Management**: actor state + per-connection state +- **Scheduling**: `schedule.at()`, `schedule.after()` with alarm handlers +- **Force Sleep**: `sleep()` method with configurable sleep timeout +- **Lifecycle Hooks**: `onStart`, `onStop`, `onConnect`, `onDisconnect` + +### Configuration Options +- **Transport**: WebSocket vs Server-Sent Events +- **Encoding**: JSON, CBOR, Bare + +### Raw Protocols +- **Raw HTTP**: Direct HTTP request handling +- **Raw WebSocket**: Direct WebSocket connection handling + +### Connection Patterns +- **Handle Mode**: Fire-and-forget action calls +- **Connection Mode**: Persistent connection with real-time events + +### Actor Management +- **Create**: `client.actor.create(key, opts)` +- **Get**: `client.actor.get(key, opts)` +- **Get or Create**: `client.actor.getOrCreate(key, opts)` +- **Get by ID**: `client.actor.getForId(actorId, opts)` +- **Dispose**: `client.dispose()` - disconnect all connections \ No newline at end of file diff --git a/examples/kitchen-sink/index.html b/examples/kitchen-sink/index.html new file mode 100644 index 000000000..04d64ac54 --- /dev/null +++ b/examples/kitchen-sink/index.html @@ -0,0 +1,13 @@ + + + + + + + RivetKit Kitchen Sink + + +
+ + + \ No newline at end of file diff --git a/examples/kitchen-sink/package.json b/examples/kitchen-sink/package.json new file mode 100644 index 000000000..3413bacf4 --- /dev/null +++ b/examples/kitchen-sink/package.json @@ -0,0 +1,29 @@ +{ + "name": "example-kitchen-sink", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", + "dev:backend": "tsx --watch src/backend/server.ts", + "dev:frontend": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@rivetkit/react": "workspace:*", + "rivetkit": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.2.1", + "concurrently": "^8.2.2", + "tsx": "^4.7.1", + "typescript": "^5.2.2", + "vite": "^5.2.0" + } +} diff --git a/examples/kitchen-sink/src/backend/actors/demo.ts b/examples/kitchen-sink/src/backend/actors/demo.ts new file mode 100644 index 000000000..0c7252d5d --- /dev/null +++ b/examples/kitchen-sink/src/backend/actors/demo.ts @@ -0,0 +1,155 @@ +import { actor } from "rivetkit"; +import { handleHttpRequest, httpActions } from "./http"; +import { handleWebSocket, websocketActions } from "./websocket"; + +export const demo = actor({ + createState: (_c, input) => ({ + input, + count: 0, + lastMessage: "", + alarmHistory: [] as { id: string; time: number; data?: any }[], + startCount: 0, + stopCount: 0, + }), + connState: { + connectionTime: 0, + }, + onStart: (c) => { + c.state.startCount += 1; + c.log.info({ msg: "demo actor started", startCount: c.state.startCount }); + }, + onStop: (c) => { + c.state.stopCount += 1; + c.log.info({ msg: "demo actor stopped", stopCount: c.state.stopCount }); + }, + onConnect: (c, conn) => { + conn.state.connectionTime = Date.now(); + c.log.info({ + msg: "client connected", + connectionTime: conn.state.connectionTime, + }); + }, + onDisconnect: (c) => { + c.log.info("client disconnected"); + }, + onFetch: handleHttpRequest, + onWebSocket: handleWebSocket, + actions: { + // Sync actions + increment: (c, amount: number = 1) => { + c.state.count += amount; + c.broadcast("countChanged", { count: c.state.count, amount }); + return c.state.count; + }, + getCount: (c) => { + return c.state.count; + }, + setMessage: (c, message: string) => { + c.state.lastMessage = message; + c.broadcast("messageChanged", { message }); + return message; + }, + + // Async actions + delayedIncrement: async (c, amount: number = 1, delayMs: number = 1000) => { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + c.state.count += amount; + c.broadcast("countChanged", { count: c.state.count, amount }); + return c.state.count; + }, + + // Promise action + promiseAction: () => { + return Promise.resolve({ + timestamp: Date.now(), + message: "promise resolved", + }); + }, + + // State management + getState: (c) => { + return { + actorState: c.state, + connectionState: c.conn.state, + }; + }, + + // Scheduling + scheduleAlarmAt: (c, timestamp: number, data?: any) => { + const id = `alarm-${Date.now()}`; + c.schedule.at(timestamp, "onAlarm", { id, data }); + return { id, scheduledFor: timestamp }; + }, + scheduleAlarmAfter: (c, delayMs: number, data?: any) => { + const id = `alarm-${Date.now()}`; + c.schedule.after(delayMs, "onAlarm", { id, data }); + return { id, scheduledFor: Date.now() + delayMs }; + }, + onAlarm: (c, payload: { id: string; data?: any }) => { + const alarmEntry = { ...payload, time: Date.now() }; + c.state.alarmHistory.push(alarmEntry); + c.broadcast("alarmTriggered", alarmEntry); + c.log.info({ msg: "alarm triggered", ...alarmEntry }); + }, + getAlarmHistory: (c) => { + return c.state.alarmHistory; + }, + clearAlarmHistory: (c) => { + c.state.alarmHistory = []; + return true; + }, + + // Sleep + triggerSleep: (c) => { + c.sleep(); + return "sleep triggered"; + }, + + // Lifecycle info + getLifecycleInfo: (c) => { + return { + startCount: c.state.startCount, + stopCount: c.state.stopCount, + }; + }, + + // Metadata + getMetadata: (c) => { + return { + name: c.name, + }; + }, + getInput: (c) => { + return c.state.input; + }, + getActorState: (c) => { + return c.state; + }, + getConnState: (c) => { + return c.conn.state; + }, + + // Events + broadcastCustomEvent: (c, eventName: string, data: any) => { + c.broadcast(eventName, data); + return { eventName, data, timestamp: Date.now() }; + }, + + // Connections + listConnections: (c) => { + return Array.from(c.conns.values()).map((conn) => ({ + id: conn.id, + connectedAt: conn.state.connectionTime, + })); + }, + + // HTTP actions + ...httpActions, + + // WebSocket actions + ...websocketActions, + }, + options: { + sleepTimeout: 2000, + }, +}); diff --git a/examples/kitchen-sink/src/backend/actors/http.ts b/examples/kitchen-sink/src/backend/actors/http.ts new file mode 100644 index 000000000..ff96bf097 --- /dev/null +++ b/examples/kitchen-sink/src/backend/actors/http.ts @@ -0,0 +1,148 @@ +import type { ActorContext } from "rivetkit"; + +export function handleHttpRequest( + c: ActorContext, + request: Request, +) { + const url = new URL(request.url); + const method = request.method; + const path = url.pathname; + + // Track request + if (!c.state.requestCount) c.state.requestCount = 0; + if (!c.state.requestHistory) c.state.requestHistory = []; + + c.state.requestCount++; + c.state.requestHistory.push({ + method, + path, + timestamp: Date.now(), + headers: Object.fromEntries(request.headers.entries()), + }); + + c.log.info({ + msg: "http request received", + method, + path, + fullUrl: request.url, + requestCount: c.state.requestCount, + }); + + // Handle different endpoints + if (path === "/api/hello") { + return new Response( + JSON.stringify({ + message: "Hello from HTTP actor!", + timestamp: Date.now(), + requestCount: c.state.requestCount, + }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + } + + if (path === "/api/echo" && method === "POST") { + return new Response(request.body, { + headers: { + "Content-Type": request.headers.get("Content-Type") || "text/plain", + "X-Echo-Timestamp": Date.now().toString(), + }, + }); + } + + if (path === "/api/stats") { + return new Response( + JSON.stringify({ + requestCount: c.state.requestCount, + requestHistory: c.state.requestHistory.slice(-10), // Last 10 requests + }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + } + + if (path === "/api/headers") { + const headers = Object.fromEntries(request.headers.entries()); + return new Response( + JSON.stringify({ + headers, + method, + path, + timestamp: Date.now(), + }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + } + + if (path === "/api/json" && method === "POST") { + return request.json().then((body) => { + return new Response( + JSON.stringify({ + received: body, + method, + timestamp: Date.now(), + processed: true, + }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + }); + } + + // Handle custom paths with query parameters + if (path.startsWith("/api/custom")) { + const searchParams = Object.fromEntries(url.searchParams.entries()); + return new Response( + JSON.stringify({ + path, + method, + queryParams: searchParams, + timestamp: Date.now(), + message: "Custom endpoint response", + }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + } + + // Return 404 for unhandled paths + return new Response( + JSON.stringify({ + error: "Not Found", + path, + method, + availableEndpoints: [ + "/api/hello", + "/api/echo", + "/api/stats", + "/api/headers", + "/api/json", + "/api/custom/*", + ], + }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); +} + +export const httpActions = { + getHttpStats: (c: any) => { + return { + requestCount: c.state.requestCount || 0, + requestHistory: c.state.requestHistory || [], + }; + }, + clearHttpHistory: (c: any) => { + c.state.requestHistory = []; + c.state.requestCount = 0; + return true; + }, +}; diff --git a/examples/kitchen-sink/src/backend/actors/websocket.ts b/examples/kitchen-sink/src/backend/actors/websocket.ts new file mode 100644 index 000000000..ebb0d69b7 --- /dev/null +++ b/examples/kitchen-sink/src/backend/actors/websocket.ts @@ -0,0 +1,194 @@ +import type { UniversalWebSocket } from "rivetkit"; + +export function handleWebSocket( + c: any, + websocket: UniversalWebSocket, + opts: any, +) { + const connectionId = `conn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + // Initialize WebSocket state if not exists + if (!c.state.connectionCount) c.state.connectionCount = 0; + if (!c.state.messageCount) c.state.messageCount = 0; + if (!c.state.messageHistory) c.state.messageHistory = []; + + c.state.connectionCount++; + c.log.info("websocket connected", { + connectionCount: c.state.connectionCount, + connectionId, + url: opts.request.url, + }); + + // Send welcome message + const welcomeMessage = JSON.stringify({ + type: "welcome", + connectionId, + connectionCount: c.state.connectionCount, + timestamp: Date.now(), + }); + + websocket.send(welcomeMessage); + c.state.messageHistory.push({ + type: "sent", + data: welcomeMessage, + timestamp: Date.now(), + connectionId, + }); + + // Handle incoming messages + websocket.addEventListener("message", (event: any) => { + c.state.messageCount++; + const timestamp = Date.now(); + + c.log.info("websocket message received", { + messageCount: c.state.messageCount, + connectionId, + dataType: typeof event.data, + }); + + // Record received message + c.state.messageHistory.push({ + type: "received", + data: event.data, + timestamp, + connectionId, + }); + + const data = event.data; + + if (typeof data === "string") { + try { + const parsed = JSON.parse(data); + + if (parsed.type === "ping") { + const pongMessage = JSON.stringify({ + type: "pong", + timestamp, + originalTimestamp: parsed.timestamp, + }); + websocket.send(pongMessage); + c.state.messageHistory.push({ + type: "sent", + data: pongMessage, + timestamp, + connectionId, + }); + } else if (parsed.type === "echo") { + const echoMessage = JSON.stringify({ + type: "echo-response", + originalMessage: parsed.message, + timestamp, + }); + websocket.send(echoMessage); + c.state.messageHistory.push({ + type: "sent", + data: echoMessage, + timestamp, + connectionId, + }); + } else if (parsed.type === "getStats") { + const statsMessage = JSON.stringify({ + type: "stats", + connectionCount: c.state.connectionCount, + messageCount: c.state.messageCount, + timestamp, + }); + websocket.send(statsMessage); + c.state.messageHistory.push({ + type: "sent", + data: statsMessage, + timestamp, + connectionId, + }); + } else if (parsed.type === "broadcast") { + // Broadcast to all connections would need additional infrastructure + const broadcastResponse = JSON.stringify({ + type: "broadcast-ack", + message: parsed.message, + timestamp, + }); + websocket.send(broadcastResponse); + c.state.messageHistory.push({ + type: "sent", + data: broadcastResponse, + timestamp, + connectionId, + }); + } else { + // Echo back unknown JSON messages + websocket.send(data); + c.state.messageHistory.push({ + type: "sent", + data: data, + timestamp, + connectionId, + }); + } + } catch { + // If not JSON, just echo it back + websocket.send(data); + c.state.messageHistory.push({ + type: "sent", + data: data, + timestamp, + connectionId, + }); + } + } else if (data instanceof ArrayBuffer || data instanceof Uint8Array) { + // Handle binary data - reverse the bytes + const bytes = new Uint8Array(data); + const reversed = new Uint8Array(bytes.length); + for (let i = 0; i < bytes.length; i++) { + reversed[i] = bytes[bytes.length - 1 - i]; + } + websocket.send(reversed); + c.state.messageHistory.push({ + type: "sent", + data: `[Binary: ${reversed.length} bytes - reversed]`, + timestamp, + connectionId, + }); + } else { + // Echo other data types + websocket.send(data); + c.state.messageHistory.push({ + type: "sent", + data: data, + timestamp, + connectionId, + }); + } + }); + + // Handle connection close + websocket.addEventListener("close", () => { + c.state.connectionCount--; + c.log.info("websocket disconnected", { + connectionCount: c.state.connectionCount, + connectionId, + }); + }); + + // Handle errors + websocket.addEventListener("error", (error: any) => { + c.log.error("websocket error", { error: error.message, connectionId }); + }); +} + +export const websocketActions = { + getWebSocketStats: (c: any) => { + return { + connectionCount: c.state.connectionCount || 0, + messageCount: c.state.messageCount || 0, + messageHistory: (c.state.messageHistory || []).slice(-50), // Last 50 messages + }; + }, + clearWebSocketHistory: (c: any) => { + c.state.messageHistory = []; + c.state.messageCount = 0; + return true; + }, + getWebSocketMessageHistory: (c: any, limit: number = 20) => { + return (c.state.messageHistory || []).slice(-limit); + }, +}; diff --git a/examples/kitchen-sink/src/backend/registry.ts b/examples/kitchen-sink/src/backend/registry.ts new file mode 100644 index 000000000..7bd07a3db --- /dev/null +++ b/examples/kitchen-sink/src/backend/registry.ts @@ -0,0 +1,10 @@ +import { setup } from "rivetkit"; +import { demo } from "./actors/demo"; + +export const registry = setup({ + use: { + demo, + }, +}); + +export type Registry = typeof registry; diff --git a/examples/kitchen-sink/src/backend/server.ts b/examples/kitchen-sink/src/backend/server.ts new file mode 100644 index 000000000..b51ac47fe --- /dev/null +++ b/examples/kitchen-sink/src/backend/server.ts @@ -0,0 +1,8 @@ +import { registry } from "./registry"; + +registry.start({ + cors: { + origin: "http://localhost:5173", + credentials: true, + }, +}); diff --git a/examples/kitchen-sink/src/frontend/App.tsx b/examples/kitchen-sink/src/frontend/App.tsx new file mode 100644 index 000000000..7bda68bfe --- /dev/null +++ b/examples/kitchen-sink/src/frontend/App.tsx @@ -0,0 +1,117 @@ +import { createClient } from "@rivetkit/react"; +import { useState, useMemo, useEffect } from "react"; +import type { Registry } from "../backend/registry"; +import ConnectionScreen from "./components/ConnectionScreen"; +import InteractionScreen from "./components/InteractionScreen"; + +export interface AppState { + // Configuration + transport: "websocket" | "sse"; + encoding: "json" | "cbor" | "bare"; + connectionMode: "handle" | "connection"; + + // Actor management + actorMethod: "get" | "getOrCreate" | "getForId" | "create"; + actorName: string; + actorKey: string; + actorId: string; + actorRegion: string; + createInput: string; + + // Connection state + isConnected: boolean; + connectionError?: string; +} + +function App() { + const [state, setState] = useState(null); + + const handleConnect = (config: AppState) => { + setState(config); + }; + + const handleDisconnect = () => { + setState(null); + }; + + const updateState = (updates: Partial) => { + setState(prev => prev ? { ...prev, ...updates } : null); + }; + + // Create client with user-selected encoding and transport + const client = useMemo(() => { + if (!state) return null; + + return createClient({ + endpoint: "http://localhost:6420", + encoding: state.encoding, + transport: state.transport, + }); + }, [state?.encoding, state?.transport]); + + // Create the connection/handle once based on state + const [actorHandle, setActorHandle] = useState(null); + + useEffect(() => { + if (!state || !client) { + setActorHandle(null); + return; + } + + const accessor = (client as any)[state.actorName]; + const key = state.actorKey ? [state.actorKey] : []; + + const initHandle = async () => { + let baseHandle: any; + switch (state.actorMethod) { + case "get": + baseHandle = accessor.get(key); + break; + case "getOrCreate": { + const createInput = state.createInput ? JSON.parse(state.createInput) : undefined; + baseHandle = accessor.getOrCreate(key, { createWithInput: createInput }); + break; + } + case "getForId": + if (!state.actorId) { + throw new Error("Actor ID is required for getForId method"); + } + baseHandle = accessor.getForId(state.actorId); + break; + case "create": { + const createInput = state.createInput ? JSON.parse(state.createInput) : undefined; + baseHandle = await accessor.create(key, { input: createInput }); + break; + } + default: + throw new Error(`Unknown actor method: ${state.actorMethod}`); + } + + // Apply connection mode + const handle = state.connectionMode === "connection" + ? baseHandle.connect() + : baseHandle; + + setActorHandle(handle); + }; + + initHandle(); + }, [state, client]); + + return ( +
+ + {state && actorHandle && ( + + )} +
+ ); +} + +export default App; diff --git a/examples/kitchen-sink/src/frontend/components/ConnectionScreen.tsx b/examples/kitchen-sink/src/frontend/components/ConnectionScreen.tsx new file mode 100644 index 000000000..f47bcb970 --- /dev/null +++ b/examples/kitchen-sink/src/frontend/components/ConnectionScreen.tsx @@ -0,0 +1,225 @@ +import { useState } from "react"; +import type { AppState } from "../App"; + +interface ConnectionScreenProps { + onConnect: (config: AppState) => void; +} + +type ActorMethod = "get" | "getOrCreate" | "getForId" | "create"; + +export default function ConnectionScreen({ onConnect }: ConnectionScreenProps) { + const [actorMethod, setActorMethod] = useState("getOrCreate"); + const [actorName, setActorName] = useState("demo"); + const [actorKey, setActorKey] = useState(""); + const [actorId, setActorId] = useState(""); + const [actorRegion, setActorRegion] = useState(""); + const [createInput, setCreateInput] = useState(""); + const [transport, setTransport] = useState<"websocket" | "sse">("websocket"); + const [encoding, setEncoding] = useState<"json" | "cbor" | "bare">("bare"); + const [connectionMode, setConnectionMode] = useState<"connection" | "handle">("handle"); + const [isConnecting, setIsConnecting] = useState(false); + + const handleConnect = async () => { + setIsConnecting(true); + + const config: AppState = { + actorMethod, + actorName, + actorKey, + actorId, + actorRegion, + createInput, + transport, + encoding, + connectionMode, + isConnected: true, + }; + + onConnect(config); + setIsConnecting(false); + }; + + return ( +
+
+
+

Connect to Actor

+

Configure your RivetKit connection

+
+ +
+
+

Actor Configuration

+
+ +
+ + + + +
+
+ +
+ + +
+ + {actorMethod === "getForId" ? ( +
+ + setActorId(e.target.value)} + placeholder="Actor ID" + required + /> +
+ ) : ( +
+ + setActorKey(e.target.value)} + placeholder="Optional key for actor instance" + /> +
+ )} + + {(actorMethod === "create" || actorMethod === "getOrCreate") && ( + <> +
+ + setActorRegion(e.target.value)} + placeholder="Optional region" + /> +
+
+ +