From 5b91eee920d4228dfa3b58be361f89b5b476cbd6 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 22 Oct 2025 00:11:57 -0700 Subject: [PATCH 1/4] chore: fix rivetkit release script --- .github/workflows/pkg-pr-new.yaml | 5 +++- engine/docker/template/src/docker-compose.ts | 3 --- examples/better-auth-external-db/package.json | 1 + examples/deno/deno.json | 4 ++-- examples/hono-react/package.json | 1 + examples/hono/package.json | 1 + examples/raw-fetch-handler/package.json | 1 + .../raw-websocket-handler-proxy/package.json | 6 ++++- .../src/backend/server.ts | 8 ++++--- frontend/package.json | 1 - pnpm-lock.yaml | 24 +++++++++++++++++++ scripts/release/tsconfig.json | 2 +- scripts/rivetkit/release.ts | 2 +- scripts/tests/actor_e2e.ts | 2 +- scripts/tests/actor_spam.ts | 2 +- scripts/tests/actor_stress.ts | 5 ++++ scripts/tests/tsconfig.json | 2 +- tsconfig.base.json | 1 + 18 files changed, 55 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pkg-pr-new.yaml b/.github/workflows/pkg-pr-new.yaml index bd99ad0f11..261be86846 100644 --- a/.github/workflows/pkg-pr-new.yaml +++ b/.github/workflows/pkg-pr-new.yaml @@ -1,5 +1,8 @@ on: pull_request: + push: + branches: + - main jobs: publish: @@ -9,5 +12,5 @@ jobs: - run: corepack enable - uses: actions/setup-node@v4 - run: pnpm install - - run: pnpm build + - run: pnpm build -F '@rivetkit/*' - run: pnpm dlx pkg-pr-new publish 'engine/sdks/typescript/runner/' 'engine/sdks/typescript/runner-protocol/' 'rivetkit-typescript/packages/*' --packageManager pnpm --template './examples/*' diff --git a/engine/docker/template/src/docker-compose.ts b/engine/docker/template/src/docker-compose.ts index e866e9133d..71cb7b64db 100644 --- a/engine/docker/template/src/docker-compose.ts +++ b/engine/docker/template/src/docker-compose.ts @@ -354,14 +354,11 @@ export function generateDockerCompose(context: TemplateContext) { // If host networking is requested, set network_mode for all services if (context.config.networkMode === "host") { for (const svc of Object.values(dockerComposeConfig.services)) { - // @ts-expect-error - mutate dynamic service objects svc.network_mode = "host"; // Remove networks field as it's incompatible with host networking - // @ts-expect-error if (svc.networks) delete svc.networks; // Remove ports since published ports are ignored with host networking // and produce warnings in Docker Compose output. - // @ts-expect-error if (svc.ports) delete svc.ports; } } diff --git a/examples/better-auth-external-db/package.json b/examples/better-auth-external-db/package.json index 34c58ea9f3..a7f8b9c1d5 100644 --- a/examples/better-auth-external-db/package.json +++ b/examples/better-auth-external-db/package.json @@ -26,6 +26,7 @@ "vitest": "^3.1.1" }, "dependencies": { + "@hono/node-server": "^1.14.0", "@rivetkit/react": "workspace:*", "@types/better-sqlite3": "^7.6.13", "better-auth": "^1.0.1", diff --git a/examples/deno/deno.json b/examples/deno/deno.json index fc08739232..cd6d38569b 100644 --- a/examples/deno/deno.json +++ b/examples/deno/deno.json @@ -11,8 +11,8 @@ "fs": "node:fs", "fs/promises": "node:fs/promises", "crypto": "node:crypto", - "rivetkit": "../../packages/rivetkit/dist/tsup/mod.js", - "rivetkit/client": "../../packages/rivetkit/dist/tsup/client/mod.js", + "rivetkit": "../../rivetkit-typescript/packages/rivetkit/dist/tsup/mod.js", + "rivetkit/client": "../../rivetkit-typescript/packages/rivetkit/dist/tsup/client/mod.js", "hono": "npm:hono@4.9.8", "hono/ws": "npm:hono@4.9.8/ws", "hono/deno": "npm:hono@4.9.8/deno" diff --git a/examples/hono-react/package.json b/examples/hono-react/package.json index 1be27f89ed..d8c414e5a0 100644 --- a/examples/hono-react/package.json +++ b/examples/hono-react/package.json @@ -24,6 +24,7 @@ "vitest": "^3.1.1" }, "dependencies": { + "@hono/node-server": "^1.14.0", "@rivetkit/react": "workspace:*", "hono": "^4.7.0", "react": "^18.2.0", diff --git a/examples/hono/package.json b/examples/hono/package.json index d474f094c1..601968a944 100644 --- a/examples/hono/package.json +++ b/examples/hono/package.json @@ -16,6 +16,7 @@ "typescript": "^5.5.2" }, "dependencies": { + "@hono/node-server": "^1.14.0", "hono": "^4.7.0" }, "stableVersion": "0.8.0" diff --git a/examples/raw-fetch-handler/package.json b/examples/raw-fetch-handler/package.json index 1b8ed5247f..1c97fbefbc 100644 --- a/examples/raw-fetch-handler/package.json +++ b/examples/raw-fetch-handler/package.json @@ -13,6 +13,7 @@ "test": "vitest" }, "dependencies": { + "@hono/node-server": "^1.14.0", "rivetkit": "workspace:*", "@rivetkit/react": "workspace:*", "hono": "^4.6.18", diff --git a/examples/raw-websocket-handler-proxy/package.json b/examples/raw-websocket-handler-proxy/package.json index 9f94243ba6..9f0b894e32 100644 --- a/examples/raw-websocket-handler-proxy/package.json +++ b/examples/raw-websocket-handler-proxy/package.json @@ -18,15 +18,19 @@ "test": "vitest run" }, "dependencies": { + "@hono/node-server": "^1.14.0", + "@hono/node-ws": "^1.1.1", "rivetkit": "workspace:*", "react": "^18.3.1", "react-dom": "^18.3.1", - "hono": "^4.7.0" + "hono": "^4.7.0", + "ws": "^8.18.0" }, "devDependencies": { "@types/node": "^22.10.2", "@types/react": "^18.3.16", "@types/react-dom": "^18.3.5", + "@types/ws": "^8.5.10", "@vitejs/plugin-react": "^4.3.4", "concurrently": "^9.1.0", "tsx": "^4.19.2", diff --git a/examples/raw-websocket-handler-proxy/src/backend/server.ts b/examples/raw-websocket-handler-proxy/src/backend/server.ts index a8eb71f976..4e70e8796d 100644 --- a/examples/raw-websocket-handler-proxy/src/backend/server.ts +++ b/examples/raw-websocket-handler-proxy/src/backend/server.ts @@ -1,6 +1,8 @@ import { serve } from "@hono/node-server"; import { createNodeWebSocket } from "@hono/node-ws"; +import type { Context } from "hono"; import { Hono } from "hono"; +import type { WSContext } from "hono/ws"; import { registry } from "./registry.js"; const { client } = registry.start({ @@ -16,7 +18,7 @@ const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); // Forward WebSocket connections to actor's WebSocket handler app.get( "/ws/:name", - upgradeWebSocket(async (c) => { + upgradeWebSocket(async (c: Context) => { const name = c.req.param("name"); // Connect to actor WebSocket @@ -24,7 +26,7 @@ app.get( const actorWs = await actor.websocket("/"); return { - onOpen: async (_evt, ws) => { + onOpen: async (_evt: Event, ws: WSContext) => { // Bridge actor WebSocket to client WebSocket actorWs.addEventListener("message", (event: MessageEvent) => { ws.send(event.data); @@ -34,7 +36,7 @@ app.get( ws.close(); }); }, - onMessage: (evt) => { + onMessage: (evt: MessageEvent) => { // Forward message to actor WebSocket if (actorWs && typeof evt.data === "string") { actorWs.send(evt.data); diff --git a/frontend/package.json b/frontend/package.json index 63607f398f..db9f8209c9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,6 @@ "dev:engine": "vite --config vite.engine.config.ts", "dev:cloud": "vite --config vite.cloud.config.ts", "ts-check": "tsc --noEmit", - "build": "echo 'Please use build:engine or build:inspector or build:cloud' && exit 1", "build:inspector": "vite build --mode=production --config vite.inspector.config.ts", "build:engine": "vite build --mode=production --config vite.engine.config.ts", "build:cloud": "vite build --mode=production --config vite.cloud.config.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 144080fb6f..1bda78540f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -272,6 +272,9 @@ importers: examples/better-auth-external-db: dependencies: + '@hono/node-server': + specifier: ^1.14.0 + version: 1.19.1(hono@4.9.8) '@rivetkit/react': specifier: workspace:* version: link:../../rivetkit-typescript/packages/react @@ -783,6 +786,9 @@ importers: examples/hono: dependencies: + '@hono/node-server': + specifier: ^1.14.0 + version: 1.19.1(hono@4.9.8) hono: specifier: ^4.7.0 version: 4.9.8 @@ -848,6 +854,9 @@ importers: examples/hono-react: dependencies: + '@hono/node-server': + specifier: ^1.14.0 + version: 1.19.1(hono@4.9.8) '@rivetkit/react': specifier: workspace:* version: link:../../rivetkit-typescript/packages/react @@ -1005,6 +1014,9 @@ importers: examples/raw-fetch-handler: dependencies: + '@hono/node-server': + specifier: ^1.14.0 + version: 1.19.1(hono@4.9.8) '@rivetkit/react': specifier: workspace:* version: link:../../rivetkit-typescript/packages/react @@ -1097,6 +1109,12 @@ importers: examples/raw-websocket-handler-proxy: dependencies: + '@hono/node-server': + specifier: ^1.14.0 + version: 1.19.1(hono@4.9.8) + '@hono/node-ws': + specifier: ^1.1.1 + version: 1.2.0(@hono/node-server@1.19.1(hono@4.9.8))(hono@4.9.8) hono: specifier: ^4.7.0 version: 4.9.8 @@ -1109,6 +1127,9 @@ importers: rivetkit: specifier: workspace:* version: link:../../rivetkit-typescript/packages/rivetkit + ws: + specifier: ^8.18.0 + version: 8.18.3 devDependencies: '@types/node': specifier: ^22.10.2 @@ -1119,6 +1140,9 @@ importers: '@types/react-dom': specifier: ^18.3.5 version: 18.3.7(@types/react@18.3.24) + '@types/ws': + specifier: ^8.5.10 + version: 8.18.1 '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.7.0(vite@6.4.1(@types/node@22.18.1)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.30.2)(sass@1.93.2)(stylus@0.62.0)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1)) diff --git a/scripts/release/tsconfig.json b/scripts/release/tsconfig.json index 07898737ed..27ce8a72b8 100644 --- a/scripts/release/tsconfig.json +++ b/scripts/release/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.base.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"], "paths": { diff --git a/scripts/rivetkit/release.ts b/scripts/rivetkit/release.ts index a3b8867596..401c5dcb04 100644 --- a/scripts/rivetkit/release.ts +++ b/scripts/rivetkit/release.ts @@ -348,7 +348,7 @@ async function checkPythonEnvironment() { async function getCurrentVersion(): Promise { // Get version from the main package const { stdout } = - await $`cat packages/rivetkit/package.json | jq -r '.version'`; + await $`cat rivetkit-typescript/packages/rivetkit/package.json | jq -r '.version'`; const version = stdout.trim(); return version; } diff --git a/scripts/tests/actor_e2e.ts b/scripts/tests/actor_e2e.ts index 985e269d7a..ccacc45308 100755 --- a/scripts/tests/actor_e2e.ts +++ b/scripts/tests/actor_e2e.ts @@ -121,7 +121,7 @@ function testWebSocket(actorId: string): Promise { ws.addEventListener("error", (error) => { clearTimeout(timeout); - reject(new Error(`WebSocket error: ${error.message}`)); + reject(new Error(`WebSocket error: ${(error as any)?.message || "Unknown error"}`)); }); ws.addEventListener("close", () => { diff --git a/scripts/tests/actor_spam.ts b/scripts/tests/actor_spam.ts index fcee781425..f3988087f6 100755 --- a/scripts/tests/actor_spam.ts +++ b/scripts/tests/actor_spam.ts @@ -133,7 +133,7 @@ function testWebSocket(actorId: string): Promise { ws.addEventListener("error", (error) => { clearTimeout(timeout); - reject(new Error(`WebSocket error: ${error.message}`)); + reject(new Error(`WebSocket error: ${(error as any)?.message || "Unknown error"}`)); }); ws.addEventListener("close", () => { diff --git a/scripts/tests/actor_stress.ts b/scripts/tests/actor_stress.ts index 02ac6c59dd..b831b33549 100755 --- a/scripts/tests/actor_stress.ts +++ b/scripts/tests/actor_stress.ts @@ -19,6 +19,11 @@ async function actorLoop(workerId: number): Promise { try { const actorResponse = await createActor("default", "test-runner"); actorId = actorResponse.actor.actor_id; + + if (!actorId) { + throw new Error("Failed to get actor ID"); + } + activeActors.add(actorId); const actorPingResponse = await fetch(`${RIVET_ENDPOINT}/ping`, { diff --git a/scripts/tests/tsconfig.json b/scripts/tests/tsconfig.json index 224bd364c9..7e3793dfc6 100644 --- a/scripts/tests/tsconfig.json +++ b/scripts/tests/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../../../tsconfig.base.json", + "extends": "../../tsconfig.base.json", "include": ["**/*.ts"] } diff --git a/tsconfig.base.json b/tsconfig.base.json index a7267f3b0e..86becd2356 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -8,6 +8,7 @@ "allowSyntheticDefaultImports": true, "stripInternal": true, "moduleResolution": "bundler", + "downlevelIteration": true, "lib": ["ESNext"], "types": ["node"] } From a97aa78d8767f4c1ca805404f3b99d83ece836f8 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Thu, 23 Oct 2025 00:33:19 -0700 Subject: [PATCH 2/4] fix: fix biome ignore path --- biome.json | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/biome.json b/biome.json index 8a4341237e..f73b7b236e 100644 --- a/biome.json +++ b/biome.json @@ -2,17 +2,18 @@ "$schema": "https://biomejs.dev/schemas/2.1.1/schema.json", "files": { "includes": [ + "**/*.js", "**/*.json", "**/*.ts", - "**/*.js", - "!examples/snippets", - "!rivetkit-openapi/openapi.json", "!engine/artifacts", - "!website", - "!scripts", - "!frontend", + "!engine/sdks", "!engine/sdks/typescript/api-full", "!engine/sdks/typescript/runner-protocol" + "!examples/snippets", + "!frontend", + "!rivetkit-openapi/openapi.json", + "!scripts", + "!website" ], "ignoreUnknown": true }, From 82faa7ed5e07422edeb6607e5e8f43648592a0d8 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 22 Oct 2025 21:58:34 -0700 Subject: [PATCH 3/4] feat(core): allow routing traffic to actors via path --- engine/packages/guard/src/routing/mod.rs | 146 +++++++-- .../guard/src/routing/pegboard_gateway.rs | 48 ++- engine/packages/guard/src/routing/runner.rs | 14 +- .../packages/guard/tests/parse_actor_path.rs | 203 ++++++++++++ engine/packages/pegboard-gateway/src/lib.rs | 41 +-- .../src/common/actor-router-consts.ts | 2 + .../src/drivers/engine/actor-driver.ts | 4 +- .../packages/rivetkit/src/manager/gateway.ts | 258 ++++++++++++++- .../rivetkit/tests/parse-actor-path.test.ts | 305 ++++++++++++++++++ 9 files changed, 941 insertions(+), 80 deletions(-) create mode 100644 engine/packages/guard/tests/parse_actor_path.rs create mode 100644 rivetkit-typescript/packages/rivetkit/tests/parse-actor-path.test.ts diff --git a/engine/packages/guard/src/routing/mod.rs b/engine/packages/guard/src/routing/mod.rs index faad1975d6..6a05134c99 100644 --- a/engine/packages/guard/src/routing/mod.rs +++ b/engine/packages/guard/src/routing/mod.rs @@ -17,6 +17,13 @@ pub(crate) const SEC_WEBSOCKET_PROTOCOL: HeaderName = HeaderName::from_static("sec-websocket-protocol"); pub(crate) const WS_PROTOCOL_TARGET: &str = "rivet_target."; +#[derive(Debug, Clone)] +pub struct ActorPathInfo { + pub actor_id: String, + pub token: Option, + pub remaining_path: String, +} + /// Creates the main routing function that handles all incoming requests #[tracing::instrument(skip_all)] pub fn create_routing_function(ctx: StandaloneCtx, shared_state: SharedState) -> RoutingFn { @@ -35,9 +42,6 @@ pub fn create_routing_function(ctx: StandaloneCtx, shared_state: SharedState) -> tracing::debug!("Routing request for hostname: {host}, path: {path}"); - // Parse query parameters - let query_params = parse_query_params(path); - // Check if this is a WebSocket upgrade request let is_websocket = headers .get("upgrade") @@ -45,7 +49,28 @@ pub fn create_routing_function(ctx: StandaloneCtx, shared_state: SharedState) -> .map(|v| v.eq_ignore_ascii_case("websocket")) .unwrap_or(false); - // Extract target from WebSocket protocol, HTTP header, or query param + // First, check if this is an actor path-based route + if let Some(actor_path_info) = parse_actor_path(path) { + tracing::debug!(?actor_path_info, "routing using path-based actor routing"); + + // Route to pegboard gateway with the extracted information + if let Some(routing_output) = pegboard_gateway::route_request_path_based( + &ctx, + &shared_state, + &actor_path_info.actor_id, + actor_path_info.token.as_deref(), + &actor_path_info.remaining_path, + headers, + is_websocket, + ) + .await? + { + return Ok(routing_output); + } + } + + // Fallback to header-based routing + // Extract target from WebSocket protocol or HTTP header let target = if is_websocket { // For WebSocket, parse the sec-websocket-protocol header headers @@ -58,21 +83,15 @@ pub fn create_routing_function(ctx: StandaloneCtx, shared_state: SharedState) -> .map(|p| p.trim()) .find_map(|p| p.strip_prefix(WS_PROTOCOL_TARGET)) }) - // Fallback to query parameter if protocol not provided - .or_else(|| query_params.get("x_rivet_target").map(|s| s.as_str())) } else { - // For HTTP, use the x-rivet-target header, fallback to query param - headers - .get(X_RIVET_TARGET) - .and_then(|x| x.to_str().ok()) - .or_else(|| query_params.get("x_rivet_target").map(|s| s.as_str())) + // For HTTP, use the x-rivet-target header + headers.get(X_RIVET_TARGET).and_then(|x| x.to_str().ok()) }; // Read target if let Some(target) = target { if let Some(routing_output) = - runner::route_request(&ctx, target, host, path, headers, &query_params) - .await? + runner::route_request(&ctx, target, host, path, headers).await? { return Ok(routing_output); } @@ -85,7 +104,6 @@ pub fn create_routing_function(ctx: StandaloneCtx, shared_state: SharedState) -> path, headers, is_websocket, - &query_params, ) .await? { @@ -120,18 +138,98 @@ pub fn create_routing_function(ctx: StandaloneCtx, shared_state: SharedState) -> ) } -/// Parse query parameters from a path string -fn parse_query_params(path: &str) -> std::collections::HashMap { - let mut params = std::collections::HashMap::new(); +/// Parse actor routing information from path +/// Matches patterns: +/// - /gateway/actors/{actor_id}/tokens/{token}/route/{...path} +/// - /gateway/actors/{actor_id}/route/{...path} +pub fn parse_actor_path(path: &str) -> Option { + // Find query string position (everything from ? onwards, but before fragment) + let query_pos = path.find('?'); + let fragment_pos = path.find('#'); + + // Extract query string (excluding fragment) + let query_string = match (query_pos, fragment_pos) { + (Some(q), Some(f)) if q < f => &path[q..f], + (Some(q), None) => &path[q..], + _ => "", + }; + + // Extract base path (before query and fragment) + let base_path = match query_pos { + Some(pos) => &path[..pos], + None => match fragment_pos { + Some(pos) => &path[..pos], + None => path, + }, + }; + + // Check for double slashes (invalid path) + if base_path.contains("//") { + return None; + } + + // Split the path into segments + let segments: Vec<&str> = base_path.split('/').filter(|s| !s.is_empty()).collect(); + + // Check minimum required segments: gateway, actors, {actor_id}, route + if segments.len() < 4 { + return None; + } + + // Verify the fixed segments + if segments[0] != "gateway" || segments[1] != "actors" { + return None; + } + + // Check for empty actor_id + if segments[2].is_empty() { + return None; + } - if let Some(query_start) = path.find('?') { - // Strip fragment if present - let query = &path[query_start + 1..].split('#').next().unwrap_or(""); - // Use url::form_urlencoded to properly decode query parameters - for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { - params.insert(key.into_owned(), value.into_owned()); + let actor_id = segments[2].to_string(); + + // Check for token or direct route + let (token, remaining_path_start_idx) = + if segments.len() >= 6 && segments[3] == "tokens" && segments[5] == "route" { + // Pattern with token: /gateway/actors/{actor_id}/tokens/{token}/route/{...path} + // Check for empty token + if segments[4].is_empty() { + return None; + } + (Some(segments[4].to_string()), 6) + } else if segments.len() >= 4 && segments[3] == "route" { + // Pattern without token: /gateway/actors/{actor_id}/route/{...path} + (None, 4) + } else { + return None; + }; + + // Calculate the position in the original path where remaining path starts + let mut prefix_len = 0; + for (i, segment) in segments.iter().enumerate() { + if i >= remaining_path_start_idx { + break; } + prefix_len += 1 + segment.len(); // +1 for the slash } - params + // Extract the remaining path preserving trailing slashes + let remaining_base = if prefix_len < base_path.len() { + &base_path[prefix_len..] + } else { + "/" + }; + + // Ensure remaining path starts with / + let remaining_path = if remaining_base.is_empty() || !remaining_base.starts_with('/') { + format!("/{}{}", remaining_base, query_string) + } else { + format!("{}{}", remaining_base, query_string) + }; + + Some(ActorPathInfo { + actor_id, + token, + remaining_path, + }) } diff --git a/engine/packages/guard/src/routing/pegboard_gateway.rs b/engine/packages/guard/src/routing/pegboard_gateway.rs index f8770bf933..a7d6b7573d 100644 --- a/engine/packages/guard/src/routing/pegboard_gateway.rs +++ b/engine/packages/guard/src/routing/pegboard_gateway.rs @@ -10,9 +10,30 @@ use crate::{errors, shared_state::SharedState}; const ACTOR_READY_TIMEOUT: Duration = Duration::from_secs(10); pub const X_RIVET_ACTOR: HeaderName = HeaderName::from_static("x-rivet-actor"); +pub const X_RIVET_AMESPACE: HeaderName = HeaderName::from_static("x-rivet-namespace"); const WS_PROTOCOL_ACTOR: &str = "rivet_actor."; +const WS_PROTOCOL_TOKEN: &str = "rivet_token."; -/// Route requests to actor services based on hostname and path +/// Route requests to actor services using path-based routing +#[tracing::instrument(skip_all)] +pub async fn route_request_path_based( + ctx: &StandaloneCtx, + shared_state: &SharedState, + actor_id_str: &str, + _token: Option<&str>, + path: &str, + _headers: &hyper::HeaderMap, + _is_websocket: bool, +) -> Result> { + // NOTE: Token validation implemented in EE + + // Parse actor ID + let actor_id = Id::parse(actor_id_str).context("invalid actor id in path")?; + + route_request_inner(ctx, shared_state, actor_id, path).await +} + +/// Route requests to actor services based on headers #[tracing::instrument(skip_all)] pub async fn route_request( ctx: &StandaloneCtx, @@ -22,14 +43,13 @@ pub async fn route_request( path: &str, headers: &hyper::HeaderMap, is_websocket: bool, - query_params: &std::collections::HashMap, ) -> Result> { // Check target if target != "actor" { return Ok(None); } - // Extract actor ID from WebSocket protocol, HTTP header, or query param + // Extract actor ID from WebSocket protocol or HTTP header let actor_id_str = if is_websocket { // For WebSocket, parse the sec-websocket-protocol header headers @@ -42,26 +62,22 @@ pub async fn route_request( .map(|p| p.trim()) .find_map(|p| p.strip_prefix(WS_PROTOCOL_ACTOR)) }) - // Fallback to query parameter if protocol not provided - .or_else(|| query_params.get("x_rivet_actor").map(|s| s.as_str())) .ok_or_else(|| { crate::errors::MissingHeader { - header: "`rivet_actor.*` protocol in sec-websocket-protocol or x_rivet_actor query parameter".to_string(), + header: "`rivet_actor.*` protocol in sec-websocket-protocol".to_string(), } .build() })? } else { - // For HTTP, use the x-rivet-actor header, fallback to query param + // For HTTP, use the x-rivet-actor header headers .get(X_RIVET_ACTOR) .map(|x| x.to_str()) .transpose() .context("invalid x-rivet-actor header")? - // Fallback to query parameter if header not provided - .or_else(|| query_params.get("x_rivet_actor").map(|s| s.as_str())) .ok_or_else(|| { crate::errors::MissingHeader { - header: format!("{} header or x_rivet_actor query parameter", X_RIVET_ACTOR), + header: X_RIVET_ACTOR.to_string(), } .build() })? @@ -70,6 +86,15 @@ pub async fn route_request( // Find actor to route to let actor_id = Id::parse(actor_id_str).context("invalid x-rivet-actor header")?; + route_request_inner(ctx, shared_state, actor_id, path).await +} + +async fn route_request_inner( + ctx: &StandaloneCtx, + shared_state: &SharedState, + actor_id: Id, + path: &str, +) -> Result> { // Route to peer dc where the actor lives if actor_id.label() != ctx.config().dc_label() { tracing::debug!(peer_dc_label=?actor_id.label(), "re-routing actor to peer dc"); @@ -189,11 +214,12 @@ pub async fn route_request( tracing::debug!(?actor_id, ?runner_id, "actor ready"); - // Return pegboard-gateway instance + // Return pegboard-gateway instance with path let gateway = pegboard_gateway::PegboardGateway::new( shared_state.pegboard_gateway.clone(), runner_id, actor_id, + path.to_string(), ); Ok(Some(RoutingOutput::CustomServe(std::sync::Arc::new( gateway, diff --git a/engine/packages/guard/src/routing/runner.rs b/engine/packages/guard/src/routing/runner.rs index 6f2ff5e085..b963c1895d 100644 --- a/engine/packages/guard/src/routing/runner.rs +++ b/engine/packages/guard/src/routing/runner.rs @@ -14,7 +14,6 @@ pub async fn route_request( host: &str, path: &str, headers: &hyper::HeaderMap, - query_params: &std::collections::HashMap, ) -> Result> { if target != "runner" { return Ok(None); @@ -58,7 +57,7 @@ pub async fn route_request( // Check auth (if enabled) if let Some(auth) = &ctx.config().auth { - // Extract token from protocol, header, or query param + // Extract token from protocol or header let token = if is_websocket { headers .get(SEC_WEBSOCKET_PROTOCOL) @@ -69,11 +68,9 @@ pub async fn route_request( .map(|p| p.trim()) .find_map(|p| p.strip_prefix(WS_PROTOCOL_TOKEN)) }) - // Fallback to query parameter if protocol not provided - .or_else(|| query_params.get("x_rivet_token").map(|s| s.as_str())) .ok_or_else(|| { crate::errors::MissingHeader { - header: "`rivet_token.*` protocol in sec-websocket-protocol or x_rivet_token query parameter".to_string(), + header: "`rivet_token.*` protocol in sec-websocket-protocol".to_string(), } .build() })? @@ -81,14 +78,9 @@ pub async fn route_request( headers .get(X_RIVET_TOKEN) .and_then(|x| x.to_str().ok()) - // Fallback to query parameter if header not provided - .or_else(|| query_params.get("x_rivet_token").map(|s| s.as_str())) .ok_or_else(|| { crate::errors::MissingHeader { - header: format!( - "{} header or x_rivet_token query parameter", - X_RIVET_TOKEN - ), + header: X_RIVET_TOKEN.to_string(), } .build() })? diff --git a/engine/packages/guard/tests/parse_actor_path.rs b/engine/packages/guard/tests/parse_actor_path.rs new file mode 100644 index 0000000000..39d07c207f --- /dev/null +++ b/engine/packages/guard/tests/parse_actor_path.rs @@ -0,0 +1,203 @@ +use rivet_guard::routing::{ActorPathInfo, parse_actor_path}; + +#[test] +fn test_parse_actor_path_with_token() { + // Basic path with token and route + let path = "/gateway/actors/actor-123/tokens/my-token/route/api/v1/endpoint"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor-123"); + assert_eq!(result.token, Some("my-token".to_string())); + assert_eq!(result.remaining_path, "/api/v1/endpoint"); +} + +#[test] +fn test_parse_actor_path_without_token() { + // Path without token + let path = "/gateway/actors/actor-123/route/api/v1/endpoint"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor-123"); + assert_eq!(result.token, None); + assert_eq!(result.remaining_path, "/api/v1/endpoint"); +} + +#[test] +fn test_parse_actor_path_with_uuid() { + // Path with UUID as actor ID + let path = "/gateway/actors/12345678-1234-1234-1234-123456789abc/route/status"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "12345678-1234-1234-1234-123456789abc"); + assert_eq!(result.token, None); + assert_eq!(result.remaining_path, "/status"); +} + +#[test] +fn test_parse_actor_path_with_query_params() { + // Path with query parameters + let path = "/gateway/actors/actor-456/route/api/endpoint?foo=bar&baz=qux"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor-456"); + assert_eq!(result.token, None); + assert_eq!(result.remaining_path, "/api/endpoint?foo=bar&baz=qux"); + + // Path with token and query parameters + let path = "/gateway/actors/actor-456/tokens/token123/route/api?key=value"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor-456"); + assert_eq!(result.token, Some("token123".to_string())); + assert_eq!(result.remaining_path, "/api?key=value"); +} + +#[test] +fn test_parse_actor_path_with_fragment() { + // Path with fragment + let path = "/gateway/actors/actor-789/route/page#section"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor-789"); + assert_eq!(result.token, None); + // Fragment is stripped during parsing + assert_eq!(result.remaining_path, "/page"); +} + +#[test] +fn test_parse_actor_path_empty_remaining() { + // Path with no remaining path after route + let path = "/gateway/actors/actor-000/route"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor-000"); + assert_eq!(result.token, None); + assert_eq!(result.remaining_path, "/"); + + // With token and no remaining path + let path = "/gateway/actors/actor-000/tokens/tok/route"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor-000"); + assert_eq!(result.token, Some("tok".to_string())); + assert_eq!(result.remaining_path, "/"); +} + +#[test] +fn test_parse_actor_path_with_trailing_slash() { + // Path with trailing slash + let path = "/gateway/actors/actor-111/route/api/"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor-111"); + assert_eq!(result.token, None); + assert_eq!(result.remaining_path, "/api/"); +} + +#[test] +fn test_parse_actor_path_complex_remaining() { + // Complex remaining path with multiple segments + let path = + "/gateway/actors/actor-complex/tokens/secure-token/route/api/v2/users/123/profile/settings"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor-complex"); + assert_eq!(result.token, Some("secure-token".to_string())); + assert_eq!(result.remaining_path, "/api/v2/users/123/profile/settings"); +} + +#[test] +fn test_parse_actor_path_special_characters() { + // Actor ID with allowed special characters + let path = "/gateway/actors/actor_id-123.test/route/endpoint"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor_id-123.test"); + assert_eq!(result.token, None); + assert_eq!(result.remaining_path, "/endpoint"); +} + +#[test] +fn test_parse_actor_path_encoded_characters() { + // URL encoded characters in path + let path = "/gateway/actors/actor-123/route/api%20endpoint/test%2Fpath"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor-123"); + assert_eq!(result.token, None); + assert_eq!(result.remaining_path, "/api%20endpoint/test%2Fpath"); +} + +// Invalid path tests + +#[test] +fn test_parse_actor_path_invalid_prefix() { + // Wrong prefix + assert!(parse_actor_path("/api/actors/123/route/endpoint").is_none()); + assert!(parse_actor_path("/gateway/actor/123/route/endpoint").is_none()); + assert!(parse_actor_path("/actors/123/route/endpoint").is_none()); +} + +#[test] +fn test_parse_actor_path_missing_route() { + // Missing route keyword + assert!(parse_actor_path("/gateway/actors/123").is_none()); + assert!(parse_actor_path("/gateway/actors/123/endpoint").is_none()); + assert!(parse_actor_path("/gateway/actors/123/tokens/tok").is_none()); +} + +#[test] +fn test_parse_actor_path_too_short() { + // Too few segments + assert!(parse_actor_path("/gateway").is_none()); + assert!(parse_actor_path("/gateway/actors").is_none()); + assert!(parse_actor_path("/gateway/actors/123").is_none()); +} + +#[test] +fn test_parse_actor_path_malformed_token_path() { + // Token path but missing route + assert!(parse_actor_path("/gateway/actors/123/tokens/tok/api").is_none()); + // Token without value + assert!(parse_actor_path("/gateway/actors/123/tokens//route/api").is_none()); +} + +#[test] +fn test_parse_actor_path_wrong_segment_positions() { + // Segments in wrong positions + assert!(parse_actor_path("/actors/gateway/123/route/endpoint").is_none()); + assert!(parse_actor_path("/gateway/route/actors/123/endpoint").is_none()); +} + +#[test] +fn test_parse_actor_path_empty_values() { + // Empty actor_id + assert!(parse_actor_path("/gateway/actors//route/endpoint").is_none()); + assert!(parse_actor_path("/gateway/actors//tokens/tok/route/endpoint").is_none()); +} + +#[test] +fn test_parse_actor_path_double_slash() { + // Double slashes in path + let path = "/gateway/actors//actor-123/route/endpoint"; + // This will fail because the double slash creates an empty segment + assert!(parse_actor_path(path).is_none()); +} + +#[test] +fn test_parse_actor_path_case_sensitive() { + // Keywords are case sensitive + assert!(parse_actor_path("/Gateway/actors/123/route/endpoint").is_none()); + assert!(parse_actor_path("/gateway/Actors/123/route/endpoint").is_none()); + assert!(parse_actor_path("/gateway/actors/123/Route/endpoint").is_none()); + assert!(parse_actor_path("/gateway/actors/123/tokens/tok/Route/endpoint").is_none()); +} + +#[test] +fn test_parse_actor_path_query_and_fragment() { + // Path with both query and fragment + let path = "/gateway/actors/actor-123/route/api?query=1#section"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor-123"); + assert_eq!(result.token, None); + // Fragment is stripped, query is preserved + assert_eq!(result.remaining_path, "/api?query=1"); +} + +#[test] +fn test_parse_actor_path_only_query_string() { + // Path ending with route but having query string + let path = "/gateway/actors/actor-123/route?direct=true"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor-123"); + assert_eq!(result.token, None); + assert_eq!(result.remaining_path, "/?direct=true"); +} diff --git a/engine/packages/pegboard-gateway/src/lib.rs b/engine/packages/pegboard-gateway/src/lib.rs index d39416a96a..230afa357c 100644 --- a/engine/packages/pegboard-gateway/src/lib.rs +++ b/engine/packages/pegboard-gateway/src/lib.rs @@ -29,15 +29,17 @@ pub struct PegboardGateway { shared_state: SharedState, runner_id: Id, actor_id: Id, + path: String, } impl PegboardGateway { - #[tracing::instrument(skip_all, fields(?actor_id, ?runner_id))] - pub fn new(shared_state: SharedState, runner_id: Id, actor_id: Id) -> Self { + #[tracing::instrument(skip_all, fields(?actor_id, ?runner_id, ?path))] + pub fn new(shared_state: SharedState, runner_id: Id, actor_id: Id, path: String) -> Self { Self { shared_state, runner_id, actor_id, + path, } } } @@ -50,14 +52,8 @@ impl CustomServeTrait for PegboardGateway { req: Request>, _request_context: &mut RequestContext, ) -> Result> { - // Extract actor ID for the message (HTTP requests use x-rivet-actor header) - let actor_id = req - .headers() - .get("x-rivet-actor") - .context("missing x-rivet-actor header")? - .to_str() - .context("invalid x-rivet-actor header")? - .to_string(); + // Use the actor ID from the gateway instance + let actor_id = self.actor_id.to_string(); // Extract request parts let mut headers = HashableMap::new(); @@ -69,10 +65,6 @@ impl CustomServeTrait for PegboardGateway { // Extract method and path before consuming the request let method = req.method().to_string(); - let path = req - .uri() - .path_and_query() - .map_or_else(|| "/".to_string(), |x| x.to_string()); let body_bytes = req .into_body() @@ -96,7 +88,7 @@ impl CustomServeTrait for PegboardGateway { protocol::ToClientRequestStart { actor_id: actor_id.clone(), method, - path, + path: self.path.clone(), headers, body: if body_bytes.is_empty() { None @@ -163,22 +155,11 @@ impl CustomServeTrait for PegboardGateway { &self, client_ws: WebSocketHandle, headers: &hyper::HeaderMap, - path: &str, + _path: &str, _request_context: &mut RequestContext, ) -> Result<()> { - // Extract actor ID from WebSocket protocol - let actor_id = headers - .get(SEC_WEBSOCKET_PROTOCOL) - .and_then(|protocols| protocols.to_str().ok()) - .and_then(|protocols| { - // Parse protocols to find actor.{id} - protocols - .split(',') - .map(|p| p.trim()) - .find_map(|p| p.strip_prefix(WS_PROTOCOL_ACTOR)) - }) - .context("missing actor protocol in sec-websocket-protocol")? - .to_string(); + // Use the actor ID from the gateway instance + let actor_id = self.actor_id.to_string(); // Extract headers let mut request_headers = HashableMap::new(); @@ -202,7 +183,7 @@ impl CustomServeTrait for PegboardGateway { let open_message = protocol::ToClientTunnelMessageKind::ToClientWebSocketOpen( protocol::ToClientWebSocketOpen { actor_id: actor_id.clone(), - path: path.to_string(), + path: self.path.clone(), headers: request_headers, }, ); diff --git a/rivetkit-typescript/packages/rivetkit/src/common/actor-router-consts.ts b/rivetkit-typescript/packages/rivetkit/src/common/actor-router-consts.ts index 9f8be14a79..0a17e7231b 100644 --- a/rivetkit-typescript/packages/rivetkit/src/common/actor-router-consts.ts +++ b/rivetkit-typescript/packages/rivetkit/src/common/actor-router-consts.ts @@ -23,6 +23,7 @@ export const HEADER_RIVET_TOKEN = "x-rivet-token"; // MARK: Manager Gateway Headers export const HEADER_RIVET_TARGET = "x-rivet-target"; export const HEADER_RIVET_ACTOR = "x-rivet-actor"; +export const HEADER_RIVET_NAMESPACE = "x-rivet-namespace"; // MARK: WebSocket Protocol Prefixes /** Some servers (such as node-ws & Cloudflare) require explicitly match a certain WebSocket protocol. This gives us a static protocol to match against. */ @@ -55,5 +56,6 @@ export const ALLOWED_PUBLIC_HEADERS = [ HEADER_CONN_TOKEN, HEADER_RIVET_TARGET, HEADER_RIVET_ACTOR, + HEADER_RIVET_NAMESPACE, HEADER_RIVET_TOKEN, ]; diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts index 219fa37a0f..4520de7fa8 100644 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts +++ b/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts @@ -324,10 +324,8 @@ export class EngineActorDriver implements ActorDriver { const url = new URL(request.url); - // Parse configuration from Sec-WebSocket-Protocol header + // Parse configuration from Sec-WebSocket-Protocol header (optional for path-based routing) const protocols = request.headers.get("sec-websocket-protocol"); - if (protocols === null) - throw new Error(`Missing sec-websocket-protocol header`); let encodingRaw: string | undefined; let connParamsRaw: string | undefined; diff --git a/rivetkit-typescript/packages/rivetkit/src/manager/gateway.ts b/rivetkit-typescript/packages/rivetkit/src/manager/gateway.ts index fbcf5f4e72..9da388b83a 100644 --- a/rivetkit-typescript/packages/rivetkit/src/manager/gateway.ts +++ b/rivetkit-typescript/packages/rivetkit/src/manager/gateway.ts @@ -4,6 +4,7 @@ import { MissingActorHeader, WebSocketsNotEnabled } from "@/actor/errors"; import type { Encoding, Transport } from "@/client/mod"; import { HEADER_RIVET_ACTOR, + HEADER_RIVET_NAMESPACE, HEADER_RIVET_TARGET, WS_PROTOCOL_ACTOR, WS_PROTOCOL_CONN_ID, @@ -11,6 +12,7 @@ import { WS_PROTOCOL_CONN_TOKEN, WS_PROTOCOL_ENCODING, WS_PROTOCOL_TARGET, + WS_PROTOCOL_TOKEN, } from "@/common/actor-router-consts"; import { deconstructError, noopNext } from "@/common/utils"; import type { UniversalWebSocket, UpgradeWebSocketArgs } from "@/mod"; @@ -19,10 +21,123 @@ import { promiseWithResolvers, stringifyError } from "@/utils"; import type { ManagerDriver } from "./driver"; import { logger } from "./log"; +interface ActorPathInfo { + actorId: string; + token?: string; + remainingPath: string; +} + +/** + * Handle path-based WebSocket routing + */ +async function handleWebSocketGatewayPathBased( + runConfig: RunnerConfig, + managerDriver: ManagerDriver, + c: HonoContext, + actorPathInfo: ActorPathInfo, +): Promise { + const upgradeWebSocket = runConfig.getUpgradeWebSocket?.(); + if (!upgradeWebSocket) { + throw new WebSocketsNotEnabled(); + } + + // NOTE: Token validation implemented in EE + + // Parse additional configuration from Sec-WebSocket-Protocol header + const protocols = c.req.header("sec-websocket-protocol"); + let encodingRaw: string | undefined; + let connParamsRaw: string | undefined; + let connIdRaw: string | undefined; + let connTokenRaw: string | undefined; + + if (protocols) { + const protocolList = protocols.split(",").map((p) => p.trim()); + for (const protocol of protocolList) { + if (protocol.startsWith(WS_PROTOCOL_ENCODING)) { + encodingRaw = protocol.substring(WS_PROTOCOL_ENCODING.length); + } else if (protocol.startsWith(WS_PROTOCOL_CONN_PARAMS)) { + connParamsRaw = decodeURIComponent( + protocol.substring(WS_PROTOCOL_CONN_PARAMS.length), + ); + } else if (protocol.startsWith(WS_PROTOCOL_CONN_ID)) { + connIdRaw = protocol.substring(WS_PROTOCOL_CONN_ID.length); + } else if (protocol.startsWith(WS_PROTOCOL_CONN_TOKEN)) { + connTokenRaw = protocol.substring( + WS_PROTOCOL_CONN_TOKEN.length, + ); + } + } + } + + logger().debug({ + msg: "proxying websocket to actor via path-based routing", + actorId: actorPathInfo.actorId, + path: actorPathInfo.remainingPath, + encoding: encodingRaw, + }); + + const encoding = encodingRaw || "json"; + const connParams = connParamsRaw ? JSON.parse(connParamsRaw) : undefined; + + return await managerDriver.proxyWebSocket( + c, + actorPathInfo.remainingPath, + actorPathInfo.actorId, + encoding as any, // Will be validated by driver + connParams, + connIdRaw, + connTokenRaw, + ); +} + +/** + * Handle path-based HTTP routing + */ +async function handleHttpGatewayPathBased( + managerDriver: ManagerDriver, + c: HonoContext, + actorPathInfo: ActorPathInfo, +): Promise { + // NOTE: Token validation implemented in EE + + logger().debug({ + msg: "proxying request to actor via path-based routing", + actorId: actorPathInfo.actorId, + path: actorPathInfo.remainingPath, + method: c.req.method, + }); + + // Preserve all headers + const proxyHeaders = new Headers(c.req.raw.headers); + + // Build the proxy request with the actor URL format + const proxyUrl = new URL(`http://actor${actorPathInfo.remainingPath}`); + + const proxyRequest = new Request(proxyUrl, { + method: c.req.raw.method, + headers: proxyHeaders, + body: c.req.raw.body, + signal: c.req.raw.signal, + duplex: "half", + } as RequestInit); + + return await managerDriver.proxyRequest( + c, + proxyRequest, + actorPathInfo.actorId, + ); +} + /** * Provides an endpoint to connect to individual actors. * - * Routes requests based on the Upgrade header: + * Routes requests using either path-based routing or header-based routing: + * + * Path-based routing (checked first): + * - /gateway/actors/{actor_id}/tokens/{token}/route/{...path} + * - /gateway/actors/{actor_id}/route/{...path} + * + * Header-based routing (fallback): * - WebSocket requests: Uses sec-websocket-protocol for routing (target.actor, actor.{id}) * - HTTP requests: Uses x-rivet-target and x-rivet-actor headers for routing */ @@ -47,6 +162,40 @@ export async function actorGateway( } } + // Include query string if present (needed for parseActorPath to preserve query params) + const pathWithQuery = c.req.url.includes("?") + ? strippedPath + c.req.url.substring(c.req.url.indexOf("?")) + : strippedPath; + + // First, check if this is an actor path-based route + const actorPathInfo = parseActorPath(pathWithQuery); + if (actorPathInfo) { + logger().debug({ + msg: "routing using path-based actor routing", + actorPathInfo, + }); + + // Check if this is a WebSocket upgrade request + const isWebSocket = c.req.header("upgrade") === "websocket"; + + if (isWebSocket) { + return await handleWebSocketGatewayPathBased( + runConfig, + managerDriver, + c, + actorPathInfo, + ); + } + + // Handle regular HTTP requests + return await handleHttpGatewayPathBased( + managerDriver, + c, + actorPathInfo, + ); + } + + // Fallback to header-based routing // Check if this is a WebSocket upgrade request if (c.req.header("upgrade") === "websocket") { return await handleWebSocketGateway( @@ -188,6 +337,113 @@ async function handleHttpGateway( return await managerDriver.proxyRequest(c, proxyRequest, actorId); } +/** + * Parse actor routing information from path + * Matches patterns: + * - /gateway/actors/{actor_id}/tokens/{token}/route/{...path} + * - /gateway/actors/{actor_id}/route/{...path} + */ +export function parseActorPath(path: string): ActorPathInfo | null { + // Find query string position (everything from ? onwards, but before fragment) + const queryPos = path.indexOf("?"); + const fragmentPos = path.indexOf("#"); + + // Extract query string (excluding fragment) + let queryString = ""; + if (queryPos !== -1) { + if (fragmentPos !== -1 && queryPos < fragmentPos) { + queryString = path.slice(queryPos, fragmentPos); + } else { + queryString = path.slice(queryPos); + } + } + + // Extract base path (before query and fragment) + let basePath = path; + if (queryPos !== -1) { + basePath = path.slice(0, queryPos); + } else if (fragmentPos !== -1) { + basePath = path.slice(0, fragmentPos); + } + + // Check for double slashes (invalid path) + if (basePath.includes("//")) { + return null; + } + + // Split the path into segments + const segments = basePath.split("/").filter((s) => s.length > 0); + + // Check minimum required segments: gateway, actors, {actor_id}, route + if (segments.length < 4) { + return null; + } + + // Verify the fixed segments + if (segments[0] !== "gateway" || segments[1] !== "actors") { + return null; + } + + // Check for empty actor_id + if (segments[2].length === 0) { + return null; + } + + const actorId = segments[2]; + + // Check for token or direct route + let token: string | undefined; + let remainingPathStartIdx: number; + + if ( + segments.length >= 6 && + segments[3] === "tokens" && + segments[5] === "route" + ) { + // Pattern with token: /gateway/actors/{actor_id}/tokens/{token}/route/{...path} + // Check for empty token + if (segments[4].length === 0) { + return null; + } + token = segments[4]; + remainingPathStartIdx = 6; + } else if (segments.length >= 4 && segments[3] === "route") { + // Pattern without token: /gateway/actors/{actor_id}/route/{...path} + token = undefined; + remainingPathStartIdx = 4; + } else { + return null; + } + + // Calculate the position in the original path where remaining path starts + let prefixLen = 0; + for (let i = 0; i < remainingPathStartIdx; i++) { + prefixLen += 1 + segments[i].length; // +1 for the slash + } + + // Extract the remaining path preserving trailing slashes + let remainingBase: string; + if (prefixLen < basePath.length) { + remainingBase = basePath.slice(prefixLen); + } else { + remainingBase = "/"; + } + + // Ensure remaining path starts with / + let remainingPath: string; + if (remainingBase.length === 0 || !remainingBase.startsWith("/")) { + remainingPath = `/${remainingBase}${queryString}`; + } else { + remainingPath = `${remainingBase}${queryString}`; + } + + return { + actorId, + token, + remainingPath, + }; +} + /** * Creates a WebSocket proxy for test endpoints that forwards messages between server and client WebSockets */ diff --git a/rivetkit-typescript/packages/rivetkit/tests/parse-actor-path.test.ts b/rivetkit-typescript/packages/rivetkit/tests/parse-actor-path.test.ts new file mode 100644 index 0000000000..0651092a0d --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/tests/parse-actor-path.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, test } from "vitest"; +import { parseActorPath } from "@/manager/gateway"; + +describe("parseActorPath", () => { + describe("Valid paths with token", () => { + test("should parse basic path with token and route", () => { + const path = + "/gateway/actors/actor-123/tokens/my-token/route/api/v1/endpoint"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor-123"); + expect(result?.token).toBe("my-token"); + expect(result?.remainingPath).toBe("/api/v1/endpoint"); + }); + + test("should parse path with UUID as actor ID", () => { + const path = + "/gateway/actors/12345678-1234-1234-1234-123456789abc/tokens/my-token/route/status"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe( + "12345678-1234-1234-1234-123456789abc", + ); + expect(result?.token).toBe("my-token"); + expect(result?.remainingPath).toBe("/status"); + }); + + test("should parse path with token and query parameters", () => { + const path = + "/gateway/actors/actor-456/tokens/token123/route/api?key=value"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor-456"); + expect(result?.token).toBe("token123"); + expect(result?.remainingPath).toBe("/api?key=value"); + }); + + test("should parse path with token and no remaining path", () => { + const path = "/gateway/actors/actor-000/tokens/tok/route"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor-000"); + expect(result?.token).toBe("tok"); + expect(result?.remainingPath).toBe("/"); + }); + + test("should parse complex path with token and multiple segments", () => { + const path = + "/gateway/actors/actor-complex/tokens/secure-token/route/api/v2/users/123/profile/settings"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor-complex"); + expect(result?.token).toBe("secure-token"); + expect(result?.remainingPath).toBe( + "/api/v2/users/123/profile/settings", + ); + }); + }); + + describe("Valid paths without token", () => { + test("should parse basic path without token", () => { + const path = "/gateway/actors/actor-123/route/api/v1/endpoint"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor-123"); + expect(result?.token).toBeUndefined(); + expect(result?.remainingPath).toBe("/api/v1/endpoint"); + }); + + test("should parse path with UUID without token", () => { + const path = + "/gateway/actors/12345678-1234-1234-1234-123456789abc/route/status"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe( + "12345678-1234-1234-1234-123456789abc", + ); + expect(result?.token).toBeUndefined(); + expect(result?.remainingPath).toBe("/status"); + }); + + test("should parse path without token and with query params", () => { + const path = + "/gateway/actors/actor-456/route/api/endpoint?foo=bar&baz=qux"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor-456"); + expect(result?.token).toBeUndefined(); + expect(result?.remainingPath).toBe("/api/endpoint?foo=bar&baz=qux"); + }); + + test("should parse path without token and no remaining path", () => { + const path = "/gateway/actors/actor-000/route"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor-000"); + expect(result?.token).toBeUndefined(); + expect(result?.remainingPath).toBe("/"); + }); + }); + + describe("Query parameters and fragments", () => { + test("should preserve query parameters", () => { + const path = + "/gateway/actors/actor-456/route/api/endpoint?foo=bar&baz=qux"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.remainingPath).toBe("/api/endpoint?foo=bar&baz=qux"); + }); + + test("should strip fragment from path", () => { + const path = "/gateway/actors/actor-789/route/page#section"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor-789"); + expect(result?.token).toBeUndefined(); + expect(result?.remainingPath).toBe("/page"); + }); + + test("should preserve query but strip fragment", () => { + const path = "/gateway/actors/actor-123/route/api?query=1#section"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor-123"); + expect(result?.token).toBeUndefined(); + expect(result?.remainingPath).toBe("/api?query=1"); + }); + + test("should handle path ending with route but having query string", () => { + const path = "/gateway/actors/actor-123/route?direct=true"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor-123"); + expect(result?.token).toBeUndefined(); + expect(result?.remainingPath).toBe("/?direct=true"); + }); + }); + + describe("Trailing slashes", () => { + test("should preserve trailing slash in remaining path", () => { + const path = "/gateway/actors/actor-111/route/api/"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor-111"); + expect(result?.token).toBeUndefined(); + expect(result?.remainingPath).toBe("/api/"); + }); + }); + + describe("Special characters", () => { + test("should handle actor ID with allowed special characters", () => { + const path = "/gateway/actors/actor_id-123.test/route/endpoint"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor_id-123.test"); + expect(result?.token).toBeUndefined(); + expect(result?.remainingPath).toBe("/endpoint"); + }); + + test("should handle URL encoded characters in remaining path", () => { + const path = + "/gateway/actors/actor-123/route/api%20endpoint/test%2Fpath"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor-123"); + expect(result?.token).toBeUndefined(); + expect(result?.remainingPath).toBe("/api%20endpoint/test%2Fpath"); + }); + }); + + describe("Invalid paths - wrong prefix", () => { + test("should reject path with wrong prefix", () => { + expect(parseActorPath("/api/actors/123/route/endpoint")).toBeNull(); + }); + + test("should reject path with wrong actor keyword", () => { + expect( + parseActorPath("/gateway/actor/123/route/endpoint"), + ).toBeNull(); + }); + + test("should reject path missing gateway prefix", () => { + expect(parseActorPath("/actors/123/route/endpoint")).toBeNull(); + }); + }); + + describe("Invalid paths - missing route", () => { + test("should reject path without route keyword", () => { + expect(parseActorPath("/gateway/actors/123")).toBeNull(); + }); + + test("should reject path with endpoint but no route keyword", () => { + expect(parseActorPath("/gateway/actors/123/endpoint")).toBeNull(); + }); + + test("should reject path with tokens but no route keyword", () => { + expect(parseActorPath("/gateway/actors/123/tokens/tok")).toBeNull(); + }); + }); + + describe("Invalid paths - too short", () => { + test("should reject path with only gateway", () => { + expect(parseActorPath("/gateway")).toBeNull(); + }); + + test("should reject path with only gateway and actors", () => { + expect(parseActorPath("/gateway/actors")).toBeNull(); + }); + + test("should reject path with only gateway, actors, and actor ID", () => { + expect(parseActorPath("/gateway/actors/123")).toBeNull(); + }); + }); + + describe("Invalid paths - malformed token path", () => { + test("should reject token path missing route keyword", () => { + expect( + parseActorPath("/gateway/actors/123/tokens/tok/api"), + ).toBeNull(); + }); + + test("should reject path with empty token", () => { + expect( + parseActorPath("/gateway/actors/123/tokens//route/api"), + ).toBeNull(); + }); + }); + + describe("Invalid paths - wrong segment positions", () => { + test("should reject segments in wrong order", () => { + expect( + parseActorPath("/actors/gateway/123/route/endpoint"), + ).toBeNull(); + }); + + test("should reject route keyword in wrong position", () => { + expect( + parseActorPath("/gateway/route/actors/123/endpoint"), + ).toBeNull(); + }); + }); + + describe("Invalid paths - empty values", () => { + test("should reject path with empty actor ID", () => { + expect( + parseActorPath("/gateway/actors//route/endpoint"), + ).toBeNull(); + }); + + test("should reject path with empty actor ID in token path", () => { + expect( + parseActorPath("/gateway/actors//tokens/tok/route/endpoint"), + ).toBeNull(); + }); + }); + + describe("Invalid paths - double slash", () => { + test("should reject path with double slashes", () => { + const path = "/gateway/actors//actor-123/route/endpoint"; + expect(parseActorPath(path)).toBeNull(); + }); + }); + + describe("Invalid paths - case sensitive", () => { + test("should reject path with capitalized Gateway", () => { + expect( + parseActorPath("/Gateway/actors/123/route/endpoint"), + ).toBeNull(); + }); + + test("should reject path with capitalized Actors", () => { + expect( + parseActorPath("/gateway/Actors/123/route/endpoint"), + ).toBeNull(); + }); + + test("should reject path with capitalized Route", () => { + expect( + parseActorPath("/gateway/actors/123/Route/endpoint"), + ).toBeNull(); + }); + + test("should reject token path with capitalized Route", () => { + expect( + parseActorPath("/gateway/actors/123/tokens/tok/Route/endpoint"), + ).toBeNull(); + }); + }); +}); From b2d5bfe5f1094ba6fb97b7cd6f261ccf3fa5f30c Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 22 Oct 2025 13:13:27 -0700 Subject: [PATCH 4/4] feat(examples-cursors): cursors example --- examples/cursors/.gitignore | 2 + examples/cursors/README.md | 43 +++ examples/cursors/package.json | 33 +++ examples/cursors/src/backend/registry.ts | 101 +++++++ examples/cursors/src/backend/server.ts | 8 + examples/cursors/src/frontend/App.tsx | 350 +++++++++++++++++++++++ examples/cursors/src/frontend/index.html | 191 +++++++++++++ examples/cursors/src/frontend/main.tsx | 12 + examples/cursors/tests/cursors.test.ts | 132 +++++++++ examples/cursors/tsconfig.json | 43 +++ examples/cursors/turbo.json | 4 + examples/cursors/vite.config.ts | 10 + examples/cursors/vitest.config.ts | 10 + 13 files changed, 939 insertions(+) create mode 100644 examples/cursors/.gitignore create mode 100644 examples/cursors/README.md create mode 100644 examples/cursors/package.json create mode 100644 examples/cursors/src/backend/registry.ts create mode 100644 examples/cursors/src/backend/server.ts create mode 100644 examples/cursors/src/frontend/App.tsx create mode 100644 examples/cursors/src/frontend/index.html create mode 100644 examples/cursors/src/frontend/main.tsx create mode 100644 examples/cursors/tests/cursors.test.ts create mode 100644 examples/cursors/tsconfig.json create mode 100644 examples/cursors/turbo.json create mode 100644 examples/cursors/vite.config.ts create mode 100644 examples/cursors/vitest.config.ts diff --git a/examples/cursors/.gitignore b/examples/cursors/.gitignore new file mode 100644 index 0000000000..79b7a1192f --- /dev/null +++ b/examples/cursors/.gitignore @@ -0,0 +1,2 @@ +.actorcore +node_modules \ No newline at end of file diff --git a/examples/cursors/README.md b/examples/cursors/README.md new file mode 100644 index 0000000000..b7b2646a0b --- /dev/null +++ b/examples/cursors/README.md @@ -0,0 +1,43 @@ +# Real-time Collaborative Cursors for RivetKit + +Example project demonstrating real-time cursor tracking and collaborative canvas 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/cursors +npm install +``` + +### Development + +```sh +npm run dev +``` + +Open your browser to `http://localhost:5173`. Open multiple tabs or windows to see real-time cursor tracking and text placement across different users. + +## Features + +- Real-time cursor position tracking +- Multiple users with color-coded cursors +- Click-to-place text on canvas +- Multiple room support for different collaborative spaces +- Persistent text labels across sessions +- Event-driven architecture with RivetKit actors +- TypeScript support throughout + +## License + +Apache 2.0 diff --git a/examples/cursors/package.json b/examples/cursors/package.json new file mode 100644 index 0000000000..dbee03b23e --- /dev/null +++ b/examples/cursors/package.json @@ -0,0 +1,33 @@ +{ + "name": "example-cursors", + "version": "2.0.20", + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", + "dev:backend": "tsx --watch src/backend/server.ts", + "dev:frontend": "vite", + "check-types": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "@types/prompts": "^2", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "concurrently": "^8.2.2", + "prompts": "^2.4.2", + "tsx": "^3.12.7", + "typescript": "^5.5.2", + "vite": "^5.0.0", + "vitest": "^3.1.1", + "@rivetkit/react": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "dependencies": { + "rivetkit": "workspace:*" + }, + "stableVersion": "0.8.0" +} diff --git a/examples/cursors/src/backend/registry.ts b/examples/cursors/src/backend/registry.ts new file mode 100644 index 0000000000..9936f5c704 --- /dev/null +++ b/examples/cursors/src/backend/registry.ts @@ -0,0 +1,101 @@ +import { actor, setup } from "rivetkit"; + +export interface CursorPosition { + userId: string; + x: number; + y: number; + timestamp: number; +} + +export interface TextLabel { + id: string; + userId: string; + text: string; + x: number; + y: number; + timestamp: number; +} + +export const cursorRoom = actor({ + state: { + textLabels: [] as TextLabel[], + }, + + connState: { + cursor: null as CursorPosition | null, + }, + + actions: { + // Update cursor position + updateCursor: (c, userId: string, x: number, y: number) => { + const cursor: CursorPosition = { + userId, + x, + y, + timestamp: Date.now(), + }; + c.conn.state.cursor = cursor; + c.broadcast("cursorMoved", cursor); + return cursor; + }, + + // Update text on the canvas (creates or updates) + updateText: ( + c, + id: string, + userId: string, + text: string, + x: number, + y: number, + ) => { + const textLabel: TextLabel = { + id, + userId, + text, + x, + y, + timestamp: Date.now(), + }; + + // Find and update existing text label or add new one + const existingIndex = c.state.textLabels.findIndex( + (label) => label.id === id, + ); + if (existingIndex >= 0) { + c.state.textLabels[existingIndex] = textLabel; + } else { + c.state.textLabels.push(textLabel); + } + + c.broadcast("textUpdated", textLabel); + return textLabel; + }, + + // Remove text from the canvas + removeText: (c, id: string) => { + c.state.textLabels = c.state.textLabels.filter( + (label) => label.id !== id, + ); + c.broadcast("textRemoved", id); + }, + + // Get all room state (cursors and text labels) + getRoomState: (c) => { + const cursors: Record = {}; + for (const conn of c.conns.values()) { + if (conn.state.cursor) { + cursors[conn.state.cursor.userId] = conn.state.cursor; + } + } + return { + cursors, + textLabels: c.state.textLabels, + }; + }, + }, +}); + +// Register actors for use: https://rivet.dev/docs/setup +export const registry = setup({ + use: { cursorRoom }, +}); diff --git a/examples/cursors/src/backend/server.ts b/examples/cursors/src/backend/server.ts new file mode 100644 index 0000000000..b51ac47fe0 --- /dev/null +++ b/examples/cursors/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/cursors/src/frontend/App.tsx b/examples/cursors/src/frontend/App.tsx new file mode 100644 index 0000000000..fc7d5eb72a --- /dev/null +++ b/examples/cursors/src/frontend/App.tsx @@ -0,0 +1,350 @@ +import { createRivetKit } from "@rivetkit/react"; +import { useEffect, useRef, useState } from "react"; +import type { + CursorPosition, + TextLabel, + registry, +} from "../backend/registry"; + +const { useActor } = createRivetKit("http://localhost:6420"); + +// Generate a random user ID +const generateUserId = () => + `user-${Math.random().toString(36).substring(2, 9)}`; + +// Cursor colors for different users (darker palette) +const CURSOR_COLORS = [ + "#E63946", + "#2A9D8F", + "#1B8AAE", + "#F77F00", + "#06A77D", + "#D4A017", + "#9B59B6", + "#5DADE2", +]; + +function getColorForUser(userId: string): string { + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = userId.charCodeAt(i) + ((hash << 5) - hash); + } + return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length]; +} + +// Virtual canvas size - all coordinates are in this space +const CANVAS_WIDTH = 1920; +const CANVAS_HEIGHT = 1080; + +export function App() { + const [roomId, setRoomId] = useState("general"); + const [userId] = useState(generateUserId()); + const [cursors, setCursors] = useState>({}); + const [textLabels, setTextLabels] = useState([]); + const [textInput, setTextInput] = useState(""); + const [isTyping, setIsTyping] = useState(false); + const [typingPosition, setTypingPosition] = useState({ x: 0, y: 0 }); + const [currentTextId, setCurrentTextId] = useState(null); + const [scale, setScale] = useState(1); + const canvasRef = useRef(null); + const containerRef = useRef(null); + + const cursorRoom = useActor({ + name: "cursorRoom", + key: [roomId], + }); + + // Calculate scale factor to fit canvas in viewport + useEffect(() => { + const updateScale = () => { + if (!containerRef.current) return; + + const containerWidth = containerRef.current.clientWidth; + const containerHeight = containerRef.current.clientHeight; + + // Calculate scale to fit canvas while maintaining aspect ratio + const scaleX = containerWidth / CANVAS_WIDTH; + const scaleY = containerHeight / CANVAS_HEIGHT; + const newScale = Math.min(scaleX, scaleY); + + setScale(newScale); + }; + + updateScale(); + window.addEventListener("resize", updateScale); + return () => window.removeEventListener("resize", updateScale); + }, []); + + // Load initial state + useEffect(() => { + if (cursorRoom.connection) { + cursorRoom.connection.getRoomState().then((state) => { + setCursors(state.cursors); + setTextLabels(state.textLabels); + }).catch((error) => { + console.error('error loading room state', error); + }); + } + }, [cursorRoom.connection]); + + // Listen for cursor movements + cursorRoom.useEvent("cursorMoved", (cursor: CursorPosition) => { + setCursors((prev) => ({ + ...prev, + [cursor.userId]: cursor, + })); + }); + + // Listen for text updates + cursorRoom.useEvent("textUpdated", (label: TextLabel) => { + setTextLabels((prev) => { + const existingIndex = prev.findIndex(l => l.id === label.id); + if (existingIndex >= 0) { + const newLabels = [...prev]; + newLabels[existingIndex] = label; + return newLabels; + } else { + return [...prev, label]; + } + }); + }); + + // Listen for text removal + cursorRoom.useEvent("textRemoved", (id: string) => { + setTextLabels((prev) => prev.filter(label => label.id !== id)); + }); + + // Listen for cursor removal (when connection closes) + cursorRoom.useEvent("cursorRemoved", (cursor: CursorPosition) => { + setCursors((prev) => { + const newCursors = { ...prev }; + delete newCursors[cursor.userId]; + return newCursors; + }); + }); + + // Convert screen coordinates to canvas coordinates + const screenToCanvas = (screenX: number, screenY: number) => { + if (!canvasRef.current) return { x: 0, y: 0 }; + + const rect = canvasRef.current.getBoundingClientRect(); + const x = (screenX - rect.left) / scale; + const y = (screenY - rect.top) / scale; + + return { x, y }; + }; + + // Handle mouse movement on canvas + const handleMouseMove = (e: React.MouseEvent) => { + if (cursorRoom.connection && canvasRef.current) { + const { x, y } = screenToCanvas(e.clientX, e.clientY); + cursorRoom.connection.updateCursor(userId, x, y); + } + }; + + // Handle canvas click + const handleCanvasClick = (e: React.MouseEvent) => { + if (!canvasRef.current) return; + + const { x, y } = screenToCanvas(e.clientX, e.clientY); + const newTextId = `${userId}-${Date.now()}`; + setTypingPosition({ x, y }); + setCurrentTextId(newTextId); + setIsTyping(true); + setTextInput(""); + }; + + // Handle text input changes + const handleTextChange = (newText: string) => { + setTextInput(newText); + if (cursorRoom.connection && currentTextId && newText.trim()) { + cursorRoom.connection.updateText( + currentTextId, + userId, + newText, + typingPosition.x, + typingPosition.y, + ); + } + }; + + // Handle key press while typing + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + // Finalize the text + if (textInput.trim() && cursorRoom.connection && currentTextId) { + cursorRoom.connection.updateText( + currentTextId, + userId, + textInput, + typingPosition.x, + typingPosition.y, + ); + } else if (cursorRoom.connection && currentTextId) { + // Remove empty text + cursorRoom.connection.removeText(currentTextId); + } + setTextInput(""); + setIsTyping(false); + setCurrentTextId(null); + } else if (e.key === "Escape") { + // Cancel typing and remove text + if (cursorRoom.connection && currentTextId) { + cursorRoom.connection.removeText(currentTextId); + } + setTextInput(""); + setIsTyping(false); + setCurrentTextId(null); + } + }; + + // Cursor is automatically removed when connection closes via connState cleanup + + return ( +
+
+
+ + setRoomId(e.target.value)} + placeholder="Enter room name" + /> +
+
+ Your ID: {userId} +
+
+ +
+
+ {/* Render text labels */} + {textLabels + .filter((label) => label.id !== currentTextId) + .map((label) => ( +
+ {label.text} +
+ ))} + + {/* Render text being typed */} + {isTyping && ( +
+
+ {textInput} + | +
+
+ enter +
+
+ )} + + {/* Render cursors */} + {Object.entries(cursors).map(([id, cursor]) => { + const color = getColorForUser(cursor.userId); + const isOwnCursor = id === userId; + return ( +
+ + + +
+ {isOwnCursor ? "you" : cursor.userId} +
+
+ ); + })} + + {!cursorRoom.connection && ( +
Connecting to room...
+ )} + + {/* Hidden input to capture typing */} + {isTyping && ( + handleTextChange(e.target.value)} + onBlur={() => { + if (!textInput.trim() && cursorRoom.connection && currentTextId) { + cursorRoom.connection.removeText(currentTextId); + setCurrentTextId(null); + } + setIsTyping(false); + }} + autoFocus + /> + )} +
+
+
+ ); +} diff --git a/examples/cursors/src/frontend/index.html b/examples/cursors/src/frontend/index.html new file mode 100644 index 0000000000..e7c7bafc88 --- /dev/null +++ b/examples/cursors/src/frontend/index.html @@ -0,0 +1,191 @@ + + + + + + Cursors Example + + + +
+ + + \ No newline at end of file diff --git a/examples/cursors/src/frontend/main.tsx b/examples/cursors/src/frontend/main.tsx new file mode 100644 index 0000000000..bd39f29eec --- /dev/null +++ b/examples/cursors/src/frontend/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Root element not found"); + +createRoot(root).render( + + + +); diff --git a/examples/cursors/tests/cursors.test.ts b/examples/cursors/tests/cursors.test.ts new file mode 100644 index 0000000000..235b87cb2b --- /dev/null +++ b/examples/cursors/tests/cursors.test.ts @@ -0,0 +1,132 @@ +import { setupTest } from "rivetkit/test"; +import { expect, test } from "vitest"; +import { registry } from "../src/backend/registry"; + +test("Cursor room can handle cursor updates", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const room = client.cursorRoom.getOrCreate(["test-room"]); + + // Test initial state + const initialCursors = await room.getCursors(); + expect(initialCursors).toEqual({}); + + // Update cursor position + const cursor1 = await room.updateCursor("user1", 100, 200); + + // Verify cursor structure + expect(cursor1).toMatchObject({ + userId: "user1", + x: 100, + y: 200, + timestamp: expect.any(Number), + }); + + // Update another cursor + await room.updateCursor("user2", 300, 400); + + // Verify cursors are stored + const cursors = await room.getCursors(); + expect(Object.keys(cursors)).toHaveLength(2); + expect(cursors.user1).toBeDefined(); + expect(cursors.user2).toBeDefined(); + expect(cursors.user1.x).toBe(100); + expect(cursors.user1.y).toBe(200); + expect(cursors.user2.x).toBe(300); + expect(cursors.user2.y).toBe(400); +}); + +test("Cursor room can place text labels", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const room = client.cursorRoom.getOrCreate(["test-text"]); + + // Test initial state + const initialLabels = await room.getTextLabels(); + expect(initialLabels).toEqual([]); + + // Place text + const label1 = await room.placeText("user1", "Hello", 50, 75); + + // Verify label structure + expect(label1).toMatchObject({ + id: expect.any(String), + userId: "user1", + text: "Hello", + x: 50, + y: 75, + timestamp: expect.any(Number), + }); + + // Place another text + const label2 = await room.placeText("user2", "World", 150, 175); + + // Verify labels are stored in order + const labels = await room.getTextLabels(); + expect(labels).toHaveLength(2); + expect(labels[0]).toEqual(label1); + expect(labels[1]).toEqual(label2); +}); + +test("Cursor room can remove cursors", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const room = client.cursorRoom.getOrCreate(["test-remove"]); + + // Add some cursors + await room.updateCursor("user1", 100, 200); + await room.updateCursor("user2", 300, 400); + await room.updateCursor("user3", 500, 600); + + let cursors = await room.getCursors(); + expect(Object.keys(cursors)).toHaveLength(3); + + // Remove one cursor + await room.removeCursor("user2"); + + cursors = await room.getCursors(); + expect(Object.keys(cursors)).toHaveLength(2); + expect(cursors.user1).toBeDefined(); + expect(cursors.user3).toBeDefined(); + expect(cursors.user2).toBeUndefined(); +}); + +test("Cursor updates overwrite previous positions", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const room = client.cursorRoom.getOrCreate(["test-overwrite"]); + + // Update cursor multiple times + await room.updateCursor("user1", 100, 200); + const cursor2 = await room.updateCursor("user1", 300, 400); + const cursor3 = await room.updateCursor("user1", 500, 600); + + const cursors = await room.getCursors(); + expect(Object.keys(cursors)).toHaveLength(1); + expect(cursors.user1.x).toBe(500); + expect(cursors.user1.y).toBe(600); + expect(cursors.user1.timestamp).toBe(cursor3.timestamp); + expect(cursor3.timestamp).toBeGreaterThanOrEqual(cursor2.timestamp); +}); + +test("Multiple users can place text in the same room", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const room = client.cursorRoom.getOrCreate(["test-multiuser-text"]); + + // Multiple users placing text + await room.placeText("Alice", "Hello!", 10, 10); + await room.placeText("Bob", "Hi there!", 50, 50); + await room.placeText("Charlie", "Good day!", 100, 100); + await room.placeText("Alice", "How are you?", 150, 150); + + const labels = await room.getTextLabels(); + expect(labels).toHaveLength(4); + + // Verify users + expect(labels[0].userId).toBe("Alice"); + expect(labels[1].userId).toBe("Bob"); + expect(labels[2].userId).toBe("Charlie"); + expect(labels[3].userId).toBe("Alice"); + + // Verify text content + expect(labels[0].text).toBe("Hello!"); + expect(labels[1].text).toBe("Hi there!"); + expect(labels[2].text).toBe("Good day!"); + expect(labels[3].text).toBe("How are you?"); +}); diff --git a/examples/cursors/tsconfig.json b/examples/cursors/tsconfig.json new file mode 100644 index 0000000000..b2ac3f62a7 --- /dev/null +++ b/examples/cursors/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "esnext", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["esnext", "dom"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "esnext", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "bundler", + /* Specify type package names to be included without being referenced in a source file. */ + "types": ["node", "vite/client", "vitest"], + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true + }, + "include": ["src/**/*", "actors/**/*", "tests/**/*"] +} diff --git a/examples/cursors/turbo.json b/examples/cursors/turbo.json new file mode 100644 index 0000000000..29d4cb2625 --- /dev/null +++ b/examples/cursors/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] +} diff --git a/examples/cursors/vite.config.ts b/examples/cursors/vite.config.ts new file mode 100644 index 0000000000..a291f28846 --- /dev/null +++ b/examples/cursors/vite.config.ts @@ -0,0 +1,10 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + root: "src/frontend", + server: { + port: 5173, + }, +}); diff --git a/examples/cursors/vitest.config.ts b/examples/cursors/vitest.config.ts new file mode 100644 index 0000000000..f913a97abd --- /dev/null +++ b/examples/cursors/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + server: { + port: 5173, + }, + test: { + include: ["tests/**/*.test.ts"], + }, +});