diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b0790412..a99f13508 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,14 +31,14 @@ jobs: with: node-version: '22.14' # Note: We're not using the built-in cache here because we need to use corepack - + - name: Setup Corepack run: corepack enable - + - id: yarn-cache-dir-path name: Get yarn cache directory path run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - + - name: Cache dependencies uses: actions/cache@v3 id: cache @@ -50,14 +50,14 @@ jobs: restore-keys: | ${{ runner.os }}-deps-${{ hashFiles('**/yarn.lock') }}- ${{ runner.os }}-deps- - + - name: Install dependencies run: yarn install - # - name: Run actor-core tests - # # TODO: Add back - # # run: yarn test - # run: yarn check-types + - name: Run actor-core tests + # TODO: Add back + # run: yarn test + run: yarn check-types # - name: Install Rust # uses: dtolnay/rust-toolchain@stable diff --git a/CLAUDE.md b/CLAUDE.md index 7f4ec0563..55bf4790d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ - **Build:** `yarn build` - Production build using Turbopack - **Build specific package:** `yarn build -F actor-core` - Build only specified package - **Format:** `yarn fmt` - Format code with Biome +- Do not run the format command automatically. ## Core Concepts @@ -85,6 +86,9 @@ This ensures imports resolve correctly across different build environments and p - Extend from `ActorError` base class - Use `UserError` for client-safe errors - Use `InternalError` for internal errors +- Don't try to fix type issues by casting to unknown or any. If you need to do this, then stop and ask me to manually intervene. +- Write log messages in lowercase +- Instead of returning raw HTTP responses with c.json, use or write an error in packages/actor-core/src/actor/errors.ts and throw that instead. The middleware will automatically serialize the response for you. ## Project Structure diff --git a/README.md b/README.md index 6f3c0b794..499de11e4 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Each unit of compute is like a tiny server that remembers things between request   **Durable State Without a Database** -Your code's state is saved automatically—no database, ORM, or config needed. Just use regular JavaScript objects or SQLite (available in April). +Your code's state is saved automatically—no database, ORM, or config needed. Just use regular JavaScript objects or SQLite (available in June).
@@ -96,7 +96,7 @@ Browse snippets for how to use ActorCore with different use cases. | Multiplayer Game | [actor.ts](/examples/snippets/game/actor-json.ts) | [actor.ts](/examples/snippets/game/actor-sqlite.ts) | [App.tsx](/examples/snippets/game/App.tsx) | | Rate Limiter | [actor.ts](/examples/snippets/rate/actor-json.ts) | [actor.ts](/examples/snippets/rate/actor-sqlite.ts) | [App.tsx](/examples/snippets/rate/App.tsx) | -_SQLite will be available in late April. We’re working on publishing full examples related to these snippets. If you find an error, please create an issue._ +_SQLite will be available in June. We’re working on publishing full examples related to these snippets. If you find an error, please create an issue._ ## Runs On Your Stack @@ -142,7 +142,7 @@ Seamlessly integrate ActorCore with your favorite frameworks, languages, and too - AI SDK  [AI SDK](https://github.com/rivet-gg/actor-core/issues/907) *(On The Roadmap)* ### Local-First Sync -- LiveStore  [LiveStore](https://github.com/rivet-gg/actor-core/issues/908) *(Available In May)* +- LiveStore  [LiveStore](https://github.com/rivet-gg/actor-core/issues/908) *(Available In June)* - ZeroSync  [ZeroSync](https://github.com/rivet-gg/actor-core/issues/909) *(Help Wanted)* - TinyBase  [TinyBase](https://github.com/rivet-gg/actor-core/issues/910) *(Help Wanted)* - Yjs  [Yjs](https://github.com/rivet-gg/actor-core/issues/911) *(Help Wanted)* diff --git a/docs/concepts/interacting-with-actors.mdx b/docs/concepts/interacting-with-actors.mdx index 14773a56e..6d2196471 100644 --- a/docs/concepts/interacting-with-actors.mdx +++ b/docs/concepts/interacting-with-actors.mdx @@ -569,7 +569,6 @@ Other common errors you might encounter: - `InternalError`: Error from your actor that's not a subclass of `UserError` - `ManagerError`: Issues when connecting to or communicating with the actor manager -- `NoSupportedTransport`: When the client and server have no compatible transport ## Disconnecting and Cleanup diff --git a/docs/introduction.mdx b/docs/introduction.mdx index f793122c6..5e64e72ce 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -67,7 +67,7 @@ import FAQ from "/snippets/landing-faq.mdx";

Durable State Without a Database

-

Your code's state is saved automatically—no database, ORM, or config needed. Just use regular JavaScript objects or SQLite (available in April).

+

Your code's state is saved automatically—no database, ORM, or config needed. Just use regular JavaScript objects or SQLite (available in June).

diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 000000000..bae3c41e4 --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,328 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "0.8.0", + "title": "ActorCore API" + }, + "components": { + "schemas": { + "ResolveResponse": { + "type": "object", + "properties": { + "i": { + "type": "string", + "example": "actor-123" + } + }, + "required": [ + "i" + ] + }, + "ResolveQuery": { + "type": "object", + "properties": { + "query": { + "nullable": true, + "example": { + "getForId": { + "actorId": "actor-123" + } + } + } + } + }, + "ActionResponse": { + "nullable": true + }, + "ActionRequest": { + "type": "object", + "properties": { + "query": { + "nullable": true, + "example": { + "getForId": { + "actorId": "actor-123" + } + } + }, + "body": { + "nullable": true, + "example": { + "param1": "value1", + "param2": 123 + } + } + } + }, + "ConnectionMessageResponse": { + "nullable": true + }, + "ConnectionMessageRequest": { + "type": "object", + "properties": { + "message": { + "nullable": true, + "example": { + "type": "message", + "content": "Hello, actor!" + } + } + } + } + }, + "parameters": {} + }, + "paths": { + "/actors/resolve": { + "post": { + "parameters": [ + { + "schema": { + "type": "string", + "description": "Actor query information" + }, + "required": true, + "name": "X-AC-Query", + "in": "header" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveQuery" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveResponse" + } + } + } + }, + "400": { + "description": "User error" + }, + "500": { + "description": "Internal error" + } + } + } + }, + "/actors/connect/websocket": { + "get": { + "parameters": [ + { + "schema": { + "type": "string", + "description": "The encoding format to use for the response (json, cbor)", + "example": "json" + }, + "required": true, + "name": "encoding", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Actor query information" + }, + "required": true, + "name": "query", + "in": "query" + } + ], + "responses": { + "101": { + "description": "WebSocket upgrade" + } + } + } + }, + "/actors/connect/sse": { + "get": { + "parameters": [ + { + "schema": { + "type": "string", + "description": "The encoding format to use for the response (json, cbor)", + "example": "json" + }, + "required": true, + "name": "X-AC-Encoding", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Actor query information" + }, + "required": true, + "name": "X-AC-Query", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Connection parameters" + }, + "required": false, + "name": "X-AC-Conn-Params", + "in": "header" + } + ], + "responses": { + "200": { + "description": "SSE stream", + "content": { + "text/event-stream": { + "schema": { + "nullable": true + } + } + } + } + } + } + }, + "/actors/actions/{action}": { + "post": { + "parameters": [ + { + "schema": { + "type": "string", + "example": "myAction" + }, + "required": true, + "name": "action", + "in": "path" + }, + { + "schema": { + "type": "string", + "description": "The encoding format to use for the response (json, cbor)", + "example": "json" + }, + "required": true, + "name": "X-AC-Encoding", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Connection parameters" + }, + "required": false, + "name": "X-AC-Conn-Params", + "in": "header" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionResponse" + } + } + } + }, + "400": { + "description": "User error" + }, + "500": { + "description": "Internal error" + } + } + } + }, + "/actors/message": { + "post": { + "parameters": [ + { + "schema": { + "type": "string", + "description": "Actor ID (used in some endpoints)", + "example": "actor-123456" + }, + "required": true, + "name": "X-AC-Actor", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Connection ID", + "example": "conn-123456" + }, + "required": true, + "name": "X-AC-Conn", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "The encoding format to use for the response (json, cbor)", + "example": "json" + }, + "required": true, + "name": "X-AC-Encoding", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Connection token" + }, + "required": true, + "name": "X-AC-Conn-Token", + "in": "header" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectionMessageRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectionMessageResponse" + } + } + } + }, + "400": { + "description": "User error" + }, + "500": { + "description": "Internal error" + } + } + } + } + } +} \ No newline at end of file diff --git a/docs/snippets/landing-snippets.mdx b/docs/snippets/landing-snippets.mdx index af4ea71ea..e073499bf 100644 --- a/docs/snippets/landing-snippets.mdx +++ b/docs/snippets/landing-snippets.mdx @@ -91,7 +91,7 @@ import RateReact from "/snippets/examples/rate-react.mdx";
State
JavaScript
-
SQLiteAvailable In April
+
SQLiteAvailable In June
diff --git a/docs/snippets/landing-tech.mdx b/docs/snippets/landing-tech.mdx index 382202d67..f91f1a290 100644 --- a/docs/snippets/landing-tech.mdx +++ b/docs/snippets/landing-tech.mdx @@ -161,7 +161,7 @@ LiveStore LiveStore - Available In May + Available In June ZeroSync diff --git a/examples/chat-room/scripts/cli.ts b/examples/chat-room/scripts/cli.ts index 954dcd343..3d5f1278d 100644 --- a/examples/chat-room/scripts/cli.ts +++ b/examples/chat-room/scripts/cli.ts @@ -10,9 +10,9 @@ async function main() { // connect to chat room - now accessed via property // can still pass parameters like room - const chatRoom = await client.chatRoom.connect(room, { + const chatRoom = client.chatRoom.get(room, { params: { room }, - }); + }).connect(); // fetch history const history = await chatRoom.getHistory(); diff --git a/examples/chat-room/scripts/connect.ts b/examples/chat-room/scripts/connect.ts index 59378aef2..967ff84e2 100644 --- a/examples/chat-room/scripts/connect.ts +++ b/examples/chat-room/scripts/connect.ts @@ -7,7 +7,7 @@ async function main() { const client = createClient(process.env.ENDPOINT ?? "http://localhost:6420"); // connect to chat room - now accessed via property - const chatRoom = await client.chatRoom.connect(); + const chatRoom = client.chatRoom.get().connect(); // call action to get existing messages const messages = await chatRoom.getHistory(); diff --git a/examples/chat-room/tests/chat-room.test.ts b/examples/chat-room/tests/chat-room.test.ts index 527e9c223..791ceffbb 100644 --- a/examples/chat-room/tests/chat-room.test.ts +++ b/examples/chat-room/tests/chat-room.test.ts @@ -6,7 +6,7 @@ test("chat room should handle messages", async (test) => { const { client } = await setupTest(test, app); // Connect to chat room - const chatRoom = await client.chatRoom.connect(); + const chatRoom = client.chatRoom.get().connect(); // Initial history should be empty const initialMessages = await chatRoom.getHistory(); diff --git a/examples/counter/scripts/connect.ts b/examples/counter/scripts/connect.ts index 0e76fb603..b4f83b252 100644 --- a/examples/counter/scripts/connect.ts +++ b/examples/counter/scripts/connect.ts @@ -5,7 +5,7 @@ import type { App } from "../actors/app"; async function main() { const client = createClient(process.env.ENDPOINT ?? "http://localhost:6420"); - const counter = await client.counter.connect() + const counter = client.counter.connect() counter.on("newCount", (count: number) => console.log("Event:", count)); diff --git a/examples/counter/tests/counter.test.ts b/examples/counter/tests/counter.test.ts index 26259b9aa..b0ee8c2f1 100644 --- a/examples/counter/tests/counter.test.ts +++ b/examples/counter/tests/counter.test.ts @@ -4,7 +4,7 @@ import { app } from "../actors/app"; test("it should count", async (test) => { const { client } = await setupTest(test, app); - const counter = await client.counter.connect(); + const counter = client.counter.get().connect(); // Test initial count expect(await counter.getCount()).toBe(0); diff --git a/examples/linear-coding-agent/src/server/index.ts b/examples/linear-coding-agent/src/server/index.ts index 75fc02bb4..f3dcd45bf 100644 --- a/examples/linear-coding-agent/src/server/index.ts +++ b/examples/linear-coding-agent/src/server/index.ts @@ -1,10 +1,10 @@ -import { Hono } from 'hono'; -import { serve } from '@hono/node-server'; -import dotenv from 'dotenv'; -import { createClient } from 'actor-core/client'; -import { app } from '../actors/app'; -import type { App } from '../actors/app'; -import type { LinearWebhookEvent } from '../types'; +import { Hono } from "hono"; +import { serve } from "@hono/node-server"; +import dotenv from "dotenv"; +import { createClient } from "actor-core/client"; +import { app } from "../actors/app"; +import type { App } from "../actors/app"; +import type { LinearWebhookEvent } from "../types"; // Load environment variables dotenv.config(); @@ -14,162 +14,208 @@ const server = new Hono(); const PORT = process.env.PORT || 8080; // Create actor client -const ACTOR_SERVER_URL = process.env.ACTOR_SERVER_URL || "http://localhost:6420"; +const ACTOR_SERVER_URL = + process.env.ACTOR_SERVER_URL || "http://localhost:6420"; const client = createClient(ACTOR_SERVER_URL); // Middleware to initialize agent -server.use('*', async (c, next) => { - try { - // Initialize any new actor instances with repository settings - await next(); - } catch (error) { - console.error('[SERVER] Error in middleware:', error); - return c.json( - { - status: 'error', - statusEmoji: '❌', - message: error instanceof Error ? error.message : 'Unknown error' - }, - 500 - ); - } +server.use("*", async (c, next) => { + try { + // Initialize any new actor instances with repository settings + await next(); + } catch (error) { + console.error("[SERVER] Error in middleware:", error); + return c.json( + { + status: "error", + statusEmoji: "❌", + message: error instanceof Error ? error.message : "Unknown error", + }, + 500, + ); + } }); // Route for Linear webhooks -server.post('/api/webhook/linear', async (c) => { - try { - // Get raw payload for signature verification - const rawBody = await c.req.text(); - - // Verify webhook signature - const signature = c.req.header('linear-signature'); - const webhookSecret = process.env.LINEAR_WEBHOOK_SECRET; - - if (webhookSecret) { - // Only verify if webhook secret is configured - const crypto = await import('crypto'); - const computedSignature = crypto.createHmac('sha256', webhookSecret) - .update(rawBody) - .digest('hex'); - - if (signature !== computedSignature) { - console.error('[SERVER] Invalid webhook signature'); - return c.json({ status: 'error', statusEmoji: '❌', message: 'Invalid webhook signature' }, 401); - } - } else { - console.warn('[SERVER] LINEAR_WEBHOOK_SECRET not configured, skipping signature verification'); - } - - // Parse the webhook payload - const event = JSON.parse(rawBody) as LinearWebhookEvent; - - console.log(`[SERVER] Received Linear webhook: ${event.type} - ${event.action}`); - - // Determine the issue ID to use as a tag for the actor - const issueId = event.data.issue?.id ?? event.data.id; - if (!issueId) { - console.error('[SERVER] No issue ID found in webhook event'); - return c.json({ status: 'error', statusEmoji: '❌', message: 'No issue ID found in webhook event' }, 400); - } - - // Create or get a coding agent instance with the issue ID as a key - // This ensures each issue gets its own actor instance - console.log(`[SERVER] Getting actor for issue: ${issueId}`); - const actorClient = await client.codingAgent.connect(issueId); - - // Initialize the agent if needed - console.log(`[SERVER] Initializing actor for issue: ${issueId}`); - await actorClient.initialize(); - - // Determine which handler to use based on the event type and action - if (event.type === 'Issue' && event.action === 'create') { - // Handle new issue creation - console.log(`[SERVER] Processing issue creation: ${issueId} - ${event.data.title}`); - const result = await actorClient.issueCreated(event); - return c.json({ - status: 'success', - message: result.message || 'Issue creation event queued for processing', - requestId: result.requestId - }); - } - else if (event.type === 'Comment' && event.action === 'create') { - // Handle new comment with enhanced logging - console.log(`[SERVER] Processing comment creation on issue: ${issueId}`); - console.log(`[SERVER] Comment details: ID=${event.data.id}, Body="${event.data.body?.substring(0, 100)}${event.data.body && event.data.body.length > 100 ? '...' : ''}", UserIsBot=${event.data.user?.isBot}`); - - // Early detection of bot comments to avoid unnecessary processing - if (event.data.user?.isBot) { - console.log(`[SERVER] Skipping comment from bot user - preventing feedback loop`); - return c.json({ - status: 'skipped', - message: 'Comment skipped - from bot user', - statusEmoji: '⏭️' - }); - } - - // Check for bot emojis at the start of comment - if (event.data.body && ( - event.data.body.startsWith('✅') || - event.data.body.startsWith('❌') || - event.data.body.startsWith('🤖'))) { - console.log(`[SERVER] Skipping comment with bot emoji: "${event.data.body?.substring(0, 20)}..."`); - return c.json({ - status: 'skipped', - message: 'Comment skipped - contains bot emoji', - statusEmoji: '⏭️' - }); - } - - const result = await actorClient.commentCreated(event); - console.log(`[SERVER] Comment sent to actor for processing, requestId: ${result.requestId}`); - - return c.json({ - status: 'success', - message: result.message || 'Comment creation event queued for processing', - requestId: result.requestId - }); - } - else if (event.type === 'Issue' && event.action === 'update') { - // Handle issue updates (status changes) - console.log(`[SERVER] Processing issue update: ${issueId} - New state: ${event.data.state?.name}`); - const result = await actorClient.issueUpdated(event); - return c.json({ - status: 'success', - message: result.message || 'Issue update event queued for processing', - requestId: result.requestId - }); - } - else { - // Unhandled event type - console.log(`[SERVER] Unhandled event type: ${event.type} - ${event.action}`); - return c.json({ status: 'skipped', statusEmoji: '⏭️', message: 'Event type not handled' }); - } - } catch (error) { - console.error('[SERVER] Error processing webhook:', error); - return c.json( - { - status: 'error', - statusEmoji: '❌', - message: error instanceof Error ? error.message : 'Unknown error' - }, - 500 - ); - } +server.post("/api/webhook/linear", async (c) => { + try { + // Get raw payload for signature verification + const rawBody = await c.req.text(); + + // Verify webhook signature + const signature = c.req.header("linear-signature"); + const webhookSecret = process.env.LINEAR_WEBHOOK_SECRET; + + if (webhookSecret) { + // Only verify if webhook secret is configured + const crypto = await import("crypto"); + const computedSignature = crypto + .createHmac("sha256", webhookSecret) + .update(rawBody) + .digest("hex"); + + if (signature !== computedSignature) { + console.error("[SERVER] Invalid webhook signature"); + return c.json( + { + status: "error", + statusEmoji: "❌", + message: "Invalid webhook signature", + }, + 401, + ); + } + } else { + console.warn( + "[SERVER] LINEAR_WEBHOOK_SECRET not configured, skipping signature verification", + ); + } + + // Parse the webhook payload + const event = JSON.parse(rawBody) as LinearWebhookEvent; + + console.log( + `[SERVER] Received Linear webhook: ${event.type} - ${event.action}`, + ); + + // Determine the issue ID to use as a tag for the actor + const issueId = event.data.issue?.id ?? event.data.id; + if (!issueId) { + console.error("[SERVER] No issue ID found in webhook event"); + return c.json( + { + status: "error", + statusEmoji: "❌", + message: "No issue ID found in webhook event", + }, + 400, + ); + } + + // Create or get a coding agent instance with the issue ID as a key + // This ensures each issue gets its own actor instance + console.log(`[SERVER] Getting actor for issue: ${issueId}`); + const actorClient = client.codingAgent.get(issueId).connect(); + + // Initialize the agent if needed + console.log(`[SERVER] Initializing actor for issue: ${issueId}`); + await actorClient.initialize(); + + // Determine which handler to use based on the event type and action + if (event.type === "Issue" && event.action === "create") { + // Handle new issue creation + console.log( + `[SERVER] Processing issue creation: ${issueId} - ${event.data.title}`, + ); + const result = await actorClient.issueCreated(event); + return c.json({ + status: "success", + message: result.message || "Issue creation event queued for processing", + requestId: result.requestId, + }); + } else if (event.type === "Comment" && event.action === "create") { + // Handle new comment with enhanced logging + console.log(`[SERVER] Processing comment creation on issue: ${issueId}`); + console.log( + `[SERVER] Comment details: ID=${event.data.id}, Body="${event.data.body?.substring(0, 100)}${event.data.body && event.data.body.length > 100 ? "..." : ""}", UserIsBot=${event.data.user?.isBot}`, + ); + + // Early detection of bot comments to avoid unnecessary processing + if (event.data.user?.isBot) { + console.log( + `[SERVER] Skipping comment from bot user - preventing feedback loop`, + ); + return c.json({ + status: "skipped", + message: "Comment skipped - from bot user", + statusEmoji: "⏭", + }); + } + + // Check for bot emojis at the start of comment + if ( + event.data.body && + (event.data.body.startsWith("✅") || + event.data.body.startsWith("❌") || + event.data.body.startsWith("🤖")) + ) { + console.log( + `[SERVER] Skipping comment with bot emoji: "${event.data.body?.substring(0, 20)}..."`, + ); + return c.json({ + status: "skipped", + message: "Comment skipped - contains bot emoji", + statusEmoji: "⏭", + }); + } + + const result = await actorClient.commentCreated(event); + console.log( + `[SERVER] Comment sent to actor for processing, requestId: ${result.requestId}`, + ); + + return c.json({ + status: "success", + message: + result.message || "Comment creation event queued for processing", + requestId: result.requestId, + }); + } else if (event.type === "Issue" && event.action === "update") { + // Handle issue updates (status changes) + console.log( + `[SERVER] Processing issue update: ${issueId} - New state: ${event.data.state?.name}`, + ); + const result = await actorClient.issueUpdated(event); + return c.json({ + status: "success", + message: result.message || "Issue update event queued for processing", + requestId: result.requestId, + }); + } else { + // Unhandled event type + console.log( + `[SERVER] Unhandled event type: ${event.type} - ${event.action}`, + ); + return c.json({ + status: "skipped", + statusEmoji: "⏭", + message: "Event type not handled", + }); + } + } catch (error) { + console.error("[SERVER] Error processing webhook:", error); + return c.json( + { + status: "error", + statusEmoji: "❌", + message: error instanceof Error ? error.message : "Unknown error", + }, + 500, + ); + } }); // Health check endpoint -server.get('/health', (c) => { - console.log('[SERVER] Health check requested'); - return c.json({ status: 'ok', statusEmoji: '✅', message: 'Service is healthy' }); +server.get("/health", (c) => { + console.log("[SERVER] Health check requested"); + return c.json({ + status: "ok", + statusEmoji: "✅", + message: "Service is healthy", + }); }); - // Start the server console.log(`[SERVER] Starting server on port ${PORT}...`); -serve({ - fetch: server.fetch, - port: Number(PORT), -}, (info) => { - console.log(`[SERVER] Running on port ${info.port}`); - console.log(`[SERVER] Linear webhook URL: http://localhost:${info.port}/api/webhook/linear`); -}); +serve( + { + fetch: server.fetch, + port: Number(PORT), + }, + (info) => { + console.log(`[SERVER] Running on port ${info.port}`); + console.log( + `[SERVER] Linear webhook URL: http://localhost:${info.port}/api/webhook/linear`, + ); + }, +); diff --git a/examples/resend-streaks/tests/user.test.ts b/examples/resend-streaks/tests/user.test.ts index 12b945404..ce9cc2f84 100644 --- a/examples/resend-streaks/tests/user.test.ts +++ b/examples/resend-streaks/tests/user.test.ts @@ -26,7 +26,7 @@ beforeEach(() => { test("streak tracking with time zone signups", async (t) => { const { client } = await setupTest(t, app); - const actor = await client.user.connect(); + const actor = client.user.get().connect(); // Sign up with specific time zone const signupResult = await actor.completeSignUp( diff --git a/packages/actor-core-cli/package.json b/packages/actor-core-cli/package.json index dc64232bd..00af27cb1 100644 --- a/packages/actor-core-cli/package.json +++ b/packages/actor-core-cli/package.json @@ -38,6 +38,7 @@ "bundle-require": "^5.1.0", "chokidar": "^4.0.3", "esbuild": "^0.25.1", + "invariant": "^2.2.4", "open": "^10.1.0", "yoga-wasm-web": "0.3.3" }, @@ -46,6 +47,7 @@ "@rivet-gg/api": "^24.6.2", "@sentry/esbuild-plugin": "^3.2.0", "@sentry/node": "^9.3.0", + "@types/invariant": "^2", "@types/micromatch": "^4", "@types/react": "^18.3", "@types/semver": "^7.5.8", diff --git a/packages/actor-core-cli/src/cli.ts b/packages/actor-core-cli/src/cli.ts index ff11779e3..9284e4294 100644 --- a/packages/actor-core-cli/src/cli.ts +++ b/packages/actor-core-cli/src/cli.ts @@ -1,5 +1,5 @@ import { PACKAGE_JSON } from "./macros" with { type: "macro" }; -import { create, deploy, dev, program } from "./mod"; +import { create, deploy, dev, endpoint, program } from "./mod"; export default program .name(PACKAGE_JSON.name) @@ -8,4 +8,5 @@ export default program .addCommand(deploy) .addCommand(create) .addCommand(dev) + .addCommand(endpoint) .parse(); diff --git a/packages/actor-core-cli/src/commands/deploy.tsx b/packages/actor-core-cli/src/commands/deploy.tsx index 156051ea2..cb7265bb8 100644 --- a/packages/actor-core-cli/src/commands/deploy.tsx +++ b/packages/actor-core-cli/src/commands/deploy.tsx @@ -57,10 +57,10 @@ export const deploy = new Command() await workflow( "Deploy actors to Rivet", - async function* (ctx) { + async function*(ctx) { const { config, cli } = yield* ctx.task( "Prepare", - async function* (ctx) { + async function*(ctx) { const config = yield* validateConfigTask(ctx, cwd, appPath); const cli = yield* ctx.task("Locale rivet-cli", async (ctx) => { @@ -96,7 +96,7 @@ export const deploy = new Command() ); const { accessToken, projectName, envName, endpoint } = - yield* ctx.task("Auth with Rivet", async function* (ctx) { + yield* ctx.task("Auth with Rivet", async function*(ctx) { const { stdout } = await exec`${cli} metadata auth-status`; const isLogged = stdout === "true"; @@ -164,7 +164,7 @@ export const deploy = new Command() if (!opts.skipManager) { manager = yield* ctx.task( "Deploy ActorCore", - async function* (ctx) { + async function*(ctx) { yield fs.mkdir(path.join(cwd, ".actorcore"), { recursive: true, }); @@ -185,7 +185,7 @@ export const deploy = new Command() ); const output = - await exec`${cli} publish manager --env ${envName} --tags access=private ${entrypoint}`; + await exec`${cli} publish manager --env ${envName} --tags role=manager,framework=actor-core,framework-version=${VERSION} --unstable-minify false ${entrypoint}`; if (output.exitCode !== 0) { throw ctx.error("Failed to deploy ActorCore.", { hint: "Check the logs above for more information.", @@ -257,11 +257,32 @@ export const deploy = new Command() environment: envName, body: { region: region.id, - tags: { name: "manager", owner: "rivet" }, - buildTags: { name: "manager", current: "true" }, + tags: { + name: "manager", + role: "manager", + framework: "actor-core", + }, + buildTags: { + name: "manager", + role: "manager", + framework: "actor-core", + current: "true", + }, runtime: { environment: { RIVET_SERVICE_TOKEN: serviceToken, + ...(process.env._RIVET_MANAGER_LOG_LEVEL + ? { + _LOG_LEVEL: + process.env._RIVET_MANAGER_LOG_LEVEL, + } + : {}), + ...(process.env._RIVET_ACTOR_LOG_LEVEL + ? { + _ACTOR_LOG_LEVEL: + process.env._RIVET_ACTOR_LOG_LEVEL, + } + : {}), }, }, network: { @@ -290,10 +311,9 @@ export const deploy = new Command() config.app.config.actors, ).entries()) { yield* ctx.task( - `Deploy & upload "${actorName}" build (${idx + 1}/${ - Object.keys(config.app.config.actors).length + `Deploy & upload "${actorName}" build (${idx + 1}/${Object.keys(config.app.config.actors).length })`, - async function* (ctx) { + async function*(ctx) { yield fs.mkdir(path.join(cwd, ".actorcore"), { recursive: true, }); @@ -317,18 +337,18 @@ export const deploy = new Command() `, ); - const actorTags = { - access: "public", + const buildTags = { + role: "actor", framework: "actor-core", "framework-version": VERSION, }; - const tagsArray = Object.entries(actorTags) + const tagsArray = Object.entries(buildTags) .map(([key, value]) => `${key}=${value}`) .join(","); const output = - await exec`${cli} publish --env=${envName} --tags=${tagsArray} ${actorName} ${entrypoint}`; + await exec`${cli} publish --env=${envName} --tags=${tagsArray} --unstable-minify false ${actorName} ${entrypoint}`; if (output.exitCode !== 0) { throw ctx.error("Failed to deploy & upload actors.", { diff --git a/packages/actor-core-cli/src/commands/endpoint.tsx b/packages/actor-core-cli/src/commands/endpoint.tsx new file mode 100644 index 000000000..970e77ca5 --- /dev/null +++ b/packages/actor-core-cli/src/commands/endpoint.tsx @@ -0,0 +1,177 @@ +import * as fs from "node:fs/promises"; +import path from "node:path"; +import { Argument, Command, Option } from "commander"; +import { $ } from "execa"; +import semver from "semver"; +import which from "which"; +import { MIN_RIVET_CLI_VERSION } from "../constants"; +import { workflow } from "../workflow"; +import { RivetClient } from "@rivet-gg/api"; +import { + createActorEndpoint, + createRivetApi, + getServiceToken, +} from "../utils/rivet-api"; +import { validateConfigTask } from "../workflows/validate-config"; +import invariant from "invariant"; + +export const endpoint = new Command() + .name("endpoint") + .description( + "Get the application endpoint URL for your deployed application in Rivet.", + ) + .addArgument( + new Argument("", "The platform to get the endpoint for").choices([ + "rivet", + ]), + ) + .addOption( + new Option( + "-e, --env [env]", + "Specify environment to get the endpoint for", + ), + ) + .addOption( + new Option("--plain", "Output only the URL without any additional text"), + ) + // No actor option needed - returns the first available endpoint + .action( + async ( + platform, + opts: { + env?: string; + plain?: boolean; + }, + ) => { + const cwd = process.cwd(); + + const exec = $({ + cwd, + env: { ...process.env, npm_config_yes: "true" }, + }); + + await workflow( + "Get actor endpoint", + async function*(ctx) { + const cli = yield* ctx.task("Locate rivet-cli", async (ctx) => { + let cliLocation = process.env.RIVET_CLI_PATH || null; + + if (!cliLocation) { + cliLocation = await which("rivet-cli", { nothrow: true }); + } + + if (!cliLocation) { + cliLocation = await which("rivet", { nothrow: true }); + } + + if (cliLocation) { + // check version + const { stdout } = await exec`${cliLocation} --version`; + const semVersion = semver.coerce( + stdout.split("\n")[2].split(" ")[1].trim(), + ); + + if (semVersion) { + if (semver.gte(semVersion, MIN_RIVET_CLI_VERSION)) { + return cliLocation; + } + } + } + + return ["npx", "@rivet-gg/cli@latest"]; + }); + + const { accessToken, projectName, envName, endpoint } = + yield* ctx.task("Auth with Rivet", async function*(ctx) { + const { stdout } = await exec`${cli} metadata auth-status`; + const isLogged = stdout === "true"; + + let endpoint: string | undefined; + if (!isLogged) { + const isUsingCloud = yield* ctx.prompt( + "Are you using Rivet Cloud?", + { + type: "confirm", + }, + ); + + endpoint = "https://api.rivet.gg"; + if (!isUsingCloud) { + endpoint = yield* ctx.prompt("What is the API endpoint?", { + type: "text", + defaultValue: "http://localhost:8080", + }); + } + + await exec`${cli} login --api-endpoint=${endpoint}`; + } else { + const { stdout } = await exec`${cli} metadata api-endpoint`; + endpoint = stdout; + } + + const { stdout: accessToken } = + await exec`${cli} metadata access-token`; + + const { stdout: rawEnvs } = await exec`${cli} env ls --json`; + const envs = JSON.parse(rawEnvs); + + const envName = + opts.env ?? + (yield* ctx.prompt("Select environment", { + type: "select", + choices: envs.map( + (env: { display_name: string; name_id: string }) => ({ + label: env.display_name, + value: env.name_id, + }), + ), + })); + + const { stdout: projectName } = + await exec`${cli} metadata project-name-id`; + + return { accessToken, projectName, envName, endpoint }; + }); + + const rivet = new RivetClient({ + token: accessToken, + environment: endpoint, + }); + + yield* ctx.task("Get actor endpoint", async function*(ctx) { + const { actors } = await rivet.actor.list({ + environment: envName, + project: projectName, + includeDestroyed: false, + tagsJson: JSON.stringify({ + name: "manager", + role: "manager", + framework: "actor-core", + }), + }); + + if (actors.length === 0) { + throw ctx.error("No managers found for this project.", { + hint: "Make sure you have deployed first.", + }); + } + + const managerActor = actors[0]; + const port = managerActor.network.ports.http; + invariant(port, "http port does not exist on manager"); + invariant(port.url, "port has no url"); + + if (opts.plain) { + console.log(port.url); + } else { + yield ctx.log(`Application endpoint: ${port.url}`); + } + }); + }, + { + showLabel: !opts.plain, + quiet: opts.plain, + }, + ).render(); + }, + ); diff --git a/packages/actor-core-cli/src/mod.ts b/packages/actor-core-cli/src/mod.ts index dc61d70cb..9e3b0f8ad 100644 --- a/packages/actor-core-cli/src/mod.ts +++ b/packages/actor-core-cli/src/mod.ts @@ -2,5 +2,6 @@ import "./instrument"; export { deploy } from "./commands/deploy"; export { create, action as createAction } from "./commands/create"; export { dev } from "./commands/dev"; +export { endpoint } from "./commands/endpoint"; export { program } from "commander"; export default {}; diff --git a/packages/actor-core-cli/src/utils/rivet-api.ts b/packages/actor-core-cli/src/utils/rivet-api.ts index 76e8a3e2f..eeed91d2d 100644 --- a/packages/actor-core-cli/src/utils/rivet-api.ts +++ b/packages/actor-core-cli/src/utils/rivet-api.ts @@ -1,4 +1,5 @@ import { z, type ZodTypeAny } from "zod"; +import { httpUserAgent } from "actor-core/utils"; export async function getServiceToken( api: ReturnType, @@ -75,6 +76,7 @@ export function createRivetApi(endpoint: string, accessToken: string) { headers: { ...opts.headers, "Content-Type": "application/json", + "User-Agent": httpUserAgent(), Authorization: `Bearer ${accessToken}`, }, }); diff --git a/packages/actor-core-cli/src/workflow.tsx b/packages/actor-core-cli/src/workflow.tsx index f402bc08e..74be8f42d 100644 --- a/packages/actor-core-cli/src/workflow.tsx +++ b/packages/actor-core-cli/src/workflow.tsx @@ -216,6 +216,7 @@ export interface Context { interface TaskOptions { showLabel?: boolean; success?: ReactNode; + quiet?: boolean; } interface RunnerToolbox { diff --git a/packages/actor-core/README.md b/packages/actor-core/README.md index cbe39344d..252450cb7 100644 --- a/packages/actor-core/README.md +++ b/packages/actor-core/README.md @@ -12,77 +12,6 @@ Supports Rivet, Cloudflare Workers, Bun, and Node.js. - [Documentation](https://actorcore.org/) - [Examples](https://github.com/rivet-gg/actor-core/tree/main/examples) -## Getting Started - -### Step 1: Installation - -```bash -# npm -npm add actor-core - -# pnpm -pnpm add actor-core - -# Yarn -yarn add actor-core - -# Bun -bun add actor-core -``` - -### Step 2: Create an Actor - -```typescript -import { Actor, type Rpc } from "actor-core"; - -export interface State { - messages: { username: string; message: string }[]; -} - -export default class ChatRoom extends Actor { - // initialize this._state - _onInitialize() { - return { messages: [] }; - } - - // receive an remote procedure call from the client - sendMessage(rpc: Rpc, username: string, message: string) { - // save message to persistent storage - this._state.messages.push({ username, message }); - - // broadcast message to all clients - this._broadcast("newMessage", username, message); - } -} -``` - -### Step 3: Connect to Actor - -```typescript -import { Client } from "actor-core/client"; -import type ChatRoom from "../src/chat-room.ts"; - -const client = new Client(/* manager endpoint */); - -// connect to chat room -const chatRoom = await client.get({ name: "chat" }); - -// listen for new messages -chatRoom.on("newMessage", (username: string, message: string) => - console.log(`Message from ${username}: ${message}`), -); - -// send message to room -await chatRoom.sendMessage("william", "All the world's a stage."); -``` - -### Step 4: Deploy - -Deploy to your platform of choice: - -- [Cloudflare Workers](https://actorcore.org/platforms/cloudflare-workers) -- [Rivet](https://actorcore.org/platforms/rivet) - ## Community & Support - Join our [Discord](https://rivet.gg/discord) diff --git a/packages/actor-core/fixtures/driver-test-suite/action-timeout.ts b/packages/actor-core/fixtures/driver-test-suite/action-timeout.ts new file mode 100644 index 000000000..3ec07b91d --- /dev/null +++ b/packages/actor-core/fixtures/driver-test-suite/action-timeout.ts @@ -0,0 +1,76 @@ +import { actor, setup } from "actor-core"; + +// Short timeout actor +const shortTimeoutActor = actor({ + state: { value: 0 }, + options: { + action: { + timeout: 50, // 50ms timeout + }, + }, + actions: { + quickAction: async (c) => { + return "quick response"; + }, + slowAction: async (c) => { + // This action should timeout + await new Promise((resolve) => setTimeout(resolve, 100)); + return "slow response"; + }, + }, +}); + +// Long timeout actor +const longTimeoutActor = actor({ + state: { value: 0 }, + options: { + action: { + timeout: 200, // 200ms timeout + }, + }, + actions: { + delayedAction: async (c) => { + // This action should complete within timeout + await new Promise((resolve) => setTimeout(resolve, 100)); + return "delayed response"; + }, + }, +}); + +// Default timeout actor +const defaultTimeoutActor = actor({ + state: { value: 0 }, + actions: { + normalAction: async (c) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return "normal response"; + }, + }, +}); + +// Sync actor (timeout shouldn't apply) +const syncActor = actor({ + state: { value: 0 }, + options: { + action: { + timeout: 50, // 50ms timeout + }, + }, + actions: { + syncAction: (c) => { + return "sync response"; + }, + }, +}); + +export const app = setup({ + actors: { + shortTimeoutActor, + longTimeoutActor, + defaultTimeoutActor, + syncActor, + }, +}); + +export type App = typeof app; + diff --git a/packages/actor-core/fixtures/driver-test-suite/action-types.ts b/packages/actor-core/fixtures/driver-test-suite/action-types.ts new file mode 100644 index 000000000..cc4af7d15 --- /dev/null +++ b/packages/actor-core/fixtures/driver-test-suite/action-types.ts @@ -0,0 +1,92 @@ +import { actor, setup, UserError } from "actor-core"; + +// Actor with synchronous actions +const syncActor = actor({ + state: { value: 0 }, + actions: { + // Simple synchronous action that returns a value directly + increment: (c, amount: number = 1) => { + c.state.value += amount; + return c.state.value; + }, + // Synchronous action that returns an object + getInfo: (c) => { + return { + currentValue: c.state.value, + timestamp: Date.now(), + }; + }, + // Synchronous action with no return value (void) + reset: (c) => { + c.state.value = 0; + }, + }, +}); + +// Actor with asynchronous actions +const asyncActor = actor({ + state: { value: 0, data: null as any }, + actions: { + // Async action with a delay + delayedIncrement: async (c, amount: number = 1) => { + await Promise.resolve(); + c.state.value += amount; + return c.state.value; + }, + // Async action that simulates an API call + fetchData: async (c, id: string) => { + await Promise.resolve(); + + // Simulate response data + const data = { id, timestamp: Date.now() }; + c.state.data = data; + return data; + }, + // Async action with error handling + asyncWithError: async (c, shouldError: boolean) => { + await Promise.resolve(); + + if (shouldError) { + throw new UserError("Intentional error"); + } + + return "Success"; + }, + }, +}); + +// Actor with promise actions +const promiseActor = actor({ + state: { results: [] as string[] }, + actions: { + // Action that returns a resolved promise + resolvedPromise: (c) => { + return Promise.resolve("resolved value"); + }, + // Action that returns a promise that resolves after a delay + delayedPromise: (c): Promise => { + return new Promise((resolve) => { + c.state.results.push("delayed"); + resolve("delayed value"); + }); + }, + // Action that returns a rejected promise + rejectedPromise: (c) => { + return Promise.reject(new UserError("promised rejection")); + }, + // Action to check the collected results + getResults: (c) => { + return c.state.results; + }, + }, +}); + +export const app = setup({ + actors: { + syncActor, + asyncActor, + promiseActor, + }, +}); + +export type App = typeof app; diff --git a/packages/actor-core/fixtures/driver-test-suite/conn-params.ts b/packages/actor-core/fixtures/driver-test-suite/conn-params.ts new file mode 100644 index 000000000..06de727ff --- /dev/null +++ b/packages/actor-core/fixtures/driver-test-suite/conn-params.ts @@ -0,0 +1,33 @@ +import { actor, setup } from "actor-core"; + +const counterWithParams = actor({ + state: { count: 0, initializers: [] as string[] }, + createConnState: (c, { params }: { params: { name?: string } }) => { + return { + name: params?.name || "anonymous", + }; + }, + onConnect: (c, conn) => { + // Record connection name + c.state.initializers.push(conn.state.name); + }, + actions: { + increment: (c, x: number) => { + c.state.count += x; + c.broadcast("newCount", { + count: c.state.count, + by: c.conn.state.name, + }); + return c.state.count; + }, + getInitializers: (c) => { + return c.state.initializers; + }, + }, +}); + +export const app = setup({ + actors: { counter: counterWithParams }, +}); + +export type App = typeof app; diff --git a/packages/actor-core/fixtures/driver-test-suite/conn-state.ts b/packages/actor-core/fixtures/driver-test-suite/conn-state.ts new file mode 100644 index 000000000..2ae6fc289 --- /dev/null +++ b/packages/actor-core/fixtures/driver-test-suite/conn-state.ts @@ -0,0 +1,101 @@ +import { actor, setup } from "actor-core"; + +type ConnState = { + username: string; + role: string; + counter: number; + createdAt: number; +}; + +const connStateActor = actor({ + state: { + sharedCounter: 0, + disconnectionCount: 0, + }, + // Define connection state + createConnState: ( + c, + { params }: { params?: { username?: string; role?: string } }, + ): ConnState => { + return { + username: params?.username || "anonymous", + role: params?.role || "user", + counter: 0, + createdAt: Date.now(), + }; + }, + // Lifecycle hook when a connection is established + onConnect: (c, conn) => { + // Broadcast event about the new connection + c.broadcast("userConnected", { + id: conn.id, + username: "anonymous", + role: "user", + }); + }, + // Lifecycle hook when a connection is closed + onDisconnect: (c, conn) => { + c.state.disconnectionCount += 1; + c.broadcast("userDisconnected", { + id: conn.id, + }); + }, + actions: { + // Action to increment the connection's counter + incrementConnCounter: (c, amount: number = 1) => { + c.conn.state.counter += amount; + }, + + // Action to increment the shared counter + incrementSharedCounter: (c, amount: number = 1) => { + c.state.sharedCounter += amount; + return c.state.sharedCounter; + }, + + // Get the connection state + getConnectionState: (c) => { + return { id: c.conn.id, ...c.conn.state }; + }, + + // Check all active connections + getConnectionIds: (c) => { + return c.conns.keys().toArray(); + }, + + // Get disconnection count + getDisconnectionCount: (c) => { + return c.state.disconnectionCount; + }, + + // Get all active connection states + getAllConnectionStates: (c) => { + return c.conns.entries().map(([id, conn]) => ({ id, ...conn.state })).toArray(); + }, + + // Send message to a specific connection with matching ID + sendToConnection: (c, targetId: string, message: string) => { + if (c.conns.has(targetId)) { + c.conns.get(targetId)!.send("directMessage", { from: c.conn.id, message }); + return true; + } else { + return false; + } + }, + + // Update connection state (simulated for tests) + updateConnection: ( + c, + updates: Partial<{ username: string; role: string }>, + ) => { + if (updates.username) c.conn.state.username = updates.username; + if (updates.role) c.conn.state.role = updates.role; + return c.conn.state; + }, + }, +}); + +export const app = setup({ + actors: { connStateActor }, +}); + +export type App = typeof app; diff --git a/packages/misc/driver-test-suite/fixtures/apps/counter.ts b/packages/actor-core/fixtures/driver-test-suite/counter.ts similarity index 86% rename from packages/misc/driver-test-suite/fixtures/apps/counter.ts rename to packages/actor-core/fixtures/driver-test-suite/counter.ts index 59a418315..49dc5a07e 100644 --- a/packages/misc/driver-test-suite/fixtures/apps/counter.ts +++ b/packages/actor-core/fixtures/driver-test-suite/counter.ts @@ -8,6 +8,9 @@ const counter = actor({ c.broadcast("newCount", c.state.count); return c.state.count; }, + getCount: (c) => { + return c.state.count; + }, }, }); diff --git a/packages/actor-core/fixtures/driver-test-suite/error-handling.ts b/packages/actor-core/fixtures/driver-test-suite/error-handling.ts new file mode 100644 index 000000000..095236cb6 --- /dev/null +++ b/packages/actor-core/fixtures/driver-test-suite/error-handling.ts @@ -0,0 +1,105 @@ +import { actor, setup, UserError } from "actor-core"; + +const errorHandlingActor = actor({ + state: { + errorLog: [] as string[], + }, + actions: { + // Action that throws a UserError with just a message + throwSimpleError: () => { + throw new UserError("Simple error message"); + }, + + // Action that throws a UserError with code and metadata + throwDetailedError: () => { + throw new UserError("Detailed error message", { + code: "detailed_error", + metadata: { + reason: "test", + timestamp: Date.now(), + }, + }); + }, + + // Action that throws an internal error + throwInternalError: () => { + throw new Error("This is an internal error"); + }, + + // Action that returns successfully + successfulAction: () => { + return "success"; + }, + + // Action that times out (simulated with a long delay) + timeoutAction: async (c) => { + // This action should time out if the timeout is configured + return new Promise((resolve) => { + setTimeout(() => { + resolve("This should not be reached if timeout works"); + }, 10000); // 10 seconds + }); + }, + + // Action with configurable delay to test timeout edge cases + delayedAction: async (c, delayMs: number) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(`Completed after ${delayMs}ms`); + }, delayMs); + }); + }, + + // Log an error for inspection + logError: (c, error: string) => { + c.state.errorLog.push(error); + return c.state.errorLog; + }, + + // Get the error log + getErrorLog: (c) => { + return c.state.errorLog; + }, + + // Clear the error log + clearErrorLog: (c) => { + c.state.errorLog = []; + return true; + }, + }, + options: { + // Set a short timeout for this actor's actions + action: { + timeout: 500, // 500ms timeout for actions + }, + }, +}); + +// Actor with custom timeout +const customTimeoutActor = actor({ + state: {}, + actions: { + quickAction: async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return "Quick action completed"; + }, + slowAction: async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + return "Slow action completed"; + }, + }, + options: { + action: { + timeout: 200, // 200ms timeout + }, + }, +}); + +export const app = setup({ + actors: { + errorHandlingActor, + customTimeoutActor, + }, +}); + +export type App = typeof app; diff --git a/packages/actor-core/fixtures/driver-test-suite/lifecycle.ts b/packages/actor-core/fixtures/driver-test-suite/lifecycle.ts new file mode 100644 index 000000000..d8e0db20f --- /dev/null +++ b/packages/actor-core/fixtures/driver-test-suite/lifecycle.ts @@ -0,0 +1,41 @@ +import { actor, setup } from "actor-core"; + +const lifecycleActor = actor({ + state: { + count: 0, + events: [] as string[], + }, + createConnState: ( + c, + opts: { params: { trackLifecycle?: boolean } | undefined }, + ) => ({ + joinTime: Date.now(), + }), + onStart: (c) => { + c.state.events.push("onStart"); + }, + onBeforeConnect: (c, conn) => { + if (conn.params?.trackLifecycle) c.state.events.push("onBeforeConnect"); + }, + onConnect: (c, conn) => { + if (conn.params?.trackLifecycle) c.state.events.push("onConnect"); + }, + onDisconnect: (c, conn) => { + if (conn.params?.trackLifecycle) c.state.events.push("onDisconnect"); + }, + actions: { + getEvents: (c) => { + return c.state.events; + }, + increment: (c, x: number) => { + c.state.count += x; + return c.state.count; + }, + }, +}); + +export const app = setup({ + actors: { counter: lifecycleActor }, +}); + +export type App = typeof app; diff --git a/packages/actor-core/fixtures/driver-test-suite/metadata.ts b/packages/actor-core/fixtures/driver-test-suite/metadata.ts new file mode 100644 index 000000000..b9c7930d0 --- /dev/null +++ b/packages/actor-core/fixtures/driver-test-suite/metadata.ts @@ -0,0 +1,82 @@ +import { actor, setup } from "actor-core"; + +// Note: For testing only - metadata API will need to be mocked +// in tests since this is implementation-specific +const metadataActor = actor({ + state: { + lastMetadata: null as any, + actorName: "", + // Store tags and region in state for testing since they may not be + // available in the context in all environments + storedTags: {} as Record, + storedRegion: null as string | null, + }, + onStart: (c) => { + // Store the actor name during initialization + c.state.actorName = c.name; + }, + actions: { + // Set up test tags - this will be called by tests to simulate tags + setupTestTags: (c, tags: Record) => { + c.state.storedTags = tags; + return tags; + }, + + // Set up test region - this will be called by tests to simulate region + setupTestRegion: (c, region: string) => { + c.state.storedRegion = region; + return region; + }, + + // Get all available metadata + getMetadata: (c) => { + // Create metadata object from stored values + const metadata = { + name: c.name, + tags: c.state.storedTags, + region: c.state.storedRegion, + }; + + // Store for later inspection + c.state.lastMetadata = metadata; + return metadata; + }, + + // Get the actor name + getActorName: (c) => { + return c.name; + }, + + // Get a specific tag by key + getTag: (c, key: string) => { + return c.state.storedTags[key] || null; + }, + + // Get all tags + getTags: (c) => { + return c.state.storedTags; + }, + + // Get the region + getRegion: (c) => { + return c.state.storedRegion; + }, + + // Get the stored actor name (from onStart) + getStoredActorName: (c) => { + return c.state.actorName; + }, + + // Get last retrieved metadata + getLastMetadata: (c) => { + return c.state.lastMetadata; + }, + }, +}); + +export const app = setup({ + actors: { metadataActor }, +}); + +export type App = typeof app; + diff --git a/packages/actor-core/fixtures/driver-test-suite/scheduled.ts b/packages/actor-core/fixtures/driver-test-suite/scheduled.ts new file mode 100644 index 000000000..ab979b165 --- /dev/null +++ b/packages/actor-core/fixtures/driver-test-suite/scheduled.ts @@ -0,0 +1,83 @@ +import { actor, setup } from "actor-core"; + +const scheduled = actor({ + state: { + lastRun: 0, + scheduledCount: 0, + taskHistory: [] as string[], + }, + actions: { + // Schedule using 'at' with specific timestamp + scheduleTaskAt: (c, timestamp: number) => { + c.schedule.at(timestamp, "onScheduledTask"); + return timestamp; + }, + + // Schedule using 'after' with delay + scheduleTaskAfter: (c, delayMs: number) => { + c.schedule.after(delayMs, "onScheduledTask"); + return Date.now() + delayMs; + }, + + // Schedule with a task ID for ordering tests + scheduleTaskAfterWithId: (c, taskId: string, delayMs: number) => { + c.schedule.after(delayMs, "onScheduledTaskWithId", taskId); + return { taskId, scheduledFor: Date.now() + delayMs }; + }, + + // Original method for backward compatibility + scheduleTask: (c, delayMs: number) => { + const timestamp = Date.now() + delayMs; + c.schedule.at(timestamp, "onScheduledTask"); + return timestamp; + }, + + // Getters for state + getLastRun: (c) => { + return c.state.lastRun; + }, + + getScheduledCount: (c) => { + return c.state.scheduledCount; + }, + + getTaskHistory: (c) => { + return c.state.taskHistory; + }, + + clearHistory: (c) => { + c.state.taskHistory = []; + c.state.scheduledCount = 0; + c.state.lastRun = 0; + return true; + }, + + // Scheduled task handlers + onScheduledTask: (c) => { + c.state.lastRun = Date.now(); + c.state.scheduledCount++; + c.broadcast("scheduled", { + time: c.state.lastRun, + count: c.state.scheduledCount, + }); + }, + + onScheduledTaskWithId: (c, taskId: string) => { + c.state.lastRun = Date.now(); + c.state.scheduledCount++; + c.state.taskHistory.push(taskId); + c.broadcast("scheduledWithId", { + taskId, + time: c.state.lastRun, + count: c.state.scheduledCount, + }); + }, + }, +}); + +export const app = setup({ + actors: { scheduled }, +}); + +export type App = typeof app; + diff --git a/packages/actor-core/fixtures/driver-test-suite/vars.ts b/packages/actor-core/fixtures/driver-test-suite/vars.ts new file mode 100644 index 000000000..363ee3140 --- /dev/null +++ b/packages/actor-core/fixtures/driver-test-suite/vars.ts @@ -0,0 +1,104 @@ +import { actor, setup } from "actor-core"; + +// Actor with static vars +const staticVarActor = actor({ + state: { value: 0 }, + connState: { hello: "world" }, + vars: { counter: 42, name: "test-actor" }, + actions: { + getVars: (c) => { + return c.vars; + }, + getName: (c) => { + return c.vars.name; + }, + }, +}); + +// Actor with nested vars +const nestedVarActor = actor({ + state: { value: 0 }, + connState: { hello: "world" }, + vars: { + counter: 42, + nested: { + value: "original", + array: [1, 2, 3], + obj: { key: "value" }, + }, + }, + actions: { + getVars: (c) => { + return c.vars; + }, + modifyNested: (c) => { + // Attempt to modify the nested object + c.vars.nested.value = "modified"; + c.vars.nested.array.push(4); + c.vars.nested.obj.key = "new-value"; + return c.vars; + }, + }, +}); + +// Actor with dynamic vars +const dynamicVarActor = actor({ + state: { value: 0 }, + connState: { hello: "world" }, + createVars: () => { + return { + random: Math.random(), + computed: `Actor-${Math.floor(Math.random() * 1000)}`, + }; + }, + actions: { + getVars: (c) => { + return c.vars; + }, + }, +}); + +// Actor with unique vars per instance +const uniqueVarActor = actor({ + state: { value: 0 }, + connState: { hello: "world" }, + createVars: () => { + return { + id: Math.floor(Math.random() * 1000000), + }; + }, + actions: { + getVars: (c) => { + return c.vars; + }, + }, +}); + +// Actor that uses driver context +const driverCtxActor = actor({ + state: { value: 0 }, + connState: { hello: "world" }, + createVars: (c, driverCtx: any) => { + return { + hasDriverCtx: Boolean(driverCtx?.isTest), + }; + }, + actions: { + getVars: (c) => { + return c.vars; + }, + }, +}); + +export const app = setup({ + actors: { + staticVarActor, + nestedVarActor, + dynamicVarActor, + uniqueVarActor, + driverCtxActor, + }, +}); + +export type App = typeof app; + diff --git a/packages/actor-core/package.json b/packages/actor-core/package.json index e23f6ac3e..85bbaa4df 100644 --- a/packages/actor-core/package.json +++ b/packages/actor-core/package.json @@ -72,6 +72,16 @@ "default": "./dist/driver-helpers/mod.cjs" } }, + "./driver-test-suite": { + "import": { + "types": "./dist/driver-test-suite/mod.d.ts", + "default": "./dist/driver-test-suite/mod.js" + }, + "require": { + "types": "./dist/driver-test-suite/mod.d.cts", + "default": "./dist/driver-test-suite/mod.cjs" + } + }, "./topologies/coordinate": { "import": { "types": "./dist/topologies/coordinate/mod.d.ts", @@ -102,16 +112,6 @@ "default": "./dist/cli/mod.cjs" } }, - "./protocol/http": { - "import": { - "types": "./dist/actor/protocol/http/rpc.d.ts", - "default": "./dist/actor/protocol/http/rpc.js" - }, - "require": { - "types": "./dist/actor/protocol/http/rpc.d.cts", - "default": "./dist/actor/protocol/http/rpc.cjs" - } - }, "./test": { "import": { "types": "./dist/test/mod.d.ts", @@ -159,13 +159,15 @@ "sideEffects": false, "scripts": { "dev": "yarn build --watch", - "build": "tsup src/mod.ts src/client/mod.ts src/common/log.ts src/actor/errors.ts src/topologies/coordinate/mod.ts src/topologies/partition/mod.ts src/utils.ts src/driver-helpers/mod.ts src/cli/mod.ts src/actor/protocol/inspector/mod.ts src/actor/protocol/http/rpc.ts src/test/mod.ts src/inspector/protocol/actor/mod.ts src/inspector/protocol/manager/mod.ts src/inspector/mod.ts", + "build": "tsup src/mod.ts src/client/mod.ts src/common/log.ts src/actor/errors.ts src/topologies/coordinate/mod.ts src/topologies/partition/mod.ts src/utils.ts src/driver-helpers/mod.ts src/driver-test-suite/mod.ts src/cli/mod.ts src/actor/protocol/inspector/mod.ts src/test/mod.ts src/inspector/protocol/actor/mod.ts src/inspector/protocol/manager/mod.ts src/inspector/mod.ts", "check-types": "tsc --noEmit", "boop": "tsc --outDir dist/test -d", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "dump-openapi": "tsx scripts/dump-openapi.ts" }, "dependencies": { + "@hono/zod-openapi": "^0.19.6", "cbor-x": "^1.6.0", "hono": "^4.7.0", "invariant": "^2.2.4", @@ -174,11 +176,15 @@ "zod": "^3.24.1" }, "devDependencies": { + "@hono/node-server": "^1.14.0", + "@hono/node-ws": "^1.1.1", "@types/invariant": "^2", "@types/node": "^22.13.1", "@types/ws": "^8", + "bundle-require": "^5.1.0", "eventsource": "^3.0.5", "tsup": "^8.4.0", + "tsx": "^4.19.4", "typescript": "^5.7.3", "vitest": "^3.1.1", "ws": "^8.18.1" diff --git a/packages/actor-core/scripts/dump-openapi.ts b/packages/actor-core/scripts/dump-openapi.ts new file mode 100644 index 000000000..1a69a6b06 --- /dev/null +++ b/packages/actor-core/scripts/dump-openapi.ts @@ -0,0 +1,75 @@ +import { createManagerRouter } from "@/manager/router"; +import { AppConfig, AppConfigSchema, setup } from "@/mod"; +import { ConnectionHandlers } from "@/actor/router-endpoints"; +import { DriverConfig } from "@/driver-helpers/config"; +import { + TestGlobalState, + TestActorDriver, + TestManagerDriver, +} from "@/test/driver/mod"; +import { OpenAPIHono } from "@hono/zod-openapi"; +import { VERSION } from "@/utils"; +import * as fs from "node:fs/promises"; +import { resolve } from "node:path"; + +function main() { + const appConfig: AppConfig = AppConfigSchema.parse({ actors: {} }); + const app = setup(appConfig); + + const memoryState = new TestGlobalState(); + const driverConfig: DriverConfig = { + drivers: { + actor: new TestActorDriver(memoryState), + manager: new TestManagerDriver(app, memoryState), + }, + getUpgradeWebSocket: () => () => unimplemented(), + }; + + const sharedConnectionHandlers: ConnectionHandlers = { + onConnectWebSocket: async () => { + unimplemented(); + }, + onConnectSse: async (opts) => { + unimplemented(); + }, + onAction: async (opts) => { + unimplemented(); + }, + onConnMessage: async (opts) => { + unimplemented(); + }, + }; + + const managerRouter = createManagerRouter(appConfig, driverConfig, { + proxyMode: { + inline: { + handlers: sharedConnectionHandlers, + }, + }, + }) as unknown as OpenAPIHono; + + const openApiDoc = managerRouter.getOpenAPIDocument({ + openapi: "3.0.0", + info: { + version: VERSION, + title: "ActorCore API", + }, + }); + + const outputPath = resolve( + import.meta.dirname, + "..", + "..", + "..", + "docs", + "openapi.json", + ); + fs.writeFile(outputPath, JSON.stringify(openApiDoc, null, 2)); + console.log("Dumped OpenAPI to", outputPath); +} + +function unimplemented(): never { + throw new Error("UNIMPLEMENTED"); +} + +main(); diff --git a/packages/actor-core/src/actor/action.ts b/packages/actor-core/src/actor/action.ts index ce528e1d6..ff530b741 100644 --- a/packages/actor-core/src/actor/action.ts +++ b/packages/actor-core/src/actor/action.ts @@ -57,6 +57,13 @@ export class ActionContext { return this.#actorContext.log; } + /** + * Gets actor ID. + */ + get actorId(): string { + return this.#actorContext.actorId; + } + /** * Gets the actor name. */ diff --git a/packages/actor-core/src/actor/connection.ts b/packages/actor-core/src/actor/connection.ts index e20955fdb..4b442af7c 100644 --- a/packages/actor-core/src/actor/connection.ts +++ b/packages/actor-core/src/actor/connection.ts @@ -5,6 +5,7 @@ import { CachedSerializer } from "./protocol/serde"; import type { ConnDriver } from "./driver"; import * as messageToClient from "@/actor/protocol/message/to-client"; import type { PersistedConn } from "./persisted"; +import * as wsToClient from "@/actor/protocol/message/to-client"; export function generateConnId(): string { return crypto.randomUUID(); @@ -123,7 +124,7 @@ export class Conn { * @protected */ public _sendMessage(message: CachedSerializer) { - this.#driver.sendMessage(this.#actor, this, this.__persist.ds, message); + this.#driver.sendMessage?.(this.#actor, this, this.__persist.ds, message); } /** @@ -135,7 +136,7 @@ export class Conn { */ public send(eventName: string, ...args: unknown[]) { this._sendMessage( - new CachedSerializer({ + new CachedSerializer({ b: { ev: { n: eventName, diff --git a/packages/actor-core/src/actor/context.ts b/packages/actor-core/src/actor/context.ts index d304fbc7b..7b92fea6f 100644 --- a/packages/actor-core/src/actor/context.ts +++ b/packages/actor-core/src/actor/context.ts @@ -5,7 +5,6 @@ import { Conn, ConnId } from "./connection"; import { ActorKey } from "@/common/utils"; import { Schedule } from "./schedule"; - /** * ActorContext class that provides access to actor methods and state */ @@ -36,7 +35,6 @@ export class ActorContext { * @param args - The arguments to send with the event. */ broadcast>(name: string, ...args: Args): void { - // @ts-ignore - Access protected method this.#actor._broadcast(name, ...args); return; } @@ -45,15 +43,20 @@ export class ActorContext { * Gets the logger instance. */ get log(): Logger { - // @ts-ignore - Access protected method return this.#actor.log; } + /** + * Gets actor ID. + */ + get actorId(): string { + return this.#actor.id; + } + /** * Gets the actor name. */ get name(): string { - // @ts-ignore - Access protected method return this.#actor.name; } @@ -61,7 +64,6 @@ export class ActorContext { * Gets the actor key. */ get key(): ActorKey { - // @ts-ignore - Access protected method return this.#actor.key; } @@ -69,7 +71,6 @@ export class ActorContext { * Gets the region. */ get region(): string { - // @ts-ignore - Access protected method return this.#actor.region; } @@ -77,7 +78,6 @@ export class ActorContext { * Gets the scheduler. */ get schedule(): Schedule { - // @ts-ignore - Access protected method return this.#actor.schedule; } @@ -85,7 +85,6 @@ export class ActorContext { * Gets the map of connections. */ get conns(): Map> { - // @ts-ignore - Access protected method return this.#actor.conns; } @@ -95,7 +94,6 @@ export class ActorContext { * @param opts - Options for saving the state. */ async saveState(opts: SaveStateOptions): Promise { - // @ts-ignore - Access protected method return this.#actor.saveState(opts); } @@ -105,7 +103,6 @@ export class ActorContext { * @param promise - The promise to run in the background. */ runInBackground(promise: Promise): void { - // @ts-ignore - Access protected method this.#actor._runInBackground(promise); return; } diff --git a/packages/actor-core/src/actor/driver.ts b/packages/actor-core/src/actor/driver.ts index 238d5112f..80df2c80a 100644 --- a/packages/actor-core/src/actor/driver.ts +++ b/packages/actor-core/src/actor/driver.ts @@ -21,7 +21,7 @@ export interface ActorDriver { } export interface ConnDriver { - sendMessage( + sendMessage?( actor: AnyActorInstance, conn: AnyConn, state: ConnDriverState, diff --git a/packages/actor-core/src/actor/errors.ts b/packages/actor-core/src/actor/errors.ts index 1246703da..4069cd448 100644 --- a/packages/actor-core/src/actor/errors.ts +++ b/packages/actor-core/src/actor/errors.ts @@ -13,8 +13,17 @@ interface ActorErrorOptions extends ErrorOptions { } export class ActorError extends Error { + __type = "ActorError"; + public public: boolean; public metadata?: unknown; + public statusCode: number = 500; + + public static isActorError(error: unknown): error is ActorError { + return ( + typeof error === "object" && (error as ActorError).__type === "ActorError" + ); + } constructor( public readonly code: string, @@ -24,6 +33,27 @@ export class ActorError extends Error { super(message, { cause: opts?.cause }); this.public = opts?.public ?? false; this.metadata = opts?.metadata; + + // Set status code based on error type + if (opts?.public) { + this.statusCode = 400; // Bad request for public errors + } + } + + toString() { + // Force stringify to return the message + return this.message; + } + + /** + * Serialize error for HTTP response + */ + serializeForHttp() { + return { + type: this.code, + message: this.message, + metadata: this.metadata, + }; } } @@ -194,3 +224,62 @@ export class UserError extends ActorError { }); } } + +export class InvalidQueryJSON extends ActorError { + constructor(error?: unknown) { + super("invalid_query_json", `Invalid query JSON: ${error}`, { + public: true, + cause: error, + }); + } +} + +export class InvalidRequest extends ActorError { + constructor(error?: unknown) { + super("invalid_request", `Invalid request: ${error}`, { + public: true, + cause: error, + }); + } +} + +export class ActorNotFound extends ActorError { + constructor(identifier?: string) { + super( + "actor_not_found", + identifier ? `Actor not found: ${identifier}` : "Actor not found", + { public: true }, + ); + } +} + +export class ActorAlreadyExists extends ActorError { + constructor(name: string, key: string[]) { + super( + "actor_already_exists", + `Actor already exists with name "${name}" and key ${JSON.stringify(key)}`, + { public: true }, + ); + } +} + +export class ProxyError extends ActorError { + constructor(operation: string, error?: unknown) { + super("proxy_error", `Error proxying ${operation}: ${error}`, { + public: true, + cause: error, + }); + } +} + +export class InvalidActionRequest extends ActorError { + constructor(message: string) { + super("invalid_action_request", message, { public: true }); + } +} + +export class InvalidParams extends ActorError { + constructor(message: string) { + super("invalid_params", message, { public: true }); + } +} diff --git a/packages/actor-core/src/actor/instance.ts b/packages/actor-core/src/actor/instance.ts index 168d7994d..3f37c270e 100644 --- a/packages/actor-core/src/actor/instance.ts +++ b/packages/actor-core/src/actor/instance.ts @@ -15,6 +15,7 @@ import { instanceLogger, logger } from "./log"; import type { ActionContext } from "./action"; import { DeadlineError, Lock, deadline } from "./utils"; import { Schedule } from "./schedule"; +import * as wsToClient from "@/actor/protocol/message/to-client"; import type * as wsToServer from "@/actor/protocol/message/to-server"; import { CachedSerializer } from "./protocol/serde"; import { ActorInspector } from "@/inspector/actor"; @@ -457,7 +458,7 @@ export class ActorInstance { } } - // State will be flushed at the end of the RPC + // State will be flushed at the end of the action }, { ignoreDetached: true }, ); @@ -716,10 +717,11 @@ export class ActorInstance { // Send init message conn._sendMessage( - new CachedSerializer({ + new CachedSerializer({ b: { i: { - ci: `${conn.id}`, + ai: this.id, + ci: conn.id, ct: conn._token, }, }, @@ -732,8 +734,8 @@ export class ActorInstance { // MARK: Messages async processMessage(message: wsToServer.ToServer, conn: Conn) { await processMessage(message, this, conn, { - onExecuteRpc: async (ctx, name, args) => { - return await this.executeRpc(ctx, name, args); + onExecuteAction: async (ctx, name, args) => { + return await this.executeAction(ctx, name, args); }, onSubscribe: async (eventName, conn) => { this.#addSubscription(eventName, conn, false); @@ -818,56 +820,56 @@ export class ActorInstance { } /** - * Execute an RPC call from a client. + * Execute an action call from a client. * * This method handles: - * 1. Validating the RPC name - * 2. Executing the RPC function - * 3. Processing the result through onBeforeRpcResponse (if configured) + * 1. Validating the action name + * 2. Executing the action function + * 3. Processing the result through onBeforeActionResponse (if configured) * 4. Handling timeouts and errors * 5. Saving state changes * - * @param ctx The RPC context - * @param rpcName The name of the RPC being called - * @param args The arguments passed to the RPC - * @returns The result of the RPC call - * @throws {RpcNotFound} If the RPC doesn't exist - * @throws {RpcTimedOut} If the RPC times out + * @param ctx The action context + * @param actionName The name of the action being called + * @param args The arguments passed to the action + * @returns The result of the action call + * @throws {ActionNotFound} If the action doesn't exist + * @throws {ActionTimedOut} If the action times out * @internal */ - async executeRpc( + async executeAction( ctx: ActionContext, - rpcName: string, + actionName: string, args: unknown[], ): Promise { - invariant(this.#ready, "exucuting rpc before ready"); + invariant(this.#ready, "exucuting action before ready"); // Prevent calling private or reserved methods - if (!(rpcName in this.#config.actions)) { - logger().warn("rpc does not exist", { rpcName }); + if (!(actionName in this.#config.actions)) { + logger().warn("action does not exist", { actionName }); throw new errors.ActionNotFound(); } // Check if the method exists on this object - // biome-ignore lint/suspicious/noExplicitAny: RPC name is dynamic from client - const rpcFunction = this.#config.actions[rpcName]; - if (typeof rpcFunction !== "function") { - logger().warn("action not found", { actionName: rpcName }); + // biome-ignore lint/suspicious/noExplicitAny: action name is dynamic from client + const actionFunction = this.#config.actions[actionName]; + if (typeof actionFunction !== "function") { + logger().warn("action not found", { actionName: actionName }); throw new errors.ActionNotFound(); } - // TODO: pass abortable to the rpc to decide when to abort + // TODO: pass abortable to the action to decide when to abort // TODO: Manually call abortable for better error handling // Call the function on this object with those arguments try { // Log when we start executing the action - logger().debug("executing action", { actionName: rpcName, args }); + logger().debug("executing action", { actionName: actionName, args }); - const outputOrPromise = rpcFunction.call(undefined, ctx, ...args); + const outputOrPromise = actionFunction.call(undefined, ctx, ...args); let output: unknown; if (outputOrPromise instanceof Promise) { // Log that we're waiting for an async action - logger().debug("awaiting async action", { actionName: rpcName }); + logger().debug("awaiting async action", { actionName: actionName }); output = await deadline( outputOrPromise, @@ -875,33 +877,33 @@ export class ActorInstance { ); // Log that async action completed - logger().debug("async action completed", { actionName: rpcName }); + logger().debug("async action completed", { actionName: actionName }); } else { output = outputOrPromise; } - // Process the output through onBeforeRpcResponse if configured + // Process the output through onBeforeActionResponse if configured if (this.#config.onBeforeActionResponse) { try { const processedOutput = this.#config.onBeforeActionResponse( this.actorContext, - rpcName, + actionName, args, output, ); if (processedOutput instanceof Promise) { logger().debug("awaiting onBeforeActionResponse", { - actionName: rpcName, + actionName: actionName, }); output = await processedOutput; logger().debug("onBeforeActionResponse completed", { - actionName: rpcName, + actionName: actionName, }); } else { output = processedOutput; } } catch (error) { - logger().error("error in `onBeforeRpcResponse`", { + logger().error("error in `onBeforeActionResponse`", { error: stringifyError(error), }); } @@ -909,7 +911,7 @@ export class ActorInstance { // Log the output before returning logger().debug("action completed", { - actionName: rpcName, + actionName: actionName, outputType: typeof output, isPromise: output instanceof Promise, }); @@ -920,7 +922,7 @@ export class ActorInstance { throw new errors.ActionTimedOut(); } logger().error("action error", { - actionName: rpcName, + actionName: actionName, error: stringifyError(error), }); throw error; @@ -930,9 +932,9 @@ export class ActorInstance { } /** - * Returns a list of RPC methods available on this actor. + * Returns a list of action methods available on this actor. */ - get rpcs(): string[] { + get actions(): string[] { return Object.keys(this.#config.actions); } @@ -1019,7 +1021,7 @@ export class ActorInstance { const subscriptions = this.#subscriptionIndex.get(name); if (!subscriptions) return; - const toClientSerializer = new CachedSerializer({ + const toClientSerializer = new CachedSerializer({ b: { ev: { n: name, @@ -1038,7 +1040,7 @@ export class ActorInstance { * Runs a promise in the background. * * This allows the actor runtime to ensure that a promise completes while - * returning from an RPC request early. + * returning from an action request early. * * @param promise - The promise to run in the background. */ diff --git a/packages/actor-core/src/actor/protocol/http/action.ts b/packages/actor-core/src/actor/protocol/http/action.ts new file mode 100644 index 000000000..b9e8aa873 --- /dev/null +++ b/packages/actor-core/src/actor/protocol/http/action.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const ActionRequestSchema = z.object({ + // Args + a: z.array(z.unknown()), +}); + +export const ActionResponseSchema = z.object({ + // Output + o: z.unknown(), +}); + + +export type ActionRequest = z.infer; +export type ActionResponse = z.infer; diff --git a/packages/actor-core/src/actor/protocol/http/error.ts b/packages/actor-core/src/actor/protocol/http/error.ts new file mode 100644 index 000000000..7c7b9216b --- /dev/null +++ b/packages/actor-core/src/actor/protocol/http/error.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const ResponseErrorSchema = z.object({ + // Code + c: z.string(), + // Message + m: z.string(), + // Metadata + md: z.unknown().optional(), +}); + +export type ResponseError = z.infer; diff --git a/packages/actor-core/src/actor/protocol/http/resolve.ts b/packages/actor-core/src/actor/protocol/http/resolve.ts new file mode 100644 index 000000000..54c124539 --- /dev/null +++ b/packages/actor-core/src/actor/protocol/http/resolve.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ResolveResponseSchema = z.object({ + // Actor ID + i: z.string(), +}); + +export type ResolveResponse = z.infer; diff --git a/packages/actor-core/src/actor/protocol/http/rpc.ts b/packages/actor-core/src/actor/protocol/http/rpc.ts deleted file mode 100644 index 87b6b21b8..000000000 --- a/packages/actor-core/src/actor/protocol/http/rpc.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; - -export const RequestSchema = z.object({ - // Args - a: z.array(z.unknown()), -}); - -export const ResponseOkSchema = z.object({ - // Output - o: z.unknown(), -}); - -export const ResponseErrSchema = z.object({ - // Code - c: z.string(), - // Message - m: z.string(), - // Metadata - md: z.unknown().optional(), -}); - -export type Request = z.infer; -export type ResponseOk = z.infer; -export type ResponseErr = z.infer; diff --git a/packages/actor-core/src/actor/protocol/message/mod.ts b/packages/actor-core/src/actor/protocol/message/mod.ts index da1f23a64..23b7de19d 100644 --- a/packages/actor-core/src/actor/protocol/message/mod.ts +++ b/packages/actor-core/src/actor/protocol/message/mod.ts @@ -15,6 +15,7 @@ import { } from "@/actor/protocol/serde"; import { deconstructError } from "@/common/utils"; import { Actions } from "@/actor/config"; +import invariant from "invariant"; export const TransportSchema = z.enum(["websocket", "sse"]); @@ -35,11 +36,10 @@ function getValueLength(value: InputData): number { return value.size; } else if ( value instanceof ArrayBuffer || - value instanceof SharedArrayBuffer + value instanceof SharedArrayBuffer || + value instanceof Uint8Array ) { return value.byteLength; - } else if (Buffer.isBuffer(value)) { - return value.length; } else { assertUnreachable(value); } @@ -70,13 +70,16 @@ export async function parseMessage( } export interface ProcessMessageHandler { - onExecuteRpc?: ( + onExecuteAction?: ( ctx: ActionContext, name: string, args: unknown[], ) => Promise; onSubscribe?: (eventName: string, conn: Conn) => Promise; - onUnsubscribe?: (eventName: string, conn: Conn) => Promise; + onUnsubscribe?: ( + eventName: string, + conn: Conn, + ) => Promise; } export async function processMessage( @@ -85,50 +88,56 @@ export async function processMessage( conn: Conn, handler: ProcessMessageHandler, ) { - let rpcId: number | undefined; - let rpcName: string | undefined; + let actionId: number | undefined; + let actionName: string | undefined; try { - if ("rr" in message.b) { - // RPC request + if ("i" in message.b) { + invariant(false, "should not be notified of init event"); + } else if ("ar" in message.b) { + // Action request - if (handler.onExecuteRpc === undefined) { - throw new errors.Unsupported("RPC"); + if (handler.onExecuteAction === undefined) { + throw new errors.Unsupported("Action"); } - const { i: id, n: name, a: args = [] } = message.b.rr; + const { i: id, n: name, a: args = [] } = message.b.ar; + + actionId = id; + actionName = name; - rpcId = id; - rpcName = name; + logger().debug("processing action request", { + id, + name, + argsCount: args.length, + }); - logger().debug("processing RPC request", { id, name, argsCount: args.length }); - const ctx = new ActionContext(actor.actorContext, conn); - - // Process the RPC request and wait for the result + + // Process the action request and wait for the result // This will wait for async actions to complete - const output = await handler.onExecuteRpc(ctx, name, args); - - logger().debug("sending RPC response", { - id, - name, - outputType: typeof output, - isPromise: output instanceof Promise + const output = await handler.onExecuteAction(ctx, name, args); + + logger().debug("sending action response", { + id, + name, + outputType: typeof output, + isPromise: output instanceof Promise, }); // Send the response back to the client conn._sendMessage( new CachedSerializer({ b: { - ro: { + ar: { i: id, o: output, }, }, }), ); - - logger().debug("RPC response sent", { id, name }); + + logger().debug("action response sent", { id, name }); } else if ("sr" in message.b) { // Subscription request @@ -140,60 +149,52 @@ export async function processMessage( } const { e: eventName, s: subscribe } = message.b.sr; - logger().debug("processing subscription request", { eventName, subscribe }); + logger().debug("processing subscription request", { + eventName, + subscribe, + }); if (subscribe) { await handler.onSubscribe(eventName, conn); } else { await handler.onUnsubscribe(eventName, conn); } - - logger().debug("subscription request completed", { eventName, subscribe }); + + logger().debug("subscription request completed", { + eventName, + subscribe, + }); } else { assertUnreachable(message.b); } } catch (error) { const { code, message, metadata } = deconstructError(error, logger(), { connectionId: conn.id, - rpcId, - rpcName, + actionId, + actionName, }); logger().debug("sending error response", { - rpcId, - rpcName, + actionId, + actionName, code, - message + message, }); // Build response - if (rpcId !== undefined) { - conn._sendMessage( - new CachedSerializer({ - b: { - re: { - i: rpcId, - c: code, - m: message, - md: metadata, - }, + conn._sendMessage( + new CachedSerializer({ + b: { + e: { + c: code, + m: message, + md: metadata, + ai: actionId, }, - }), - ); - } else { - conn._sendMessage( - new CachedSerializer({ - b: { - er: { - c: code, - m: message, - md: metadata, - }, - }, - }), - ); - } - - logger().debug("error response sent", { rpcId, rpcName }); + }, + }), + ); + + logger().debug("error response sent", { actionId, actionName }); } } diff --git a/packages/actor-core/src/actor/protocol/message/to-client.ts b/packages/actor-core/src/actor/protocol/message/to-client.ts index df74e8f5f..a0a5330a9 100644 --- a/packages/actor-core/src/actor/protocol/message/to-client.ts +++ b/packages/actor-core/src/actor/protocol/message/to-client.ts @@ -2,59 +2,51 @@ import { z } from "zod"; // Only called for SSE because we don't need this for WebSockets export const InitSchema = z.object({ + // Actor ID + ai: z.string(), // Connection ID ci: z.string(), // Connection token ct: z.string(), }); -export const RpcResponseOkSchema = z.object({ - // ID - i: z.number().int(), - // Output - o: z.unknown(), -}); - -export const RpcResponseErrorSchema = z.object({ - // ID - i: z.number().int(), +// Used for connection errors (both during initialization and afterwards) +export const ErrorSchema = z.object({ // Code c: z.string(), // Message m: z.string(), // Metadata md: z.unknown().optional(), + // Action ID + ai: z.number().int().optional(), +}); + +export const ActionResponseSchema = z.object({ + // ID + i: z.number().int(), + // Output + o: z.unknown(), }); -export const ToClientEventSchema = z.object({ +export const EventSchema = z.object({ // Name n: z.string(), // Args a: z.array(z.unknown()), }); -export const ToClientErrorSchema = z.object({ - // Code - c: z.string(), - // Message - m: z.string(), - // Metadata - md: z.unknown().optional(), -}); - export const ToClientSchema = z.object({ // Body b: z.union([ z.object({ i: InitSchema }), - z.object({ ro: RpcResponseOkSchema }), - z.object({ re: RpcResponseErrorSchema }), - z.object({ ev: ToClientEventSchema }), - z.object({ er: ToClientErrorSchema }), + z.object({ e: ErrorSchema }), + z.object({ ar: ActionResponseSchema }), + z.object({ ev: EventSchema }), ]), }); export type ToClient = z.infer; -export type RpcResponseOk = z.infer; -export type RpcResponseError = z.infer; -export type ToClientEvent = z.infer; -export type ToClientError = z.infer; +export type Error = z.infer; +export type ActionResponse = z.infer; +export type Event = z.infer; diff --git a/packages/actor-core/src/actor/protocol/message/to-server.ts b/packages/actor-core/src/actor/protocol/message/to-server.ts index 6ee5b093b..4197a3000 100644 --- a/packages/actor-core/src/actor/protocol/message/to-server.ts +++ b/packages/actor-core/src/actor/protocol/message/to-server.ts @@ -1,6 +1,11 @@ import { z } from "zod"; -const RpcRequestSchema = z.object({ +const InitSchema = z.object({ + // Conn Params + p: z.unknown({}).optional(), +}); + +const ActionRequestSchema = z.object({ // ID i: z.number().int(), // Name @@ -19,11 +24,12 @@ const SubscriptionRequestSchema = z.object({ export const ToServerSchema = z.object({ // Body b: z.union([ - z.object({ rr: RpcRequestSchema }), + z.object({ i: InitSchema }), + z.object({ ar: ActionRequestSchema }), z.object({ sr: SubscriptionRequestSchema }), ]), }); export type ToServer = z.infer; -export type RpcRequest = z.infer; +export type ActionRequest = z.infer; export type SubscriptionRequest = z.infer; diff --git a/packages/actor-core/src/actor/protocol/serde.ts b/packages/actor-core/src/actor/protocol/serde.ts index 326c8c1bb..245513e61 100644 --- a/packages/actor-core/src/actor/protocol/serde.ts +++ b/packages/actor-core/src/actor/protocol/serde.ts @@ -5,7 +5,7 @@ import { assertUnreachable } from "../utils"; import * as cbor from "cbor-x"; /** Data that can be deserialized. */ -export type InputData = string | Buffer | Blob | ArrayBufferLike; +export type InputData = string | Buffer | Blob | ArrayBufferLike | Uint8Array; /** Data that's been serialized. */ export type OutputData = string | Uint8Array; @@ -20,7 +20,7 @@ export type Encoding = z.infer; /** * Helper class that helps serialize data without re-serializing for the same encoding. */ -export class CachedSerializer { +export class CachedSerializer { #data: T; #cache = new Map(); @@ -71,9 +71,12 @@ export async function deserialize(data: InputData, encoding: Encoding) { if (data instanceof Blob) { const arrayBuffer = await data.arrayBuffer(); return cbor.decode(new Uint8Array(arrayBuffer)); - } else if (data instanceof ArrayBuffer) { - return cbor.decode(new Uint8Array(data)); - } else if (Buffer.isBuffer(data)) { + } else if (data instanceof Uint8Array) { + return cbor.decode(data); + } else if ( + data instanceof ArrayBuffer || + data instanceof SharedArrayBuffer + ) { return cbor.decode(new Uint8Array(data)); } else { logger().warn("received non-binary type for cbor parse"); diff --git a/packages/actor-core/src/actor/router-endpoints.ts b/packages/actor-core/src/actor/router-endpoints.ts new file mode 100644 index 000000000..9d1b6f3ea --- /dev/null +++ b/packages/actor-core/src/actor/router-endpoints.ts @@ -0,0 +1,478 @@ +import { type HonoRequest, type Context as HonoContext } from "hono"; +import { type SSEStreamingApi, streamSSE } from "hono/streaming"; +import { type WSContext } from "hono/ws"; +import * as errors from "./errors"; +import { logger } from "./log"; +import { + type Encoding, + EncodingSchema, + serialize, + deserialize, + CachedSerializer, +} from "@/actor/protocol/serde"; +import { parseMessage } from "@/actor/protocol/message/mod"; +import * as protoHttpAction from "@/actor/protocol/http/action"; +import type * as messageToServer from "@/actor/protocol/message/to-server"; +import type { InputData, OutputData } from "@/actor/protocol/serde"; +import { assertUnreachable } from "./utils"; +import { deconstructError, stringifyError } from "@/common/utils"; +import type { AppConfig } from "@/app/config"; +import type { DriverConfig } from "@/driver-helpers/config"; +import invariant from "invariant"; + +export interface ConnectWebSocketOpts { + req: HonoRequest; + encoding: Encoding; + params: unknown; + actorId: string; +} + +export interface ConnectWebSocketOutput { + onOpen: (ws: WSContext) => Promise; + onMessage: (message: messageToServer.ToServer) => Promise; + onClose: () => Promise; +} + +export interface ConnectSseOpts { + req: HonoRequest; + encoding: Encoding; + params: unknown; + actorId: string; +} + +export interface ConnectSseOutput { + onOpen: (stream: SSEStreamingApi) => Promise; + onClose: () => Promise; +} + +export interface ActionOpts { + req: HonoRequest; + params: unknown; + actionName: string; + actionArgs: unknown[]; + actorId: string; +} + +export interface ActionOutput { + output: unknown; +} + +export interface ConnsMessageOpts { + req: HonoRequest; + connId: string; + connToken: string; + message: messageToServer.ToServer; + actorId: string; +} + +/** + * Shared interface for connection handlers used by both ActorRouterHandler and ManagerRouterHandler + */ +export interface ConnectionHandlers { + onConnectWebSocket?( + opts: ConnectWebSocketOpts, + ): Promise; + onConnectSse(opts: ConnectSseOpts): Promise; + onAction(opts: ActionOpts): Promise; + onConnMessage(opts: ConnsMessageOpts): Promise; +} + +/** + * Creates a WebSocket connection handler + */ +export function handleWebSocketConnect( + context: HonoContext, + appConfig: AppConfig, + driverConfig: DriverConfig, + handler: (opts: ConnectWebSocketOpts) => Promise, + actorId: string, +) { + return async () => { + const encoding = getRequestEncoding(context.req, true); + + let sharedWs: WSContext | undefined = undefined; + + // Setup promise for the init message since all other behavior depends on this + const { + promise: onInitPromise, + resolve: onInitResolve, + reject: onInitReject, + } = Promise.withResolvers(); + + let didTimeOut = false; + let didInit = false; + + // Add timeout waiting for init + const initTimeout = setTimeout(() => { + logger().warn("timed out waiting for init"); + + sharedWs?.close(1001, "timed out waiting for init message"); + didTimeOut = true; + onInitReject("init timed out"); + }, appConfig.webSocketInitTimeout); + + return { + onOpen: async (_evt: any, ws: WSContext) => { + sharedWs = ws; + + logger().debug("websocket open"); + + // Close WS immediately if init timed out. This indicates a long delay at the protocol level in sending the init message. + if (didTimeOut) ws.close(1001, "timed out waiting for init message"); + }, + onMessage: async (evt: { data: any }, ws: WSContext) => { + try { + const value = evt.data.valueOf() as InputData; + const message = await parseMessage(value, { + encoding: encoding, + maxIncomingMessageSize: appConfig.maxIncomingMessageSize, + }); + + if ("i" in message.b) { + // Handle init message + // + // Parameters must go over the init message instead of a query parameter so it receives full E2EE + + logger().debug("received init ws message"); + + invariant( + !didInit, + "should not have already received init message", + ); + didInit = true; + clearTimeout(initTimeout); + + try { + // Create connection handler + const wsHandler = await handler({ + req: context.req, + encoding, + params: message.b.i.p, + actorId, + }); + + // Notify socket open + // TODO: Add timeout to this + await wsHandler.onOpen(ws); + + // Allow all other events to proceed + onInitResolve(wsHandler); + } catch (error) { + deconstructError(error, logger(), { wsEvent: "open" }); + onInitReject(error); + ws.close(1011, "internal error"); + } + } else { + // Handle all other messages + + logger().debug("received regular ws message"); + + const wsHandler = await onInitPromise; + await wsHandler.onMessage(message); + } + } catch (error) { + const { code } = deconstructError(error, logger(), { + wsEvent: "message", + }); + ws.close(1011, code); + } + }, + onClose: async ( + event: { + wasClean: boolean; + code: number; + reason: string; + }, + ws: WSContext, + ) => { + if (event.wasClean) { + logger().info("websocket closed", { + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + }); + } else { + logger().warn("websocket closed", { + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + }); + } + + // HACK: Close socket in order to fix bug with Cloudflare Durable Objects leaving WS in closing state + // https://github.com/cloudflare/workerd/issues/2569 + ws.close(1000, "hack_force_close"); + + try { + const wsHandler = await onInitPromise; + await wsHandler.onClose(); + } catch (error) { + deconstructError(error, logger(), { wsEvent: "close" }); + } + }, + onError: async (_error: unknown) => { + try { + // Actors don't need to know about this, since it's abstracted away + logger().warn("websocket error"); + } catch (error) { + deconstructError(error, logger(), { wsEvent: "error" }); + } + }, + }; + }; +} + +/** + * Creates an SSE connection handler + */ +export async function handleSseConnect( + c: HonoContext, + appConfig: AppConfig, + driverConfig: DriverConfig, + handler: (opts: ConnectSseOpts) => Promise, + actorId: string, +) { + const encoding = getRequestEncoding(c.req, false); + const parameters = getRequestConnParams(c.req, appConfig, driverConfig); + + const sseHandler = await handler({ + req: c.req, + encoding, + params: parameters, + actorId, + }); + + return streamSSE(c, async (stream) => { + try { + await sseHandler.onOpen(stream); + + // Wait for close + const abortResolver = Promise.withResolvers(); + c.req.raw.signal.addEventListener("abort", async () => { + try { + abortResolver.resolve(undefined); + await sseHandler.onClose(); + } catch (error) { + logger().error("error closing sse connection", { error }); + } + }); + + // Wait until connection aborted + await abortResolver.promise; + } catch (error) { + logger().error("error opening sse connection", { error }); + throw error; + } + }); +} + +/** + * Creates an action handler + */ +export async function handleAction( + c: HonoContext, + appConfig: AppConfig, + driverConfig: DriverConfig, + handler: (opts: ActionOpts) => Promise, + actionName: string, + actorId: string, +) { + const encoding = getRequestEncoding(c.req, false); + const parameters = getRequestConnParams(c.req, appConfig, driverConfig); + + logger().debug("handling action", { actionName, encoding }); + + // Validate incoming request + let actionArgs: unknown[]; + if (encoding === "json") { + try { + actionArgs = await c.req.json(); + } catch (err) { + throw new errors.InvalidActionRequest("Invalid JSON"); + } + + if (!Array.isArray(actionArgs)) { + throw new errors.InvalidActionRequest("Action arguments must be an array"); + } + } else if (encoding === "cbor") { + try { + const value = await c.req.arrayBuffer(); + const uint8Array = new Uint8Array(value); + const deserialized = await deserialize( + uint8Array as unknown as InputData, + encoding, + ); + + // Validate using the action schema + const result = protoHttpAction.ActionRequestSchema.safeParse(deserialized); + if (!result.success) { + throw new errors.InvalidActionRequest("Invalid action request format"); + } + + actionArgs = result.data.a; + } catch (err) { + throw new errors.InvalidActionRequest( + `Invalid binary format: ${stringifyError(err)}`, + ); + } + } else { + return assertUnreachable(encoding); + } + + // Invoke the action + const result = await handler({ + req: c.req, + params: parameters, + actionName: actionName, + actionArgs: actionArgs, + actorId, + }); + + // Encode the response + if (encoding === "json") { + return c.json(result.output as Record); + } else if (encoding === "cbor") { + // Use serialize from serde.ts instead of custom encoder + const responseData = { + o: result.output, // Use the format expected by ResponseOkSchema + }; + const serialized = serialize(responseData, encoding); + + return c.body(serialized as Uint8Array, 200, { + "Content-Type": "application/octet-stream", + }); + } else { + return assertUnreachable(encoding); + } +} + +/** + * Create a connection message handler + */ +export async function handleConnectionMessage( + c: HonoContext, + appConfig: AppConfig, + handler: (opts: ConnsMessageOpts) => Promise, + connId: string, + connToken: string, + actorId: string, +) { + const encoding = getRequestEncoding(c.req, false); + + // Validate incoming request + let message: messageToServer.ToServer; + if (encoding === "json") { + try { + message = await c.req.json(); + } catch (err) { + throw new errors.InvalidRequest("Invalid JSON"); + } + } else if (encoding === "cbor") { + try { + const value = await c.req.arrayBuffer(); + const uint8Array = new Uint8Array(value); + message = await parseMessage(uint8Array as unknown as InputData, { + encoding, + maxIncomingMessageSize: appConfig.maxIncomingMessageSize, + }); + } catch (err) { + throw new errors.InvalidRequest( + `Invalid binary format: ${stringifyError(err)}`, + ); + } + } else { + return assertUnreachable(encoding); + } + + await handler({ + req: c.req, + connId, + connToken, + message, + actorId, + }); + + return c.json({}); +} + +// Helper to get the connection encoding from a request +export function getRequestEncoding( + req: HonoRequest, + useQuery: boolean, +): Encoding { + const encodingParam = useQuery + ? req.query("encoding") + : req.header(HEADER_ENCODING); + if (!encodingParam) { + return "json"; + } + + const result = EncodingSchema.safeParse(encodingParam); + if (!result.success) { + throw new errors.InvalidEncoding(encodingParam as string); + } + + return result.data; +} + +export function getRequestQuery(c: HonoContext, useQuery: boolean): unknown { + // Get query parameters for actor lookup + const queryParam = useQuery + ? c.req.query("query") + : c.req.header(HEADER_ACTOR_QUERY); + if (!queryParam) { + logger().error("missing query parameter"); + throw new errors.InvalidRequest("missing query"); + } + + // Parse the query JSON and validate with schema + try { + const parsed = JSON.parse(queryParam); + return parsed; + } catch (error) { + logger().error("invalid query json", { error }); + throw new errors.InvalidQueryJSON(error); + } +} + +export const HEADER_ACTOR_QUERY = "X-AC-Query"; + +export const HEADER_ENCODING = "X-AC-Encoding"; + +// IMPORTANT: Params must be in headers or in an E2EE part of the request (i.e. NOT the URL or query string) in order to ensure that tokens can be securely passed in params. +export const HEADER_CONN_PARAMS = "X-AC-Conn-Params"; + +export const HEADER_ACTOR_ID = "X-AC-Actor"; + +export const HEADER_CONN_ID = "X-AC-Conn"; + +export const HEADER_CONN_TOKEN = "X-AC-Conn-Token"; + +export const ALL_HEADERS = [ + HEADER_ACTOR_QUERY, + HEADER_ENCODING, + HEADER_CONN_PARAMS, + HEADER_ACTOR_ID, + HEADER_CONN_ID, + HEADER_CONN_TOKEN, +]; + +// Helper to get connection parameters for the request +export function getRequestConnParams( + req: HonoRequest, + appConfig: AppConfig, + driverConfig: DriverConfig, +): unknown { + const paramsParam = req.header(HEADER_CONN_PARAMS); + if (!paramsParam) { + return null; + } + + try { + return JSON.parse(paramsParam); + } catch (err) { + throw new errors.InvalidParams( + `Invalid params JSON: ${stringifyError(err)}`, + ); + } +} diff --git a/packages/actor-core/src/actor/router.ts b/packages/actor-core/src/actor/router.ts index 62ff90a80..f599854ea 100644 --- a/packages/actor-core/src/actor/router.ts +++ b/packages/actor-core/src/actor/router.ts @@ -1,75 +1,53 @@ -import { Hono, type HonoRequest } from "hono"; -import type { UpgradeWebSocket, WSContext } from "hono/ws"; -import * as errors from "./errors"; +import { Hono, type Context as HonoContext } from "hono"; +import type { UpgradeWebSocket } from "hono/ws"; import { logger } from "./log"; -import { type Encoding, EncodingSchema } from "@/actor/protocol/serde"; -import { parseMessage } from "@/actor/protocol/message/mod"; -import * as protoHttpRpc from "@/actor/protocol/http/rpc"; -import type * as messageToServer from "@/actor/protocol/message/to-server"; -import type { InputData } from "@/actor/protocol/serde"; -import { type SSEStreamingApi, streamSSE } from "hono/streaming"; import { cors } from "hono/cors"; -import { assertUnreachable } from "./utils"; -import { handleRouteError, handleRouteNotFound } from "@/common/router"; -import { deconstructError, stringifyError } from "@/common/utils"; +import { + handleRouteError, + handleRouteNotFound, + loggerMiddleware, +} from "@/common/router"; import type { DriverConfig } from "@/driver-helpers/config"; import type { AppConfig } from "@/app/config"; import { type ActorInspectorConnHandler, createActorInspectorRouter, } from "@/inspector/actor"; - -export interface ConnectWebSocketOpts { - req: HonoRequest; - encoding: Encoding; - params: unknown; -} - -export interface ConnectWebSocketOutput { - onOpen: (ws: WSContext) => Promise; - onMessage: (message: messageToServer.ToServer) => Promise; - onClose: () => Promise; -} - -export interface ConnectSseOpts { - req: HonoRequest; - encoding: Encoding; - params: unknown; -} - -export interface ConnectSseOutput { - onOpen: (stream: SSEStreamingApi) => Promise; - onClose: () => Promise; -} - -export interface RpcOpts { - req: HonoRequest; - params: unknown; - rpcName: string; - rpcArgs: unknown[]; -} - -export interface RpcOutput { - output: unknown; -} - -export interface ConnsMessageOpts { - req: HonoRequest; - connId: string; - connToken: string; - message: messageToServer.ToServer; -} +import invariant from "invariant"; +import { + type ConnectWebSocketOpts, + type ConnectWebSocketOutput, + type ConnectSseOpts, + type ConnectSseOutput, + type ActionOpts, + type ActionOutput, + type ConnsMessageOpts, + type ConnectionHandlers, + handleWebSocketConnect, + handleSseConnect, + handleAction, + handleConnectionMessage, + HEADER_CONN_TOKEN, + HEADER_CONN_ID, + ALL_HEADERS, +} from "./router-endpoints"; + +export type { + ConnectWebSocketOpts, + ConnectWebSocketOutput, + ConnectSseOpts, + ConnectSseOutput, + ActionOpts, + ActionOutput, + ConnsMessageOpts, +}; export interface ActorRouterHandler { - // Pass this value directly from Hono - upgradeWebSocket?: UpgradeWebSocket; + getActorId: () => Promise; + + // Connection handlers as a required subobject + connectionHandlers: ConnectionHandlers; - onConnectWebSocket?( - opts: ConnectWebSocketOpts, - ): Promise; - onConnectSse(opts: ConnectSseOpts): Promise; - onRpc(opts: RpcOpts): Promise; - onConnMessage(opts: ConnsMessageOpts): Promise; onConnectInspector?: ActorInspectorConnHandler; } @@ -85,8 +63,16 @@ export function createActorRouter( ): Hono { const app = new Hono(); + const upgradeWebSocket = driverConfig.getUpgradeWebSocket?.(app); + + app.use("*", loggerMiddleware(logger())); + // Apply CORS middleware if configured + // + //This is only relevant if the actor is exposed directly publicly if (appConfig.cors) { + const corsConfig = appConfig.cors; + app.use("*", async (c, next) => { const path = c.req.path; @@ -95,7 +81,10 @@ export function createActorRouter( return next(); } - return cors(appConfig.cors)(c, next); + return cors({ + ...corsConfig, + allowHeaders: [...(appConfig.cors?.allowHeaders ?? []), ...ALL_HEADERS], + })(c, next); }); } @@ -109,105 +98,21 @@ export function createActorRouter( return c.text("ok"); }); - if (handler.upgradeWebSocket && handler.onConnectWebSocket) { + // Use the handlers from connectionHandlers + const handlers = handler.connectionHandlers; + + if (upgradeWebSocket && handlers.onConnectWebSocket) { app.get( "/connect/websocket", - handler.upgradeWebSocket(async (c) => { - try { - if (!handler.onConnectWebSocket) - throw new Error("onConnectWebSocket is not implemented"); - - const encoding = getRequestEncoding(c.req); - const parameters = getRequestConnParams( - c.req, - appConfig, - driverConfig, - ); - - const wsHandler = await handler.onConnectWebSocket({ - req: c.req, - encoding, - params: parameters, - }); - - const { promise: onOpenPromise, resolve: onOpenResolve } = - Promise.withResolvers(); - return { - onOpen: async (_evt, ws) => { - try { - logger().debug("websocket open"); - - // Call handler - await wsHandler.onOpen(ws); - - // Resolve promise - onOpenResolve(undefined); - } catch (error) { - const { code } = deconstructError(error, logger(), { - wsEvent: "open", - }); - ws.close(1011, code); - } - }, - onMessage: async (evt, ws) => { - try { - await onOpenPromise; - - logger().debug("received message"); - - const value = evt.data.valueOf() as InputData; - const message = await parseMessage(value, { - encoding: encoding, - maxIncomingMessageSize: appConfig.maxIncomingMessageSize, - }); - - await wsHandler.onMessage(message); - } catch (error) { - const { code } = deconstructError(error, logger(), { - wsEvent: "message", - }); - ws.close(1011, code); - } - }, - onClose: async (event) => { - try { - await onOpenPromise; - - if (event.wasClean) { - logger().info("websocket closed", { - code: event.code, - reason: event.reason, - wasClean: event.wasClean, - }); - } else { - logger().warn("websocket closed", { - code: event.code, - reason: event.reason, - wasClean: event.wasClean, - }); - } - - await wsHandler.onClose(); - } catch (error) { - deconstructError(error, logger(), { wsEvent: "close" }); - } - }, - onError: async (error) => { - try { - await onOpenPromise; - - // Actors don't need to know about this, since it's abstracted - // away - logger().warn("websocket error"); - } catch (error) { - deconstructError(error, logger(), { wsEvent: "error" }); - } - }, - }; - } catch (error) { - deconstructError(error, logger(), {}); - return {}; - } + upgradeWebSocket(async (c) => { + const actorId = await handler.getActorId(); + return handleWebSocketConnect( + c as HonoContext, + appConfig, + driverConfig, + handlers.onConnectWebSocket!, + actorId, + )(); }), ); } else { @@ -220,163 +125,60 @@ export function createActorRouter( } app.get("/connect/sse", async (c) => { - const encoding = getRequestEncoding(c.req); - const parameters = getRequestConnParams(c.req, appConfig, driverConfig); - - const sseHandler = await handler.onConnectSse({ - req: c.req, - encoding, - params: parameters, - }); - - return streamSSE( + if (!handlers.onConnectSse) { + throw new Error("onConnectSse handler is required"); + } + const actorId = await handler.getActorId(); + return handleSseConnect( c, - async (stream) => { - // Create connection with validated parameters - logger().debug("sse stream open"); - - await sseHandler.onOpen(stream); - - const { promise, resolve } = Promise.withResolvers(); - - stream.onAbort(() => { - sseHandler.onClose(); - - resolve(undefined); - }); - - await promise; - }, - async (error) => { - // Actors don't need to know about this, since it's abstracted - // away - logger().warn("sse error", { error: stringifyError(error) }); - }, + appConfig, + driverConfig, + handlers.onConnectSse, + actorId, ); }); - app.post("/rpc/:rpc", async (c) => { - const rpcName = c.req.param("rpc"); - try { - // TODO: Support multiple encodings - const encoding: Encoding = "json"; - const parameters = getRequestConnParams(c.req, appConfig, driverConfig); - - // Parse request body if present - const contentLength = Number(c.req.header("content-length") || "0"); - if (contentLength > appConfig.maxIncomingMessageSize) { - throw new errors.MessageTooLong(); - } - - // Parse request body according to encoding - const body = await c.req.json(); - const { data: message, success } = - protoHttpRpc.RequestSchema.safeParse(body); - if (!success) { - throw new errors.MalformedMessage("Invalid request format"); - } - const rpcArgs = message.a; - - // Callback - const { output } = await handler.onRpc({ - req: c.req, - params: parameters, - rpcName, - rpcArgs, - }); - - // Format response according to encoding - return c.json({ - o: output, - } satisfies protoHttpRpc.ResponseOk); - } catch (error) { - // Build response error information similar to WebSocket handling - - const { statusCode, code, message, metadata } = deconstructError( - error, - logger(), - { rpc: rpcName }, - ); - - return c.json( - { - c: code, - m: message, - md: metadata, - } satisfies protoHttpRpc.ResponseErr, - { status: statusCode }, - ); + app.post("/action/:action", async (c) => { + if (!handlers.onAction) { + throw new Error("onAction handler is required"); } + const actionName = c.req.param("action"); + const actorId = await handler.getActorId(); + return handleAction( + c, + appConfig, + driverConfig, + handlers.onAction, + actionName, + actorId, + ); }); - app.post("/connections/:conn/message", async (c) => { - try { - const encoding = getRequestEncoding(c.req); - - const connId = c.req.param("conn"); - if (!connId) { - throw new errors.ConnNotFound(connId); - } - - const connToken = c.req.query("connectionToken"); - if (!connToken) throw new errors.IncorrectConnToken(); - - // Parse request body if present - const contentLength = Number(c.req.header("content-length") || "0"); - if (contentLength > appConfig.maxIncomingMessageSize) { - throw new errors.MessageTooLong(); - } - - // Read body - let value: InputData; - if (encoding === "json") { - // Handle decoding JSON in handleMessageEvent - value = await c.req.text(); - } else if (encoding === "cbor") { - value = await c.req.arrayBuffer(); - } else { - assertUnreachable(encoding); - } - - // Parse message - const message = await parseMessage(value, { - encoding, - maxIncomingMessageSize: appConfig.maxIncomingMessageSize, - }); - - await handler.onConnMessage({ - req: c.req, - connId, - connToken, - message, - }); - - // Not data to return - return c.json({}); - } catch (error) { - // Build response error information similar to WebSocket handling - const { statusCode, code, message, metadata } = deconstructError( - error, - logger(), - {}, - ); - - return c.json( - { - c: code, - m: message, - md: metadata, - } satisfies protoHttpRpc.ResponseErr, - { status: statusCode }, - ); + app.post("/connections/message", async (c) => { + if (!handlers.onConnMessage) { + throw new Error("onConnMessage handler is required"); + } + const connId = c.req.header(HEADER_CONN_ID); + const connToken = c.req.header(HEADER_CONN_TOKEN); + const actorId = await handler.getActorId(); + if (!connId || !connToken) { + throw new Error("Missing required parameters"); } + return handleConnectionMessage( + c, + appConfig, + handlers.onConnMessage, + connId, + connToken, + actorId, + ); }); if (appConfig.inspector.enabled) { app.route( "/inspect", createActorInspectorRouter( - handler.upgradeWebSocket, + upgradeWebSocket, handler.onConnectInspector, appConfig.inspector, ), @@ -388,39 +190,3 @@ export function createActorRouter( return app; } - -function getRequestEncoding(req: HonoRequest): Encoding { - const encodingRaw = req.query("encoding"); - const { data: encoding, success } = EncodingSchema.safeParse(encodingRaw); - if (!success) { - logger().warn("invalid encoding", { - encoding: encodingRaw, - }); - throw new errors.InvalidEncoding(encodingRaw); - } - - return encoding; -} - -function getRequestConnParams( - req: HonoRequest, - appConfig: AppConfig, - driverConfig: DriverConfig, -): unknown { - // Validate params size - const paramsStr = req.query("params"); - if (paramsStr && paramsStr.length > appConfig.maxConnParamLength) { - logger().warn("connection parameters too long"); - throw new errors.ConnParamsTooLong(); - } - - // Parse and validate params - try { - return typeof paramsStr === "string" ? JSON.parse(paramsStr) : undefined; - } catch (error) { - logger().warn("malformed connection parameters", { - error: stringifyError(error), - }); - throw new errors.MalformedConnParams(error); - } -} diff --git a/packages/actor-core/src/actor/unstable-react.ts b/packages/actor-core/src/actor/unstable-react.ts index 939183b7d..ccdac7ca0 100644 --- a/packages/actor-core/src/actor/unstable-react.ts +++ b/packages/actor-core/src/actor/unstable-react.ts @@ -7,7 +7,7 @@ ///** // * A React Server Components (RSC) actor. // * -// * Supports rendering React elements as RPC responses. +// * Supports rendering React elements as action responses. // * // * @see [Documentation](https://rivet.gg/docs/client/react) // * @experimental @@ -39,13 +39,13 @@ // * @private // * @internal // */ -// protected override _onBeforeRpcResponse( +// protected override _onBeforeActionResponse( // _name: string, // _args: unknown[], // output: Out, // ): Out { // if (!isValidElement(output)) { -// return super._onBeforeRpcResponse(_name, _args, output); +// return super._onBeforeActionResponse(_name, _args, output); // } // // // The output is a React element, so we need to transform it into a valid rsc message diff --git a/packages/actor-core/src/actor/utils.ts b/packages/actor-core/src/actor/utils.ts index 7464a8bea..dc8b9315e 100644 --- a/packages/actor-core/src/actor/utils.ts +++ b/packages/actor-core/src/actor/utils.ts @@ -1,6 +1,8 @@ import * as errors from "./errors"; +import { logger } from "./log"; export function assertUnreachable(x: never): never { + logger().error("unreachable", { value: `${x}`, stack: new Error().stack }); throw new errors.Unreachable(x); } @@ -35,7 +37,7 @@ export const throttle = < export class DeadlineError extends Error { constructor() { - super("Promise did not complete before deadline.") + super("Promise did not complete before deadline."); } } @@ -49,9 +51,7 @@ export function deadline(promise: Promise, timeout: number): Promise { return Promise.race([ promise, new Promise((_, reject) => { - signal.addEventListener("abort", () => - reject(new DeadlineError()), - ); + signal.addEventListener("abort", () => reject(new DeadlineError())); }), ]).finally(() => { clearTimeout(timeoutId); diff --git a/packages/actor-core/src/app/config.ts b/packages/actor-core/src/app/config.ts index 701f0d958..88d392bb4 100644 --- a/packages/actor-core/src/app/config.ts +++ b/packages/actor-core/src/app/config.ts @@ -63,6 +63,9 @@ export const AppConfigSchema = z.object({ maxIncomingMessageSize: z.number().optional().default(65_536), + /** How long to wait for the WebSocket to send an init message before closing it. */ + webSocketInitTimeout: z.number().optional().default(5_000), + /** Peer configuration for coordinated topology. */ actorPeer: ActorPeerConfigSchema.optional().default({}), diff --git a/packages/actor-core/src/client/actor-common.ts b/packages/actor-core/src/client/actor-common.ts new file mode 100644 index 000000000..d4f8a739c --- /dev/null +++ b/packages/actor-core/src/client/actor-common.ts @@ -0,0 +1,80 @@ +import type { AnyActorDefinition, ActorDefinition } from "@/actor/definition"; +import type * as protoHttpResolve from "@/actor/protocol/http/resolve"; +import type { Encoding } from "@/actor/protocol/serde"; +import type { ActorQuery } from "@/manager/protocol/query"; +import { logger } from "./log"; +import * as errors from "./errors"; +import { sendHttpRequest } from "./utils"; +import { HEADER_ACTOR_QUERY, HEADER_ENCODING } from "@/actor/router-endpoints"; + +/** + * Action function returned by Actor connections and handles. + * + * @typedef {Function} ActorActionFunction + * @template Args + * @template Response + * @param {...Args} args - Arguments for the action function. + * @returns {Promise} + */ +export type ActorActionFunction< + Args extends Array = unknown[], + Response = unknown, +> = ( + ...args: Args extends [unknown, ...infer Rest] ? Rest : Args +) => Promise; + +/** + * Maps action methods from actor definition to typed function signatures. + */ +export type ActorDefinitionActions = + AD extends ActorDefinition + ? { + [K in keyof R]: R[K] extends (...args: infer Args) => infer Return + ? ActorActionFunction + : never; + } + : never; + +/** + * Resolves an actor ID from a query by making a request to the /actors/resolve endpoint + * + * @param {string} endpoint - The manager endpoint URL + * @param {ActorQuery} actorQuery - The query to resolve + * @param {Encoding} encodingKind - The encoding to use (json or cbor) + * @returns {Promise} - A promise that resolves to the actor's ID + */ +export async function resolveActorId( + endpoint: string, + actorQuery: ActorQuery, + encodingKind: Encoding, +): Promise { + logger().debug("resolving actor ID", { query: actorQuery }); + + try { + const result = await sendHttpRequest< + Record, + protoHttpResolve.ResolveResponse + >({ + url: `${endpoint}/actors/resolve`, + method: "POST", + headers: { + [HEADER_ENCODING]: encodingKind, + [HEADER_ACTOR_QUERY]: JSON.stringify(actorQuery), + }, + body: {}, + encoding: encodingKind, + }); + + logger().debug("resolved actor ID", { actorId: result.i }); + return result.i; + } catch (error) { + logger().error("failed to resolve actor ID", { error }); + if (error instanceof errors.ActorError) { + throw error; + } else { + throw new errors.InternalError( + `Failed to resolve actor ID: ${String(error)}`, + ); + } + } +} diff --git a/packages/actor-core/src/client/actor_conn.ts b/packages/actor-core/src/client/actor-conn.ts similarity index 70% rename from packages/actor-core/src/client/actor_conn.ts rename to packages/actor-core/src/client/actor-conn.ts index 4500aaa6e..71276df8e 100644 --- a/packages/actor-core/src/client/actor_conn.ts +++ b/packages/actor-core/src/client/actor-conn.ts @@ -1,20 +1,34 @@ +import type { AnyActorDefinition } from "@/actor/definition"; import type { Transport } from "@/actor/protocol/message/mod"; -import type { Encoding } from "@/actor/protocol/serde"; import type * as wsToClient from "@/actor/protocol/message/to-client"; import type * as wsToServer from "@/actor/protocol/message/to-server"; +import type { Encoding } from "@/actor/protocol/serde"; +import { importEventSource } from "@/common/eventsource"; import { MAX_CONN_PARAMS_SIZE } from "@/common/network"; +import { httpUserAgent } from "@/utils"; import { assertUnreachable, stringifyError } from "@/common/utils"; +import { importWebSocket } from "@/common/websocket"; +import type { ActorQuery } from "@/manager/protocol/query"; import * as cbor from "cbor-x"; +import pRetry from "p-retry"; +import { ACTOR_CONNS_SYMBOL, type ClientRaw, TRANSPORT_SYMBOL } from "./client"; import * as errors from "./errors"; import { logger } from "./log"; import { type WebSocketMessage as ConnMessage, messageLength } from "./utils"; -import { ACTOR_CONNS_SYMBOL, ClientRaw, DynamicImports } from "./client"; -import { ActorDefinition, AnyActorDefinition } from "@/actor/definition"; -import pRetry, { AbortError } from "p-retry"; - -interface RpcInFlight { +import { + HEADER_ACTOR_ID, + HEADER_ACTOR_QUERY, + HEADER_CONN_ID, + HEADER_CONN_TOKEN, + HEADER_ENCODING, + HEADER_CONN_PARAMS, +} from "@/actor/router-endpoints"; +import type { EventSource } from "eventsource"; +import { ActorDefinitionActions } from "./actor-common"; + +interface ActionInFlight { name: string; - resolve: (response: wsToClient.RpcResponseOk) => void; + resolve: (response: wsToClient.ActionResponse) => void; reject: (error: Error) => void; } @@ -30,6 +44,13 @@ interface EventSubscriptions> { */ export type EventUnsubscribe = () => void; +/** + * A function that handles connection errors. + * + * @typedef {Function} ActorErrorCallback + */ +export type ActorErrorCallback = (error: errors.ActorError) => void; + interface SendOpts { ephemeral: boolean; } @@ -38,6 +59,11 @@ export type ConnTransport = { websocket: WebSocket } | { sse: EventSource }; export const CONNECT_SYMBOL = Symbol("connect"); +interface DynamicImports { + WebSocket: typeof WebSocket; + EventSource: typeof EventSource; +} + /** * Provides underlying functions for {@link ActorConn}. See {@link ActorConn} for using type-safe remote procedure calls. * @@ -50,21 +76,24 @@ export class ActorConnRaw { #abortController = new AbortController(); /** If attempting to connect. Helpful for knowing if in a retry loop when reconnecting. */ - #connecting: boolean = false; + #connecting = false; // These will only be set on SSE driver + #actorId?: string; #connectionId?: string; #connectionToken?: string; #transport?: ConnTransport; #messageQueue: wsToServer.ToServer[] = []; - #rpcInFlight = new Map(); + #actionsInFlight = new Map(); // biome-ignore lint/suspicious/noExplicitAny: Unknown subscription type #eventSubscriptions = new Map>>(); - #rpcIdCounter = 0; + #errorHandlers = new Set(); + + #actionIdCounter = 0; /** * Interval that keeps the NodeJS process alive if this is the only thing running. @@ -73,11 +102,17 @@ export class ActorConnRaw { */ #keepNodeAliveInterval: NodeJS.Timeout; + /** Promise used to indicate the required properties for using this class have loaded. Currently just #dynamicImports */ + #onConstructedPromise: Promise; + /** Promise used to indicate the socket has connected successfully. This will be rejected if the connection fails. */ #onOpenPromise?: PromiseWithResolvers; // TODO: ws message queue + // External imports + #dynamicImports!: DynamicImports; + /** * Do not call this directly. * @@ -92,42 +127,53 @@ export class ActorConnRaw { private readonly endpoint: string, private readonly params: unknown, private readonly encodingKind: Encoding, - private readonly supportedTransports: Transport[], - private readonly serverTransports: Transport[], - private readonly dynamicImports: DynamicImports, + private readonly actorQuery: ActorQuery, ) { this.#keepNodeAliveInterval = setInterval(() => 60_000); + + this.#onConstructedPromise = (async () => { + // Import dynamic dependencies + const [WebSocket, EventSource] = await Promise.all([ + importWebSocket(), + importEventSource(), + ]); + this.#dynamicImports = { + WebSocket, + EventSource, + }; + })(); } /** - * Call a raw RPC connection. See {@link ActorConn} for type-safe RPC calls. + * Call a raw action connection. See {@link ActorConn} for type-safe action calls. * * @see {@link ActorConn} - * @template Args - The type of arguments to pass to the RPC function. - * @template Response - The type of the response returned by the RPC function. - * @param {string} name - The name of the RPC function to call. - * @param {...Args} args - The arguments to pass to the RPC function. - * @returns {Promise} - A promise that resolves to the response of the RPC function. + * @template Args - The type of arguments to pass to the action function. + * @template Response - The type of the response returned by the action function. + * @param {string} name - The name of the action function to call. + * @param {...Args} args - The arguments to pass to the action function. + * @returns {Promise} - A promise that resolves to the response of the action function. */ async action = unknown[], Response = unknown>( name: string, ...args: Args ): Promise { - logger().debug("action", { name, args }); + await this.#onConstructedPromise; - // TODO: Add to queue if socket is not open + logger().debug("action", { name, args }); - const rpcId = this.#rpcIdCounter; - this.#rpcIdCounter += 1; + // If we have an active connection, use the websockactionId + const actionId = this.#actionIdCounter; + this.#actionIdCounter += 1; const { promise, resolve, reject } = - Promise.withResolvers(); - this.#rpcInFlight.set(rpcId, { name, resolve, reject }); + Promise.withResolvers(); + this.#actionsInFlight.set(actionId, { name, resolve, reject }); this.#sendMessage({ b: { - rr: { - i: rpcId, + ar: { + i: actionId, n: name, a: args, }, @@ -137,32 +183,14 @@ export class ActorConnRaw { // TODO: Throw error if disconnect is called const { i: responseId, o: output } = await promise; - if (responseId !== rpcId) + if (responseId !== actionId) throw new Error( - `Request ID ${rpcId} does not match response ID ${responseId}`, + `Request ID ${actionId} does not match response ID ${responseId}`, ); return output as Response; } - //async #rpcHttp = unknown[], Response = unknown>(name: string, ...args: Args): Promise { - // const origin = `${resolved.isTls ? "https": "http"}://${resolved.publicHostname}:${resolved.publicPort}`; - // const url = `${origin}/rpc/${encodeURIComponent(name)}`; - // const res = await fetch(url, { - // method: "POST", - // // TODO: Import type from protocol - // body: JSON.stringify({ - // args, - // }) - // }); - // if (!res.ok) { - // throw new Error(`RPC error (${res.statusText}):\n${await res.text()}`); - // } - // // TODO: Import type from protocol - // const resJson: httpRpc.ResponseOk = await res.json(); - // return resJson.output; - //} - /** * Do not call this directly. enc @@ -210,19 +238,20 @@ enc async #connectAndWait() { try { + await this.#onConstructedPromise; + // Create promise for open if (this.#onOpenPromise) throw new Error("#onOpenPromise already defined"); this.#onOpenPromise = Promise.withResolvers(); // Connect transport - const transport = this.#pickTransport(); - if (transport === "websocket") { + if (this.client[TRANSPORT_SYMBOL] === "websocket") { this.#connectWebSocket(); - } else if (transport === "sse") { + } else if (this.client[TRANSPORT_SYMBOL] === "sse") { this.#connectSse(); } else { - assertUnreachable(transport); + assertUnreachable(this.client[TRANSPORT_SYMBOL]); } // Wait for result @@ -232,23 +261,14 @@ enc } } - #pickTransport(): Transport { - // Choose first supported transport from server's list that client also supports - const transport = this.serverTransports.find((t) => - this.supportedTransports.includes(t), - ); - - if (!transport) { - throw new errors.NoSupportedTransport(); - } - - return transport; - } - #connectWebSocket() { - const { WebSocket } = this.dynamicImports; + const { WebSocket } = this.#dynamicImports; - const url = this.#buildConnUrl("websocket"); + const actorQueryStr = encodeURIComponent(JSON.stringify(this.actorQuery)); + const endpoint = this.endpoint + .replace(/^http:/, "ws:") + .replace(/^https:/, "wss:"); + const url = `${endpoint}/actors/connect/websocket?encoding=${this.encodingKind}&query=${actorQueryStr}`; logger().debug("connecting to websocket", { url }); const ws = new WebSocket(url); @@ -265,7 +285,16 @@ enc this.#transport = { websocket: ws }; ws.onopen = () => { logger().debug("websocket open"); - // #handleOnOpen is called on "i" event + + // Set init message + this.#sendMessage( + { + b: { i: { p: this.params } }, + }, + { ephemeral: true }, + ); + + // #handleOnOpen is called on "i" event from the server }; ws.onmessage = async (ev) => { this.#handleOnMessage(ev); @@ -279,12 +308,27 @@ enc } #connectSse() { - const { EventSource } = this.dynamicImports; + const { EventSource } = this.#dynamicImports; - const url = this.#buildConnUrl("sse"); + const url = `${this.endpoint}/actors/connect/sse`; logger().debug("connecting to sse", { url }); - const eventSource = new EventSource(url); + const eventSource = new EventSource(url, { + fetch: (input, init) => { + return fetch(input, { + ...init, + headers: { + ...init?.headers, + "User-Agent": httpUserAgent(), + [HEADER_ENCODING]: this.encodingKind, + [HEADER_ACTOR_QUERY]: JSON.stringify(this.actorQuery), + ...(this.params !== undefined + ? { [HEADER_CONN_PARAMS]: JSON.stringify(this.params) } + : {}), + }, + }); + }, + }); this.#transport = { sse: eventSource }; eventSource.onopen = () => { logger().debug("eventsource open"); @@ -334,70 +378,87 @@ enc /** Called by the onmessage event from drivers. */ async #handleOnMessage(event: MessageEvent) { - logger().trace("received message", { - dataType: typeof event.data, + logger().trace("received message", { + dataType: typeof event.data, isBlob: event.data instanceof Blob, - isArrayBuffer: event.data instanceof ArrayBuffer + isArrayBuffer: event.data instanceof ArrayBuffer, }); const response = (await this.#parse(event.data)) as wsToClient.ToClient; - logger().trace("parsed message", { - response: JSON.stringify(response).substring(0, 100) + "..." + logger().trace("parsed message", { + response: JSON.stringify(response).substring(0, 100) + "...", }); if ("i" in response.b) { // This is only called for SSE + this.#actorId = response.b.i.ai; this.#connectionId = response.b.i.ci; this.#connectionToken = response.b.i.ct; - logger().trace("received init message", { - connectionId: this.#connectionId + logger().trace("received init message", { + actorId: this.#actorId, + connectionId: this.#connectionId, }); this.#handleOnOpen(); - } else if ("ro" in response.b) { - // RPC response OK - const { i: rpcId } = response.b.ro; - logger().trace("received RPC response", { - rpcId, - outputType: typeof response.b.ro.o - }); + } else if ("e" in response.b) { + // Connection error + const { c: code, m: message, md: metadata, ai: actionId } = response.b.e; + + if (actionId) { + const inFlight = this.#takeActionInFlight(actionId); + + logger().warn("action error", { + actionId: actionId, + actionName: inFlight?.name, + code, + message, + metadata, + }); + + inFlight.reject(new errors.ActorError(code, message, metadata)); + } else { + logger().warn("connection error", { + code, + message, + metadata, + }); + + // Create a connection error + const actorError = new errors.ActorError(code, message, metadata); + + // If we have an onOpenPromise, reject it with the error + if (this.#onOpenPromise) { + this.#onOpenPromise.reject(actorError); + } - const inFlight = this.#takeRpcInFlight(rpcId); - logger().trace("resolving RPC promise", { - rpcId, - actionName: inFlight?.name - }); - inFlight.resolve(response.b.ro); - } else if ("re" in response.b) { - // RPC response error - const { i: rpcId, c: code, m: message, md: metadata } = response.b.re; - logger().trace("received RPC error", { rpcId, code, message }); + // Reject any in-flight requests + for (const [id, inFlight] of this.#actionsInFlight.entries()) { + inFlight.reject(actorError); + this.#actionsInFlight.delete(id); + } - const inFlight = this.#takeRpcInFlight(rpcId); + // Dispatch to error handler if registered + this.#dispatchActorError(actorError); + } + } else if ("ar" in response.b) { + // Action response OK + const { i: actionId, o: outputType } = response.b.ar; + logger().trace("received action response", { + actionId, + outputType, + }); - logger().warn("actor error", { - actionId: rpcId, + const inFlight = this.#takeActionInFlight(actionId); + logger().trace("resolving action promise", { + actionId, actionName: inFlight?.name, - code, - message, - metadata, }); - - inFlight.reject(new errors.ActionError(code, message, metadata)); + inFlight.resolve(response.b.ar); } else if ("ev" in response.b) { - logger().trace("received event", { - name: response.b.ev.n, - argsCount: response.b.ev.a?.length + logger().trace("received event", { + name: response.b.ev.n, + argsCount: response.b.ev.a?.length, }); this.#dispatchEvent(response.b.ev); - } else if ("er" in response.b) { - const { c: code, m: message, md: metadata } = response.b.er; - logger().trace("received error", { code, message }); - - logger().warn("actor error", { - code, - message, - metadata, - }); } else { assertUnreachable(response.b); } @@ -452,37 +513,16 @@ enc logger().warn("socket error", { event }); } - #buildConnUrl(transport: Transport): string { - let url = `${this.endpoint}/connect/${transport}?encoding=${this.encodingKind}`; - - if (this.params !== undefined) { - const paramsStr = JSON.stringify(this.params); - - // TODO: This is an imprecise count since it doesn't count the full URL length & URI encoding expansion in the URL size - if (paramsStr.length > MAX_CONN_PARAMS_SIZE) { - throw new errors.ConnParamsTooLong(); - } - - url += `¶ms=${encodeURIComponent(paramsStr)}`; - } - - if (transport === "websocket") { - url = url.replace(/^http:/, "ws:").replace(/^https:/, "wss:"); - } - - return url; - } - - #takeRpcInFlight(id: number): RpcInFlight { - const inFlight = this.#rpcInFlight.get(id); + #takeActionInFlight(id: number): ActionInFlight { + const inFlight = this.#actionsInFlight.get(id); if (!inFlight) { throw new errors.InternalError(`No in flight response for ${id}`); } - this.#rpcInFlight.delete(id); + this.#actionsInFlight.delete(id); return inFlight; } - #dispatchEvent(event: wsToClient.ToClientEvent) { + #dispatchEvent(event: wsToClient.Event) { const { n: name, a: args } = event; const listeners = this.#eventSubscriptions.get(name); @@ -504,6 +544,19 @@ enc } } + #dispatchActorError(error: errors.ActorError) { + // Call all registered error handlers + for (const handler of [...this.#errorHandlers]) { + try { + handler(error); + } catch (err) { + logger().error("Error in connection error handler", { + error: stringifyError(err), + }); + } + } + } + #addEventSubscription>( eventName: string, callback: (...args: Args) => void, @@ -567,13 +620,32 @@ enc return this.#addEventSubscription(eventName, callback, true); } + /** + * Subscribes to connection errors. + * + * @param {ActorErrorCallback} callback - The callback function to execute when a connection error occurs. + * @returns {() => void} - A function to unsubscribe from the error handler. + */ + onError(callback: ActorErrorCallback): () => void { + this.#errorHandlers.add(callback); + + // Return unsubscribe function + return () => { + this.#errorHandlers.delete(callback); + }; + } + #sendMessage(message: wsToServer.ToServer, opts?: SendOpts) { - let queueMessage: boolean = false; + if (this.#disposed) { + throw new errors.ActorConnDisposed(); + } + + let queueMessage = false; if (!this.#transport) { // No transport connected yet queueMessage = true; } else if ("websocket" in this.#transport) { - const { WebSocket } = this.dynamicImports; + const { WebSocket } = this.#dynamicImports; if (this.#transport.websocket.readyState === WebSocket.OPEN) { try { const messageSerialized = this.#serialize(message); @@ -594,7 +666,7 @@ enc queueMessage = true; } } else if ("sse" in this.#transport) { - const { EventSource } = this.dynamicImports; + const { EventSource } = this.#dynamicImports; if (this.#transport.sse.readyState === EventSource.OPEN) { // Spawn in background since #sendMessage cannot be async @@ -614,16 +686,21 @@ enc async #sendHttpMessage(message: wsToServer.ToServer, opts?: SendOpts) { try { - if (!this.#connectionId || !this.#connectionToken) + if (!this.#actorId || !this.#connectionId || !this.#connectionToken) throw new errors.InternalError("Missing connection ID or token."); - let url = `${this.endpoint}/connections/${this.#connectionId}/message?encoding=${this.encodingKind}&connectionToken=${encodeURIComponent(this.#connectionToken)}`; - // TODO: Implement ordered messages, this is not guaranteed order. Needs to use an index in order to ensure we can pipeline requests efficiently. // TODO: Validate that we're using HTTP/3 whenever possible for pipelining requests const messageSerialized = this.#serialize(message); - const res = await fetch(url, { + const res = await fetch(`${this.endpoint}/actors/message`, { method: "POST", + headers: { + "User-Agent": httpUserAgent(), + [HEADER_ENCODING]: this.encodingKind, + [HEADER_ACTOR_ID]: this.#actorId, + [HEADER_CONN_ID]: this.#connectionId, + [HEADER_CONN_TOKEN]: this.#connectionToken, + }, body: messageSerialized, }); @@ -636,7 +713,7 @@ enc // Dispose of the response body, we don't care about it await res.json(); } catch (error) { - // TODO: This will not automatically trigger a re-broadcast of HTTP events since SSE is separate from the HTTP RPC + // TODO: This will not automatically trigger a re-broadcast of HTTP events since SSE is separate from the HTTP action logger().warn("failed to send message, added to queue", { error, @@ -712,6 +789,8 @@ enc * @returns {Promise} A promise that resolves when the socket is gracefully closed. */ async dispose(): Promise { + await this.#onConstructedPromise; + // Internally, this "disposes" the connection if (this.#disposed) { @@ -764,24 +843,13 @@ enc } } -type ExtractActorDefinitionRpcs = - AD extends ActorDefinition ? R : never; - -type ActorDefinitionRpcs = { - [K in keyof ExtractActorDefinitionRpcs]: ExtractActorDefinitionRpcs[K] extends ( - ...args: infer Args - ) => infer Return - ? ActorRPCFunction - : never; -}; - /** * Connection to an actor. Allows calling actor's remote procedure calls with inferred types. See {@link ActorConnRaw} for underlying methods. * * @example * ``` - * const room = await client.connect(...etc...); - * // This calls the rpc named `sendMessage` on the `ChatRoom` actor. + * const room = client.connect(...etc...); + * // This calls the action named `sendMessage` on the `ChatRoom` actor. * await room.sendMessage('Hello, world!'); * ``` * @@ -790,26 +858,5 @@ type ActorDefinitionRpcs = { * @template AD The actor class that this connection is for. * @see {@link ActorConnRaw} */ - export type ActorConn = ActorConnRaw & - ActorDefinitionRpcs; - -//{ -// [K in keyof A as K extends string ? K extends `_${string}` ? never : K : K]: A[K] extends (...args: infer Args) => infer Return ? ActorRPCFunction : never; -//}; -/** - * RPC function returned by `ActorConn`. This will call `ActorConn.rpc` when triggered. - * - * @typedef {Function} ActorRPCFunction - * @template Args - * @template Response - * @param {...Args} args - Arguments for the RPC function. - * @returns {Promise} - */ - -export type ActorRPCFunction< - Args extends Array = unknown[], - Response = unknown, -> = ( - ...args: Args extends [unknown, ...infer Rest] ? Rest : Args -) => Promise; + ActorDefinitionActions; diff --git a/packages/actor-core/src/client/actor-handle.ts b/packages/actor-core/src/client/actor-handle.ts new file mode 100644 index 000000000..0cb3bbb7e --- /dev/null +++ b/packages/actor-core/src/client/actor-handle.ts @@ -0,0 +1,166 @@ +import type { AnyActorDefinition } from "@/actor/definition"; +import type { ActionRequest, ActionResponse } from "@/actor/protocol/http/action"; +import type { Encoding } from "@/actor/protocol/serde"; +import type { ActorQuery } from "@/manager/protocol/query"; +import { type ActorDefinitionActions, resolveActorId } from "./actor-common"; +import { type ActorConn, ActorConnRaw } from "./actor-conn"; +import { CREATE_ACTOR_CONN_PROXY, type ClientRaw } from "./client"; +import { logger } from "./log"; +import { sendHttpRequest } from "./utils"; +import invariant from "invariant"; +import { assertUnreachable } from "@/actor/utils"; +import { + HEADER_ACTOR_QUERY, + HEADER_CONN_PARAMS, + HEADER_ENCODING, +} from "@/actor/router-endpoints"; + +/** + * Provides underlying functions for stateless {@link ActorHandle} for action calls. + * Similar to ActorConnRaw but doesn't maintain a connection. + * + * @see {@link ActorHandle} + */ +export class ActorHandleRaw { + #client: ClientRaw; + #endpoint: string; + #encodingKind: Encoding; + #actorQuery: ActorQuery; + + /** + * Do not call this directly. + * + * Creates an instance of ActorHandleRaw. + * + * @param {string} endpoint - The endpoint to connect to. + * + * @protected + */ + public constructor( + client: any, + endpoint: string, + private readonly params: unknown, + encodingKind: Encoding, + actorQuery: ActorQuery, + ) { + this.#client = client; + this.#endpoint = endpoint; + this.#encodingKind = encodingKind; + this.#actorQuery = actorQuery; + } + + /** + * Call a raw action. This method sends an HTTP request to invoke the named action. + * + * @see {@link ActorHandle} + * @template Args - The type of arguments to pass to the action function. + * @template Response - The type of the response returned by the action function. + * @param {string} name - The name of the action function to call. + * @param {...Args} args - The arguments to pass to the action function. + * @returns {Promise} - A promise that resolves to the response of the action function. + */ + async action = unknown[], Response = unknown>( + name: string, + ...args: Args + ): Promise { + logger().debug("actor handle action", { + name, + args, + query: this.#actorQuery, + }); + + const responseData = await sendHttpRequest({ + url: `${this.#endpoint}/actors/actions/${encodeURIComponent(name)}`, + method: "POST", + headers: { + [HEADER_ENCODING]: this.#encodingKind, + [HEADER_ACTOR_QUERY]: JSON.stringify(this.#actorQuery), + ...(this.params !== undefined + ? { [HEADER_CONN_PARAMS]: JSON.stringify(this.params) } + : {}), + }, + body: { a: args } satisfies ActionRequest, + encoding: this.#encodingKind, + }); + + return responseData.o as Response; + } + + /** + * Establishes a persistent connection to the actor. + * + * @template AD The actor class that this connection is for. + * @returns {ActorConn} A connection to the actor. + */ + connect(): ActorConn { + logger().debug("establishing connection from handle", { + query: this.#actorQuery, + }); + + const conn = new ActorConnRaw( + this.#client, + this.#endpoint, + this.params, + this.#encodingKind, + this.#actorQuery, + ); + + return this.#client[CREATE_ACTOR_CONN_PROXY]( + conn, + ) as ActorConn; + } + + /** + * Resolves the actor to get its unique actor ID + * + * @returns {Promise} - A promise that resolves to the actor's ID + */ + async resolve(): Promise { + if ( + "getForKey" in this.#actorQuery || + "getOrCreateForKey" in this.#actorQuery + ) { + const actorId = await resolveActorId( + this.#endpoint, + this.#actorQuery, + this.#encodingKind, + ); + this.#actorQuery = { getForId: { actorId } }; + return actorId; + } else if ("getForId" in this.#actorQuery) { + // SKip since it's already resolved + return this.#actorQuery.getForId.actorId; + } else if ("create" in this.#actorQuery) { + // Cannot create a handle with this query + invariant(false, "actorQuery cannot be create"); + } else { + assertUnreachable(this.#actorQuery); + } + } +} + +/** + * Stateless handle to an actor. Allows calling actor's remote procedure calls with inferred types + * without establishing a persistent connection. + * + * @example + * ``` + * const room = client.get(...etc...); + * // This calls the action named `sendMessage` on the `ChatRoom` actor without a connection. + * await room.sendMessage('Hello, world!'); + * ``` + * + * Private methods (e.g. those starting with `_`) are automatically excluded. + * + * @template AD The actor class that this handle is for. + * @see {@link ActorHandleRaw} + */ +export type ActorHandle = Omit< + ActorHandleRaw, + "connect" +> & { + // Add typed version of ActorConn (instead of using AnyActorDefinition) + connect(): ActorConn; + // Resolve method returns the actor ID + resolve(): Promise; +} & ActorDefinitionActions; diff --git a/packages/actor-core/src/client/client.ts b/packages/actor-core/src/client/client.ts index 32f01fbed..40db2ffb1 100644 --- a/packages/actor-core/src/client/client.ts +++ b/packages/actor-core/src/client/client.ts @@ -1,23 +1,12 @@ import type { Transport } from "@/actor/protocol/message/mod"; import type { Encoding } from "@/actor/protocol/serde"; -import type { ActorKey } from "@/common//utils"; -import type { - ActorsRequest, - ActorsResponse, - //RivetConfigResponse, -} from "@/manager/protocol/mod"; -import type { CreateRequest } from "@/manager/protocol/query"; +import type { ActorQuery } from "@/manager/protocol/query"; import * as errors from "./errors"; -import { - ActorConn, - ActorConnRaw, - ActorRPCFunction, - CONNECT_SYMBOL, -} from "./actor_conn"; +import { ActorConn, ActorConnRaw, CONNECT_SYMBOL } from "./actor-conn"; +import { ActorHandle, ActorHandleRaw } from "./actor-handle"; +import { ActorActionFunction, resolveActorId } from "./actor-common"; import { logger } from "./log"; -import { importWebSocket } from "@/common/websocket"; -import { importEventSource } from "@/common/eventsource"; -import { ActorCoreApp } from "@/mod"; +import type { ActorCoreApp } from "@/mod"; import type { AnyActorDefinition } from "@/actor/definition"; /** Extract the actor registry from the app definition. */ @@ -33,36 +22,50 @@ export type ExtractAppFromClient>> = */ export interface ActorAccessor { /** - * Connects to an actor by its key, creating it if necessary. + * Gets a stateless handle to an actor by its key, but does not create the actor if it doesn't exist. * The actor name is automatically injected from the property accessor. * - * @template A The actor class that this connection is for. + * @template AD The actor class that this handle is for. * @param {string | string[]} [key=[]] - The key to identify the actor. Can be a single string or an array of strings. - * @param {GetOptions} [opts] - Options for getting the actor. - * @returns {Promise>} - A promise resolving to the actor connection. + * @param {GetWithIdOptions} [opts] - Options for getting the actor. + * @returns {ActorHandle} - A handle to the actor. */ - connect(key?: string | string[], opts?: GetOptions): Promise>; + get(key?: string | string[], opts?: GetWithIdOptions): ActorHandle; /** - * Creates a new actor with the name automatically injected from the property accessor, - * and connects to it. + * Gets a stateless handle to an actor by its key, creating it if necessary. + * The actor name is automatically injected from the property accessor. * - * @template A The actor class that this connection is for. - * @param {string | string[]} key - The key to identify the actor. Can be a single string or an array of strings. - * @param {CreateOptions} [opts] - Options for creating the actor (excluding name and key). - * @returns {Promise>} - A promise resolving to the actor connection. + * @template AD The actor class that this handle is for. + * @param {string | string[]} [key=[]] - The key to identify the actor. Can be a single string or an array of strings. + * @param {GetOptions} [opts] - Options for getting the actor. + * @returns {ActorHandle} - A handle to the actor. */ - createAndConnect(key: string | string[], opts?: CreateOptions): Promise>; + getOrCreate(key?: string | string[], opts?: GetOptions): ActorHandle; /** - * Connects to an actor by its ID. + * Gets a stateless handle to an actor by its ID. * - * @template A The actor class that this connection is for. + * @template AD The actor class that this handle is for. * @param {string} actorId - The ID of the actor. * @param {GetWithIdOptions} [opts] - Options for getting the actor. - * @returns {Promise>} - A promise resolving to the actor connection. + * @returns {ActorHandle} - A handle to the actor. + */ + getForId(actorId: string, opts?: GetWithIdOptions): ActorHandle; + + /** + * Creates a new actor with the name automatically injected from the property accessor, + * and returns a stateless handle to it with the actor ID resolved. + * + * @template AD The actor class that this handle is for. + * @param {string | string[]} key - The key to identify the actor. Can be a single string or an array of strings. + * @param {CreateOptions} [opts] - Options for creating the actor (excluding name and key). + * @returns {Promise>} - A promise that resolves to a handle to the actor. */ - connectForId(actorId: string, opts?: GetWithIdOptions): Promise>; + create( + key: string | string[], + opts?: CreateOptions, + ): Promise>; } /** @@ -71,7 +74,7 @@ export interface ActorAccessor { */ export interface ClientOptions { encoding?: Encoding; - supportedTransports?: Transport[]; + transport?: Transport; } /** @@ -93,22 +96,28 @@ export interface GetWithIdOptions extends QueryOptions {} /** * Options for getting an actor. * @typedef {QueryOptions} GetOptions - * @property {boolean} [noCreate] - Prevents creating a new actor if one does not exist. - * @property {Partial} [create] - Config used to create the actor. + */ +export interface GetOptions extends QueryOptions {} + +/** + * Options for getting or creating an actor. + * @typedef {QueryOptions} GetOrCreateOptions + * @property {string} [createInRegion] - Region to create the actor in if it doesn't exist. */ export interface GetOptions extends QueryOptions { - /** Prevents creating a new actor if one does not exist. */ - noCreate?: boolean; - /** Config used to create the actor. */ - create?: Partial>; + /** Region to create the actor in if it doesn't exist. */ + createInRegion?: string; } /** * Options for creating an actor. * @typedef {QueryOptions} CreateOptions - * @property {Object} - Additional options for actor creation excluding name and key that come from the key parameter. + * @property {string} [region] - The region to create the actor in. */ -export interface CreateOptions extends QueryOptions, Omit {} +export interface CreateOptions extends QueryOptions { + /** The region to create the actor in. */ + region?: string; +} /** * Represents a region to connect to. @@ -130,12 +139,9 @@ export interface Region { name: string; } -export interface DynamicImports { - WebSocket: typeof WebSocket; - EventSource: typeof EventSource; -} - export const ACTOR_CONNS_SYMBOL = Symbol("actorConns"); +export const CREATE_ACTOR_CONN_PROXY = Symbol("createActorConnProxy"); +export const TRANSPORT_SYMBOL = Symbol("transport"); /** * Client for managing & connecting to actors. @@ -148,213 +154,156 @@ export class ClientRaw { [ACTOR_CONNS_SYMBOL] = new Set(); - #managerEndpointPromise: Promise; - //#regionPromise: Promise; + #managerEndpoint: string; #encodingKind: Encoding; - #supportedTransports: Transport[]; - - // External imports - #dynamicImportsPromise: Promise; + [TRANSPORT_SYMBOL]: Transport; /** * Creates an instance of Client. * - * @param {string | Promise} managerEndpointPromise - The manager endpoint or a promise resolving to it. See {@link https://rivet.gg/docs/setup|Initial Setup} for instructions on getting the manager endpoint. + * @param {string} managerEndpoint - The manager endpoint. See {@link https://rivet.gg/docs/setup|Initial Setup} for instructions on getting the manager endpoint. * @param {ClientOptions} [opts] - Options for configuring the client. * @see {@link https://rivet.gg/docs/setup|Initial Setup} */ - public constructor( - managerEndpointPromise: string | Promise, - opts?: ClientOptions, - ) { - if (managerEndpointPromise instanceof Promise) { - // Save promise - this.#managerEndpointPromise = managerEndpointPromise; - } else { - // Convert to promise - this.#managerEndpointPromise = new Promise((resolve) => - resolve(managerEndpointPromise), - ); - } - - //this.#regionPromise = this.#fetchRegion(); + public constructor(managerEndpoint: string, opts?: ClientOptions) { + this.#managerEndpoint = managerEndpoint; this.#encodingKind = opts?.encoding ?? "cbor"; - this.#supportedTransports = opts?.supportedTransports ?? [ - "websocket", - "sse", - ]; - - // Import dynamic dependencies - this.#dynamicImportsPromise = (async () => { - const WebSocket = await importWebSocket(); - const EventSource = await importEventSource(); - return { WebSocket, EventSource }; - })(); + this[TRANSPORT_SYMBOL] = opts?.transport ?? "websocket"; } /** - * Connects to an actor by its ID. - * @template AD The actor class that this connection is for. + * Gets a stateless handle to an actor by its ID. + * + * @template AD The actor class that this handle is for. * @param {string} name - The name of the actor. * @param {string} actorId - The ID of the actor. * @param {GetWithIdOptions} [opts] - Options for getting the actor. - * @returns {Promise>} - A promise resolving to the actor connection. + * @returns {ActorHandle} - A handle to the actor. */ - async connectForId( + getForId( name: string, actorId: string, opts?: GetWithIdOptions, - ): Promise> { - logger().debug("connect to actor with id ", { + ): ActorHandle { + logger().debug("get handle to actor with id", { name, actorId, params: opts?.params, }); - const resJson = await this.#sendManagerRequest< - ActorsRequest, - ActorsResponse - >("POST", "/manager/actors", { - query: { - getForId: { - actorId, - }, + const actorQuery = { + getForId: { + actorId, }, - }); + }; - const conn = await this.#createConn( - resJson.endpoint, + const managerEndpoint = this.#managerEndpoint; + const handle = this.#createHandle( + managerEndpoint, opts?.params, - resJson.supportedTransports, + actorQuery, ); - return this.#createProxy(conn) as ActorConn; + return createActorProxy(handle) as ActorHandle; } /** - * Connects to an actor by its key, creating it if necessary. - * - * @example - * ``` - * const room = await client.connect( - * 'chat-room', - * // Get or create the actor for the channel `random` - * 'random', - * ); - * - * // Or using an array of strings as key - * const room = await client.connect( - * 'chat-room', - * ['user123', 'room456'], - * ); + * Gets a stateless handle to an actor by its key, but does not create the actor if it doesn't exist. * - * await room.sendMessage('Hello, world!'); - * ``` + * @template AD The actor class that this handle is for. + * @param {string} name - The name of the actor. + * @param {string | string[]} [key=[]] - The key to identify the actor. Can be a single string or an array of strings. + * @param {GetWithIdOptions} [opts] - Options for getting the actor. + * @returns {ActorHandle} - A handle to the actor. + */ + get( + name: string, + key?: string | string[], + opts?: GetWithIdOptions, + ): ActorHandle { + // Convert string to array of strings + const keyArray: string[] = typeof key === "string" ? [key] : key || []; + + logger().debug("get handle to actor", { + name, + key: keyArray, + parameters: opts?.params, + }); + + const actorQuery: ActorQuery = { + getForKey: { + name, + key: keyArray, + }, + }; + + const managerEndpoint = this.#managerEndpoint; + const handle = this.#createHandle( + managerEndpoint, + opts?.params, + actorQuery, + ); + return createActorProxy(handle) as ActorHandle; + } + + /** + * Gets a stateless handle to an actor by its key, creating it if necessary. * - * @template AD The actor class that this connection is for. + * @template AD The actor class that this handle is for. * @param {string} name - The name of the actor. * @param {string | string[]} [key=[]] - The key to identify the actor. Can be a single string or an array of strings. * @param {GetOptions} [opts] - Options for getting the actor. - * @returns {Promise>} - A promise resolving to the actor connection. - * @see {@link https://rivet.gg/docs/manage#client.connect} + * @returns {ActorHandle} - A handle to the actor. */ - async connect( + getOrCreate( name: string, key?: string | string[], opts?: GetOptions, - ): Promise> { + ): ActorHandle { // Convert string to array of strings - const keyArray: string[] = typeof key === 'string' ? [key] : (key || []); + const keyArray: string[] = typeof key === "string" ? [key] : key || []; - // Build create config - let create: CreateRequest | undefined = undefined; - if (!opts?.noCreate) { - create = { - name, - // Fall back to key defined when querying actor - key: opts?.create?.key ?? keyArray, - ...opts?.create, - }; - } - - logger().debug("connect to actor", { + logger().debug("get or create handle to actor", { name, key: keyArray, parameters: opts?.params, - create, + createInRegion: opts?.createInRegion, }); - let requestQuery; - if (opts?.noCreate) { - // Use getForKey endpoint if noCreate is specified - requestQuery = { - getForKey: { - name, - key: keyArray, - }, - }; - } else { - // Use getOrCreateForKey endpoint - requestQuery = { - getOrCreateForKey: { - name, - key: keyArray, - region: create?.region, - }, - }; - } - - const resJson = await this.#sendManagerRequest< - ActorsRequest, - ActorsResponse - >("POST", "/manager/actors", { - query: requestQuery, - }); + const actorQuery: ActorQuery = { + getOrCreateForKey: { + name, + key: keyArray, + region: opts?.createInRegion, + }, + }; - const conn = await this.#createConn( - resJson.endpoint, + const managerEndpoint = this.#managerEndpoint; + const handle = this.#createHandle( + managerEndpoint, opts?.params, - resJson.supportedTransports, + actorQuery, ); - return this.#createProxy(conn) as ActorConn; + return createActorProxy(handle) as ActorHandle; } /** - * Creates a new actor with the provided key and connects to it. - * - * @example - * ``` - * // Create a new document actor with a single string key - * const doc = await client.createAndConnect( - * 'document', - * 'doc123', - * { region: 'us-east-1' } - * ); - * - * // Or with an array of strings as key - * const doc = await client.createAndConnect( - * 'document', - * ['user123', 'document456'], - * { region: 'us-east-1' } - * ); - * - * await doc.doSomething(); - * ``` + * Creates a new actor with the provided key and returns a stateless handle to it. + * Resolves the actor ID and returns a handle with getForId query. * - * @template AD The actor class that this connection is for. + * @template AD The actor class that this handle is for. * @param {string} name - The name of the actor. * @param {string | string[]} key - The key to identify the actor. Can be a single string or an array of strings. * @param {CreateOptions} [opts] - Options for creating the actor (excluding name and key). - * @returns {Promise>} - A promise resolving to the actor connection. - * @see {@link https://rivet.gg/docs/manage#client.createAndConnect} + * @returns {Promise>} - A promise that resolves to a handle to the actor. */ - async createAndConnect( + async create( name: string, key: string | string[], opts: CreateOptions = {}, - ): Promise> { + ): Promise> { // Convert string to array of strings - const keyArray: string[] = typeof key === 'string' ? [key] : key; + const keyArray: string[] = typeof key === "string" ? [key] : key; // Build create config const create = { @@ -364,173 +313,75 @@ export class ClientRaw { key: keyArray, }; - // Default to the chosen region - //if (!create.region) create.region = (await this.#regionPromise)?.id; - - logger().debug("create actor and connect", { + logger().debug("create actor handle", { name, key: keyArray, parameters: opts?.params, create, }); - const resJson = await this.#sendManagerRequest< - ActorsRequest, - ActorsResponse - >("POST", "/manager/actors", { - query: { - create, - }, + // Create the actor + const createQuery = { + create, + } satisfies ActorQuery; + const actorId = await resolveActorId( + this.#managerEndpoint, + createQuery, + this.#encodingKind, + ); + logger().debug("created actor with ID", { + name, + key: keyArray, + actorId, }); - const conn = await this.#createConn( - resJson.endpoint, + // Create handle with actor ID + const getForIdQuery = { + getForId: { + actorId, + }, + } satisfies ActorQuery; + const handle = this.#createHandle( + this.#managerEndpoint, opts?.params, - resJson.supportedTransports, + getForIdQuery, ); - return this.#createProxy(conn) as ActorConn; + + const proxy = createActorProxy(handle) as ActorHandle; + + return proxy; } - async #createConn( + #createHandle( endpoint: string, params: unknown, - serverTransports: Transport[], - ): Promise { - const imports = await this.#dynamicImportsPromise; - - const conn = new ActorConnRaw( + actorQuery: ActorQuery, + ): ActorHandleRaw { + return new ActorHandleRaw( this, endpoint, params, this.#encodingKind, - this.#supportedTransports, - serverTransports, - imports, + actorQuery, ); - this[ACTOR_CONNS_SYMBOL].add(conn); - conn[CONNECT_SYMBOL](); - return conn; } - #createProxy( + [CREATE_ACTOR_CONN_PROXY]( conn: ActorConnRaw, ): ActorConn { - // Stores returned RPC functions for faster calls - const methodCache = new Map(); - return new Proxy(conn, { - get(target: ActorConnRaw, prop: string | symbol, receiver: unknown) { - // Handle built-in Symbol properties - if (typeof prop === "symbol") { - return Reflect.get(target, prop, receiver); - } - - // Handle built-in Promise methods and existing properties - if ( - prop === "then" || - prop === "catch" || - prop === "finally" || - prop === "constructor" || - prop in target - ) { - const value = Reflect.get(target, prop, receiver); - // Preserve method binding - if (typeof value === "function") { - return value.bind(target); - } - return value; - } - - // Create RPC function that preserves 'this' context - if (typeof prop === "string") { - let method = methodCache.get(prop); - if (!method) { - method = (...args: unknown[]) => target.action(prop, ...args); - methodCache.set(prop, method); - } - return method; - } - }, - - // Support for 'in' operator - has(target: ActorConnRaw, prop: string | symbol) { - // All string properties are potentially RPC functions - if (typeof prop === "string") { - return true; - } - // For symbols, defer to the target's own has behavior - return Reflect.has(target, prop); - }, - - // Support instanceof checks - getPrototypeOf(target: ActorConnRaw) { - return Reflect.getPrototypeOf(target); - }, - - // Prevent property enumeration of non-existent RPC methods - ownKeys(target: ActorConnRaw) { - return Reflect.ownKeys(target); - }, - - // Support proper property descriptors - getOwnPropertyDescriptor(target: ActorConnRaw, prop: string | symbol) { - const targetDescriptor = Reflect.getOwnPropertyDescriptor(target, prop); - if (targetDescriptor) { - return targetDescriptor; - } - if (typeof prop === "string") { - // Make RPC methods appear non-enumerable - return { - configurable: true, - enumerable: false, - writable: false, - value: (...args: unknown[]) => target.action(prop, ...args), - }; - } - return undefined; - }, - }) as ActorConn; - } + // Save to connection list + this[ACTOR_CONNS_SYMBOL].add(conn); - /** - * Sends an HTTP request to the manager actor. - * @private - * @template Request - * @template Response - * @param {string} method - The HTTP method. - * @param {string} path - The path for the request. - * @param {Request} [body] - The request body. - * @returns {Promise} - A promise resolving to the response. - * @see {@link https://rivet.gg/docs/manage#client} - */ - async #sendManagerRequest( - method: string, - path: string, - body?: Request, - ): Promise { - try { - const managerEndpoint = await this.#managerEndpointPromise; - const res = await fetch(`${managerEndpoint}${path}`, { - method, - headers: { - "Content-Type": "application/json", - }, - body: body ? JSON.stringify(body) : undefined, - }); - - if (!res.ok) { - throw new errors.ManagerError(`${res.statusText}: ${await res.text()}`); - } + // Start connection + conn[CONNECT_SYMBOL](); - return res.json(); - } catch (error) { - throw new errors.ManagerError(String(error), { cause: error }); - } + return createActorProxy(conn) as ActorConn; } /** * Disconnects from all actors. * - * @returns {Promise} A promise that resolves when the socket is gracefully closed. + * @returns {Promise} A promise that resolves when all connections are closed. */ async dispose(): Promise { if (this.#disposed) { @@ -542,9 +393,12 @@ export class ClientRaw { logger().debug("disposing client"); const disposePromises = []; + + // Dispose all connections for (const conn of this[ACTOR_CONNS_SYMBOL].values()) { disposePromises.push(conn.dispose()); } + await Promise.all(disposePromises); } } @@ -565,15 +419,15 @@ export type Client> = ClientRaw & { * Creates a client with the actor accessor proxy. * * @template A The actor application type. - * @param {string | Promise} managerEndpointPromise - The manager endpoint or a promise resolving to it. + * @param {string} managerEndpoint - The manager endpoint. * @param {ClientOptions} [opts] - Options for configuring the client. * @returns {Client} - A proxied client that supports the `client.myActor.connect()` syntax. */ export function createClient>( - managerEndpointPromise: string | Promise, + managerEndpoint: string, opts?: ClientOptions, ): Client { - const client = new ClientRaw(managerEndpointPromise, opts); + const client = new ClientRaw(managerEndpoint, opts); // Create proxy for accessing actors by name return new Proxy(client, { @@ -592,36 +446,47 @@ export function createClient>( if (typeof prop === "string") { // Return actor accessor object with methods return { - connect: ( + // Handle methods (stateless action) + get: ( key?: string | string[], - opts?: GetOptions, - ): Promise[typeof prop]>> => { - return target.connect[typeof prop]>( + opts?: GetWithIdOptions, + ): ActorHandle[typeof prop]> => { + return target.get[typeof prop]>( prop, key, - opts + opts, ); }, - createAndConnect: ( - key: string | string[], - opts: CreateOptions = {}, - ): Promise[typeof prop]>> => { - return target.createAndConnect[typeof prop]>( + getOrCreate: ( + key?: string | string[], + opts?: GetOptions, + ): ActorHandle[typeof prop]> => { + return target.getOrCreate[typeof prop]>( prop, key, - opts + opts, ); }, - connectForId: ( + getForId: ( actorId: string, opts?: GetWithIdOptions, - ): Promise[typeof prop]>> => { - return target.connectForId[typeof prop]>( + ): ActorHandle[typeof prop]> => { + return target.getForId[typeof prop]>( prop, actorId, opts, ); }, + create: async ( + key: string | string[], + opts: CreateOptions = {}, + ): Promise[typeof prop]>> => { + return await target.create[typeof prop]>( + prop, + key, + opts, + ); + }, } as ActorAccessor[typeof prop]>; } @@ -629,3 +494,82 @@ export function createClient>( }, }) as Client; } + +/** + * Creates a proxy for an actor that enables calling actions without explicitly using `.action`. + **/ +function createActorProxy( + handle: ActorHandleRaw | ActorConnRaw, +): ActorHandle | ActorConn { + // Stores returned action functions for faster calls + const methodCache = new Map(); + return new Proxy(handle, { + get(target: ActorHandleRaw, prop: string | symbol, receiver: unknown) { + // Handle built-in Symbol properties + if (typeof prop === "symbol") { + return Reflect.get(target, prop, receiver); + } + + // Handle built-in Promise methods and existing properties + if (prop === "constructor" || prop in target) { + const value = Reflect.get(target, prop, receiver); + // Preserve method binding + if (typeof value === "function") { + return value.bind(target); + } + return value; + } + + // Create action function that preserves 'this' context + if (typeof prop === "string") { + // If JS is attempting to calling this as a promise, ignore it + if (prop === "then") return undefined; + + let method = methodCache.get(prop); + if (!method) { + method = (...args: unknown[]) => target.action(prop, ...args); + methodCache.set(prop, method); + } + return method; + } + }, + + // Support for 'in' operator + has(target: ActorHandleRaw, prop: string | symbol) { + // All string properties are potentially action functions + if (typeof prop === "string") { + return true; + } + // For symbols, defer to the target's own has behavior + return Reflect.has(target, prop); + }, + + // Support instanceof checks + getPrototypeOf(target: ActorHandleRaw) { + return Reflect.getPrototypeOf(target); + }, + + // Prevent property enumeration of non-existent action methods + ownKeys(target: ActorHandleRaw) { + return Reflect.ownKeys(target); + }, + + // Support proper property descriptors + getOwnPropertyDescriptor(target: ActorHandleRaw, prop: string | symbol) { + const targetDescriptor = Reflect.getOwnPropertyDescriptor(target, prop); + if (targetDescriptor) { + return targetDescriptor; + } + if (typeof prop === "string") { + // Make action methods appear non-enumerable + return { + configurable: true, + enumerable: false, + writable: false, + value: (...args: unknown[]) => target.action(prop, ...args), + }; + } + return undefined; + }, + }) as ActorHandle | ActorConn; +} diff --git a/packages/actor-core/src/client/errors.ts b/packages/actor-core/src/client/errors.ts index d10987745..79840c2c9 100644 --- a/packages/actor-core/src/client/errors.ts +++ b/packages/actor-core/src/client/errors.ts @@ -24,13 +24,7 @@ export class MalformedResponseMessage extends ActorClientError { } } -export class NoSupportedTransport extends ActorClientError { - constructor() { - super("No supported transport available between client and server"); - } -} - -export class ActionError extends ActorClientError { +export class ActorError extends ActorClientError { constructor( public readonly code: string, message: string, @@ -39,3 +33,15 @@ export class ActionError extends ActorClientError { super(message); } } + +export class HttpRequestError extends ActorClientError { + constructor(message: string, opts?: { cause?: unknown }) { + super(`HTTP request error: ${message}`, { cause: opts?.cause }); + } +} + +export class ActorConnDisposed extends ActorClientError { + constructor() { + super("Attempting to interact with a disposed actor connection."); + } +} diff --git a/packages/actor-core/src/client/mod.ts b/packages/actor-core/src/client/mod.ts index 9c8635adf..c94978d31 100644 --- a/packages/actor-core/src/client/mod.ts +++ b/packages/actor-core/src/client/mod.ts @@ -12,9 +12,12 @@ export type { ExtractAppFromClient, ClientRaw, } from "./client"; -export type { ActorConn } from "./actor_conn"; -export { ActorConnRaw } from "./actor_conn"; -export type { EventUnsubscribe } from "./actor_conn"; +export type { ActorConn } from "./actor-conn"; +export { ActorConnRaw } from "./actor-conn"; +export type { EventUnsubscribe } from "./actor-conn"; +export type { ActorHandle } from "./actor-handle"; +export { ActorHandleRaw } from "./actor-handle"; +export type { ActorActionFunction } from "./actor-common"; export type { Transport } from "@/actor/protocol/message/mod"; export type { Encoding } from "@/actor/protocol/serde"; export type { CreateRequest } from "@/manager/protocol/query"; @@ -24,8 +27,7 @@ export { ManagerError, ConnParamsTooLong, MalformedResponseMessage, - NoSupportedTransport, - ActionError, + ActorError, } from "@/client/errors"; export { AnyActorDefinition, diff --git a/packages/actor-core/src/client/utils.ts b/packages/actor-core/src/client/utils.ts index e67cae0c9..70a40e34b 100644 --- a/packages/actor-core/src/client/utils.ts +++ b/packages/actor-core/src/client/utils.ts @@ -1,4 +1,11 @@ -import { assertUnreachable } from "@/common/utils"; +import { deserialize } from "@/actor/protocol/serde"; +import { assertUnreachable, stringifyError } from "@/common/utils"; +import { httpUserAgent } from "@/utils"; +import { Encoding } from "@/mod"; +import * as cbor from "cbor-x"; +import { ActorError, HttpRequestError } from "./errors"; +import { ResponseError } from "@/actor/protocol/http/error"; +import { logger } from "./log"; export type WebSocketMessage = string | Blob | ArrayBuffer | Uint8Array; @@ -17,3 +24,118 @@ export function messageLength(message: WebSocketMessage): number { } assertUnreachable(message); } + +export interface HttpRequestOpts { + method: string; + url: string; + headers: Record; + body?: Body; + encoding: Encoding; + skipParseResponse?: boolean; +} + +export async function sendHttpRequest< + RequestBody = unknown, + ResponseBody = unknown, +>(opts: HttpRequestOpts): Promise { + logger().debug("sending http request", { + url: opts.url, + encoding: opts.encoding, + }); + + // Serialize body + let contentType: string | undefined = undefined; + let bodyData: string | Buffer | undefined = undefined; + if (opts.method === "POST" || opts.method === "PUT") { + if (opts.encoding === "json") { + contentType = "application/json"; + bodyData = JSON.stringify(opts.body); + } else if (opts.encoding === "cbor") { + contentType = "application/octet-stream"; + bodyData = cbor.encode(opts.body); + } else { + assertUnreachable(opts.encoding); + } + } + + // Send request + let response: Response; + try { + // Make the HTTP request + response = await fetch(opts.url, { + method: opts.method, + headers: { + ...opts.headers, + ...(contentType + ? { + "Content-Type": contentType, + } + : {}), + "User-Agent": httpUserAgent(), + }, + body: bodyData, + }); + } catch (error) { + throw new HttpRequestError(`Request failed: ${error}`, { + cause: error, + }); + } + + // Parse response error + if (!response.ok) { + // Attempt to parse structured data + const bufferResponse = await response.arrayBuffer(); + let responseData: ResponseError; + try { + if (opts.encoding === "json") { + const textResponse = new TextDecoder().decode(bufferResponse); + responseData = JSON.parse(textResponse); + } else if (opts.encoding === "cbor") { + const uint8Array = new Uint8Array(bufferResponse); + responseData = cbor.decode(uint8Array); + } else { + assertUnreachable(opts.encoding); + } + } catch (error) { + //logger().warn("failed to cleanly parse error, this is likely because a non-structured response is being served", { + // error: stringifyError(error), + //}); + + // Error is not structured + const textResponse = new TextDecoder("utf-8", { fatal: false }).decode( + bufferResponse, + ); + throw new HttpRequestError( + `${response.statusText} (${response.status}):\n${textResponse}`, + ); + } + + // Throw structured error + throw new ActorError(responseData.c, responseData.m, responseData.md); + } + + // Some requests don't need the success response to be parsed, so this can speed things up + if (opts.skipParseResponse) { + return undefined as ResponseBody; + } + + // Parse the response based on encoding + let responseBody: ResponseBody; + try { + if (opts.encoding === "json") { + responseBody = (await response.json()) as ResponseBody; + } else if (opts.encoding === "cbor") { + const buffer = await response.arrayBuffer(); + const uint8Array = new Uint8Array(buffer); + responseBody = cbor.decode(uint8Array); + } else { + assertUnreachable(opts.encoding); + } + } catch (error) { + throw new HttpRequestError(`Failed to parse response: ${error}`, { + cause: error, + }); + } + + return responseBody; +} diff --git a/packages/actor-core/src/common/eventsource.ts b/packages/actor-core/src/common/eventsource.ts index 01ba18410..8329c46f3 100644 --- a/packages/actor-core/src/common/eventsource.ts +++ b/packages/actor-core/src/common/eventsource.ts @@ -1,13 +1,22 @@ import { logger } from "@/client/log"; +import type { EventSource } from "eventsource"; +// Global singleton promise that will be reused for subsequent calls +let eventSourcePromise: Promise | null = null; + +/** + * Import `eventsource` from the custom `eventsource` library. We need a custom implemnetation since we need to attach our own custom headers to the request. + **/ export async function importEventSource(): Promise { - let _EventSource: typeof EventSource; + // Return existing promise if we already started loading + if (eventSourcePromise !== null) { + return eventSourcePromise; + } + + // Create and store the promise + eventSourcePromise = (async () => { + let _EventSource: typeof EventSource; - if (typeof EventSource !== "undefined") { - // Browser environment - _EventSource = EventSource; - logger().debug("using native eventsource"); - } else { // Node.js environment try { const es = await import("eventsource"); @@ -24,7 +33,48 @@ export async function importEventSource(): Promise { } as unknown as typeof EventSource; logger().debug("using mock eventsource"); } - } - return _EventSource; + return _EventSource; + })(); + + return eventSourcePromise; } + +//export async function importEventSource(): Promise { +// // Return existing promise if we already started loading +// if (eventSourcePromise !== null) { +// return eventSourcePromise; +// } +// +// // Create and store the promise +// eventSourcePromise = (async () => { +// let _EventSource: typeof EventSource; +// +// if (typeof EventSource !== "undefined") { +// // Browser environment +// _EventSource = EventSource; +// logger().debug("using native eventsource"); +// } else { +// // Node.js environment +// try { +// const es = await import("eventsource"); +// _EventSource = es.EventSource; +// logger().debug("using eventsource from npm"); +// } catch (err) { +// // EventSource not available +// _EventSource = class MockEventSource { +// constructor() { +// throw new Error( +// 'EventSource support requires installing the "eventsource" peer dependency.', +// ); +// } +// } as unknown as typeof EventSource; +// logger().debug("using mock eventsource"); +// } +// } +// +// return _EventSource; +// })(); +// +// return eventSourcePromise; +//} diff --git a/packages/actor-core/src/common/log.ts b/packages/actor-core/src/common/log.ts index c03c5eaf2..d5ccb97a9 100644 --- a/packages/actor-core/src/common/log.ts +++ b/packages/actor-core/src/common/log.ts @@ -75,7 +75,14 @@ export class Logger { const loggers: Record = {}; export function getLogger(name = "default"): Logger { - const defaultLogLevelEnv = typeof process !== "undefined" ? (process.env._LOG_LEVEL as LogLevel) : undefined; + let defaultLogLevelEnv: LogLevel | undefined = undefined; + if (typeof Deno !== "undefined") { + defaultLogLevelEnv = Deno.env.get("_LOG_LEVEL") as LogLevel; + } else if (typeof process !== "undefined") { + // Do this after Deno since `process` is sometimes polyfilled + defaultLogLevelEnv = process.env._LOG_LEVEL as LogLevel; + } + const defaultLogLevel: LogLevel = defaultLogLevelEnv ?? "INFO"; if (!loggers[name]) { loggers[name] = new Logger(name, defaultLogLevel); diff --git a/packages/actor-core/src/common/router.ts b/packages/actor-core/src/common/router.ts index 724a4cc33..7de56b18f 100644 --- a/packages/actor-core/src/common/router.ts +++ b/packages/actor-core/src/common/router.ts @@ -1,11 +1,35 @@ -import type { Context as HonoContext } from "hono"; -import { getLogger } from "./log"; +import type { Context as HonoContext, Next } from "hono"; +import { getLogger, Logger } from "./log"; import { deconstructError } from "./utils"; +import { getRequestEncoding } from "@/actor/router-endpoints"; +import { serialize } from "@/actor/protocol/serde"; +import { ResponseError } from "@/actor/protocol/http/error"; export function logger() { return getLogger("router"); } +export function loggerMiddleware(logger: Logger) { + return async (c: HonoContext, next: Next) => { + const method = c.req.method; + const path = c.req.path; + const startTime = Date.now(); + + await next(); + + const duration = Date.now() - startTime; + logger.info("http request", { + method, + path, + status: c.res.status, + dt: `${duration}ms`, + reqSize: c.req.header("content-length"), + resSize: c.res.headers.get("content-length"), + userAgent: c.req.header("user-agent"), + }); + }; +} + export function handleRouteNotFound(c: HonoContext) { return c.text("Not Found (ActorCore)", 404); } @@ -20,5 +44,15 @@ export function handleRouteError(error: unknown, c: HonoContext) { }, ); - return c.json({ code, message, metadata }, { status: statusCode }); + const encoding = getRequestEncoding(c.req, false); + const output = serialize( + { + c: code, + m: message, + md: metadata, + } satisfies ResponseError, + encoding, + ); + + return c.body(output, { status: statusCode }); } diff --git a/packages/actor-core/src/common/utils.ts b/packages/actor-core/src/common/utils.ts index 40bb69603..35bd8a202 100644 --- a/packages/actor-core/src/common/utils.ts +++ b/packages/actor-core/src/common/utils.ts @@ -135,7 +135,7 @@ export function deconstructError( let code: string; let message: string; let metadata: unknown = undefined; - if (error instanceof errors.ActorError && error.public) { + if (errors.ActorError.isActorError(error) && error.public) { statusCode = 400; code = error.code; message = String(error); diff --git a/packages/actor-core/src/common/websocket.ts b/packages/actor-core/src/common/websocket.ts index 29ac3398c..0b36cab4a 100644 --- a/packages/actor-core/src/common/websocket.ts +++ b/packages/actor-core/src/common/websocket.ts @@ -1,30 +1,43 @@ import { logger } from "@/client/log"; +// Global singleton promise that will be reused for subsequent calls +let webSocketPromise: Promise | null = null; + export async function importWebSocket(): Promise { - let _WebSocket: typeof WebSocket; + // Return existing promise if we already started loading + if (webSocketPromise !== null) { + return webSocketPromise; + } + + // Create and store the promise + webSocketPromise = (async () => { + let _WebSocket: typeof WebSocket; - if (typeof WebSocket !== "undefined") { - // Browser environment - _WebSocket = WebSocket; - logger().debug("using native websocket"); - } else { - // Node.js environment - try { - const ws = await import("ws"); - _WebSocket = ws.default as unknown as typeof WebSocket; - logger().debug("using websocket from npm"); - } catch { - // WS not available - _WebSocket = class MockWebSocket { - constructor() { - throw new Error( - 'WebSocket support requires installing the "ws" peer dependency.', - ); - } - } as unknown as typeof WebSocket; - logger().debug("using mock websocket"); + if (typeof WebSocket !== "undefined") { + // Browser environment + _WebSocket = WebSocket; + logger().debug("using native websocket"); + } else { + // Node.js environment + try { + const ws = await import("ws"); + _WebSocket = ws.default as unknown as typeof WebSocket; + logger().debug("using websocket from npm"); + } catch { + // WS not available + _WebSocket = class MockWebSocket { + constructor() { + throw new Error( + 'WebSocket support requires installing the "ws" peer dependency.', + ); + } + } as unknown as typeof WebSocket; + logger().debug("using mock websocket"); + } } - } - return _WebSocket; + return _WebSocket; + })(); + + return webSocketPromise; } diff --git a/packages/actor-core/src/driver-helpers/mod.ts b/packages/actor-core/src/driver-helpers/mod.ts index f42e1d263..12191a98c 100644 --- a/packages/actor-core/src/driver-helpers/mod.ts +++ b/packages/actor-core/src/driver-helpers/mod.ts @@ -1,3 +1,5 @@ +import { ToServer } from "@/actor/protocol/message/to-server"; + export { type DriverConfig, DriverConfigSchema } from "./config"; export type { ActorInstance, AnyActorInstance } from "@/actor/instance"; export { diff --git a/packages/actor-core/src/driver-test-suite/log.ts b/packages/actor-core/src/driver-test-suite/log.ts new file mode 100644 index 000000000..62d1e56ea --- /dev/null +++ b/packages/actor-core/src/driver-test-suite/log.ts @@ -0,0 +1,7 @@ +import { getLogger } from "@/common/log"; + +export const LOGGER_NAME = "test-suite"; + +export function logger() { + return getLogger(LOGGER_NAME); +} diff --git a/packages/misc/driver-test-suite/src/mod.ts b/packages/actor-core/src/driver-test-suite/mod.ts similarity index 70% rename from packages/misc/driver-test-suite/src/mod.ts rename to packages/actor-core/src/driver-test-suite/mod.ts index 8a971da22..574435a7f 100644 --- a/packages/misc/driver-test-suite/src/mod.ts +++ b/packages/actor-core/src/driver-test-suite/mod.ts @@ -4,19 +4,27 @@ import { CoordinateDriver, DriverConfig, ManagerDriver, -} from "actor-core/driver-helpers"; -import { runActorDriverTests, waitFor } from "./tests/actor-driver"; +} from "@/driver-helpers/mod"; +import { runActorDriverTests } from "./tests/actor-driver"; import { runManagerDriverTests } from "./tests/manager-driver"; import { describe } from "vitest"; import { type ActorCoreApp, CoordinateTopology, StandaloneTopology, -} from "actor-core"; +} from "@/mod"; import { createNodeWebSocket, type NodeWebSocket } from "@hono/node-ws"; import invariant from "invariant"; import { bundleRequire } from "bundle-require"; -import { getPort } from "actor-core/test"; +import { getPort } from "@/test/mod"; +import { Transport } from "@/client/mod"; +import { runActorConnTests } from "./tests/actor-conn"; +import { runActorHandleTests } from "./tests/actor-handle"; +import { runActionFeaturesTests } from "./tests/action-features"; +import { runActorVarsTests } from "./tests/actor-vars"; +import { runActorConnStateTests } from "./tests/actor-conn-state"; +import { runActorMetadataTests } from "./tests/actor-metadata"; +import { runActorErrorHandlingTests } from "./tests/actor-error-handling"; export interface DriverTestConfig { /** Deploys an app and returns the connection endpoint. */ @@ -30,6 +38,8 @@ export interface DriverTestConfig { /** Cloudflare Workers has some bugs with cleanup. */ HACK_skipCleanupNet?: boolean; + + transport?: Transport; } export interface DriverDeployOutput { @@ -41,18 +51,30 @@ export interface DriverDeployOutput { /** Runs all Vitest tests against the provided drivers. */ export function runDriverTests(driverTestConfig: DriverTestConfig) { - describe("driver tests", () => { - runActorDriverTests(driverTestConfig); - runManagerDriverTests(driverTestConfig); - }); -} + runActorDriverTests(driverTestConfig); + runManagerDriverTests(driverTestConfig); -/** - * Re-export the waitFor helper for use in other tests. - * This function handles waiting in tests, using either real timers or mocked timers - * based on the driverTestConfig.useRealTimers setting. - */ -export { waitFor }; + for (const transport of ["websocket", "sse"] as Transport[]) { + describe(`actor connection (${transport})`, () => { + runActorConnTests({ + ...driverTestConfig, + transport, + }); + + runActorConnStateTests({ ...driverTestConfig, transport }); + }); + } + + runActorHandleTests(driverTestConfig); + + runActionFeaturesTests(driverTestConfig); + + runActorVarsTests(driverTestConfig); + + runActorMetadataTests(driverTestConfig); + + runActorErrorHandlingTests(driverTestConfig); +} /** * Helper function to adapt the drivers to the Node.js runtime for tests. diff --git a/packages/actor-core/src/driver-test-suite/test-apps.ts b/packages/actor-core/src/driver-test-suite/test-apps.ts new file mode 100644 index 000000000..74212917b --- /dev/null +++ b/packages/actor-core/src/driver-test-suite/test-apps.ts @@ -0,0 +1,53 @@ +import { resolve } from "node:path"; + +export type { App as CounterApp } from "../../fixtures/driver-test-suite/counter"; +export type { App as ScheduledApp } from "../../fixtures/driver-test-suite/scheduled"; +export type { App as ConnParamsApp } from "../../fixtures/driver-test-suite/conn-params"; +export type { App as LifecycleApp } from "../../fixtures/driver-test-suite/lifecycle"; +export type { App as ActionTimeoutApp } from "../../fixtures/driver-test-suite/action-timeout"; +export type { App as ActionTypesApp } from "../../fixtures/driver-test-suite/action-types"; +export type { App as VarsApp } from "../../fixtures/driver-test-suite/vars"; +export type { App as ConnStateApp } from "../../fixtures/driver-test-suite/conn-state"; +export type { App as MetadataApp } from "../../fixtures/driver-test-suite/metadata"; +export type { App as ErrorHandlingApp } from "../../fixtures/driver-test-suite/error-handling"; + +export const COUNTER_APP_PATH = resolve( + __dirname, + "../../fixtures/driver-test-suite/counter.ts", +); +export const SCHEDULED_APP_PATH = resolve( + __dirname, + "../../fixtures/driver-test-suite/scheduled.ts", +); +export const CONN_PARAMS_APP_PATH = resolve( + __dirname, + "../../fixtures/driver-test-suite/conn-params.ts", +); +export const LIFECYCLE_APP_PATH = resolve( + __dirname, + "../../fixtures/driver-test-suite/lifecycle.ts", +); +export const ACTION_TIMEOUT_APP_PATH = resolve( + __dirname, + "../../fixtures/driver-test-suite/action-timeout.ts", +); +export const ACTION_TYPES_APP_PATH = resolve( + __dirname, + "../../fixtures/driver-test-suite/action-types.ts", +); +export const VARS_APP_PATH = resolve( + __dirname, + "../../fixtures/driver-test-suite/vars.ts", +); +export const CONN_STATE_APP_PATH = resolve( + __dirname, + "../../fixtures/driver-test-suite/conn-state.ts", +); +export const METADATA_APP_PATH = resolve( + __dirname, + "../../fixtures/driver-test-suite/metadata.ts", +); +export const ERROR_HANDLING_APP_PATH = resolve( + __dirname, + "../../fixtures/driver-test-suite/error-handling.ts", +); \ No newline at end of file diff --git a/packages/actor-core/src/driver-test-suite/tests/action-features.ts b/packages/actor-core/src/driver-test-suite/tests/action-features.ts new file mode 100644 index 000000000..cc559aa88 --- /dev/null +++ b/packages/actor-core/src/driver-test-suite/tests/action-features.ts @@ -0,0 +1,171 @@ +import { describe, test, expect } from "vitest"; +import type { DriverTestConfig } from "../mod"; +import { setupDriverTest } from "../utils"; +import { + ACTION_TIMEOUT_APP_PATH, + ACTION_TYPES_APP_PATH, + type ActionTimeoutApp, + type ActionTypesApp, +} from "../test-apps"; +import { ActorError } from "@/client/errors"; + +export function runActionFeaturesTests(driverTestConfig: DriverTestConfig) { + describe("Action Features", () => { + // TODO: These do not work with fake timers + describe.skip("Action Timeouts", () => { + let usesFakeTimers = !driverTestConfig.useRealTimers; + + test("should timeout actions that exceed the configured timeout", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + ACTION_TIMEOUT_APP_PATH, + ); + + // The quick action should complete successfully + const quickResult = await client.shortTimeoutActor + .getOrCreate() + .quickAction(); + expect(quickResult).toBe("quick response"); + + // The slow action should throw a timeout error + await expect( + client.shortTimeoutActor.getOrCreate().slowAction(), + ).rejects.toThrow("Action timed out"); + }); + + test("should respect the default timeout", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + ACTION_TIMEOUT_APP_PATH, + ); + + // This action should complete within the default timeout + const result = await client.defaultTimeoutActor + .getOrCreate() + .normalAction(); + expect(result).toBe("normal response"); + }); + + test("non-promise action results should not be affected by timeout", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + ACTION_TIMEOUT_APP_PATH, + ); + + // Synchronous action should not be affected by timeout + const result = await client.syncActor.getOrCreate().syncAction(); + expect(result).toBe("sync response"); + }); + + test("should allow configuring different timeouts for different actors", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + ACTION_TIMEOUT_APP_PATH, + ); + + // The short timeout actor should fail + await expect( + client.shortTimeoutActor.getOrCreate().slowAction(), + ).rejects.toThrow("Action timed out"); + + // The longer timeout actor should succeed + const result = await client.longTimeoutActor + .getOrCreate() + .delayedAction(); + expect(result).toBe("delayed response"); + }); + }); + + describe("Action Sync & Async", () => { + test("should support synchronous actions", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + ACTION_TYPES_APP_PATH, + ); + + const instance = client.syncActor.getOrCreate(); + + // Test increment action + let result = await instance.increment(5); + expect(result).toBe(5); + + result = await instance.increment(3); + expect(result).toBe(8); + + // Test getInfo action + const info = await instance.getInfo(); + expect(info.currentValue).toBe(8); + expect(typeof info.timestamp).toBe("number"); + + // Test reset action (void return) + await instance.reset(); + result = await instance.increment(0); + expect(result).toBe(0); + }); + + test("should support asynchronous actions", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + ACTION_TYPES_APP_PATH, + ); + + const instance = client.asyncActor.getOrCreate(); + + // Test delayed increment + const result = await instance.delayedIncrement(5); + expect(result).toBe(5); + + // Test fetch data + const data = await instance.fetchData("test-123"); + expect(data.id).toBe("test-123"); + expect(typeof data.timestamp).toBe("number"); + + // Test successful async operation + const success = await instance.asyncWithError(false); + expect(success).toBe("Success"); + + // Test error in async operation + try { + await instance.asyncWithError(true); + expect.fail("did not error"); + } catch (error) { + expect(error).toBeInstanceOf(ActorError); + expect((error as ActorError).message).toBe("Intentional error"); + } + }); + + test("should handle promises returned from actions correctly", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + ACTION_TYPES_APP_PATH, + ); + + const instance = client.promiseActor.getOrCreate(); + + // Test resolved promise + const resolvedValue = await instance.resolvedPromise(); + expect(resolvedValue).toBe("resolved value"); + + // Test delayed promise + const delayedValue = await instance.delayedPromise(); + expect(delayedValue).toBe("delayed value"); + + // Test rejected promise + await expect(instance.rejectedPromise()).rejects.toThrow( + "promised rejection", + ); + + // Check state was updated by the delayed promise + const results = await instance.getResults(); + expect(results).toContain("delayed"); + }); + }); + }); +} diff --git a/packages/actor-core/src/driver-test-suite/tests/actor-conn-state.ts b/packages/actor-core/src/driver-test-suite/tests/actor-conn-state.ts new file mode 100644 index 000000000..e36c73446 --- /dev/null +++ b/packages/actor-core/src/driver-test-suite/tests/actor-conn-state.ts @@ -0,0 +1,267 @@ +import { describe, test, expect } from "vitest"; +import type { DriverTestConfig } from "../mod"; +import { setupDriverTest } from "../utils"; +import { + CONN_STATE_APP_PATH, + type ConnStateApp, +} from "../test-apps"; + +export function runActorConnStateTests( + driverTestConfig: DriverTestConfig +) { + describe("Actor Connection State Tests", () => { + describe("Connection State Initialization", () => { + test("should retrieve connection state", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + CONN_STATE_APP_PATH, + ); + + // Connect to the actor + const connection = client.connStateActor.getOrCreate().connect(); + + // Get the connection state + const connState = await connection.getConnectionState(); + + // Verify the connection state structure + expect(connState.id).toBeDefined(); + expect(connState.username).toBeDefined(); + expect(connState.role).toBeDefined(); + expect(connState.counter).toBeDefined(); + expect(connState.createdAt).toBeDefined(); + + // Clean up + await connection.dispose(); + }); + + test("should initialize connection state with custom parameters", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + CONN_STATE_APP_PATH, + ); + + // Connect with custom parameters + const connection = client.connStateActor.getOrCreate([], { + params: { + username: "testuser", + role: "admin" + } + }).connect(); + + // Get the connection state + const connState = await connection.getConnectionState(); + + // Verify the connection state was initialized with custom values + expect(connState.username).toBe("testuser"); + expect(connState.role).toBe("admin"); + + // Clean up + await connection.dispose(); + }); + }); + + describe("Connection State Management", () => { + test("should maintain unique state for each connection", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + CONN_STATE_APP_PATH, + ); + + // Create multiple connections + const conn1 = client.connStateActor.getOrCreate([], { + params: { username: "user1" } + }).connect(); + + const conn2 = client.connStateActor.getOrCreate([], { + params: { username: "user2" } + }).connect(); + + // Update connection state for each connection + await conn1.incrementConnCounter(5); + await conn2.incrementConnCounter(10); + + // Get state for each connection + const state1 = await conn1.getConnectionState(); + const state2 = await conn2.getConnectionState(); + + // Verify states are separate + expect(state1.counter).toBe(5); + expect(state2.counter).toBe(10); + expect(state1.username).toBe("user1"); + expect(state2.username).toBe("user2"); + + // Clean up + await conn1.dispose(); + await conn2.dispose(); + }); + + test("should track connections in shared state", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + CONN_STATE_APP_PATH, + ); + + // Create two connections + const handle = client.connStateActor.getOrCreate(); + const conn1 = handle.connect(); + const conn2 = handle.connect(); + + // Get state1 for reference + const state1 = await conn1.getConnectionState(); + + // Get connection IDs tracked by the actor + const connectionIds = await conn1.getConnectionIds(); + + // There should be at least 2 connections tracked + expect(connectionIds.length).toBeGreaterThanOrEqual(2); + + // Should include the ID of the first connection + expect(connectionIds).toContain(state1.id); + + // Clean up + await conn1.dispose(); + await conn2.dispose(); + }); + + test("should identify different connections in the same actor", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + CONN_STATE_APP_PATH, + ); + + // Create two connections to the same actor + const handle = client.connStateActor.getOrCreate(); + const conn1 = handle.connect(); + const conn2 = handle.connect(); + + // Get all connection states + const allStates = await conn1.getAllConnectionStates(); + + // Should have at least 2 states + expect(allStates.length).toBeGreaterThanOrEqual(2); + + // IDs should be unique + const ids = allStates.map(state => state.id); + const uniqueIds = [...new Set(ids)]; + expect(uniqueIds.length).toBe(ids.length); + + // Clean up + await conn1.dispose(); + await conn2.dispose(); + }); + }); + + describe("Connection Lifecycle", () => { + test("should track connection and disconnection events", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + CONN_STATE_APP_PATH, + ); + + // Create a connection + const handle = client.connStateActor.getOrCreate(); + const conn = handle.connect(); + + // Get the connection state + const connState = await conn.getConnectionState(); + + // Verify the connection is tracked + const connectionIds = await conn.getConnectionIds(); + expect(connectionIds).toContain(connState.id); + + // Initial disconnection count + const initialDisconnections = await conn.getDisconnectionCount(); + + // Dispose the connection + await conn.dispose(); + + // Create a new connection to check the disconnection count + const newConn = handle.connect(); + const newDisconnections = await newConn.getDisconnectionCount(); + + // Verify disconnection was tracked + expect(newDisconnections).toBeGreaterThan(initialDisconnections); + + // Clean up + await newConn.dispose(); + }); + + test("should update connection state", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + CONN_STATE_APP_PATH, + ); + + // Create a connection + const conn = client.connStateActor.getOrCreate().connect(); + + // Get the initial state + const initialState = await conn.getConnectionState(); + expect(initialState.username).toBe("anonymous"); + + // Update the connection state + const updatedState = await conn.updateConnection({ + username: "newname", + role: "moderator" + }); + + // Verify the state was updated + expect(updatedState.username).toBe("newname"); + expect(updatedState.role).toBe("moderator"); + + // Get the state again to verify persistence + const latestState = await conn.getConnectionState(); + expect(latestState.username).toBe("newname"); + expect(latestState.role).toBe("moderator"); + + // Clean up + await conn.dispose(); + }); + }); + + describe("Connection Communication", () => { + test("should send messages to specific connections", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + CONN_STATE_APP_PATH, + ); + + // Create two connections + const handle = client.connStateActor.getOrCreate(); + const conn1 = handle.connect(); + const conn2 = handle.connect(); + + // Get connection states + const state1 = await conn1.getConnectionState(); + const state2 = await conn2.getConnectionState(); + + // Set up event listener on second connection + const receivedMessages: any[] = []; + conn2.on("directMessage", (data) => { + receivedMessages.push(data); + }); + + // Send message from first connection to second + const success = await conn1.sendToConnection(state2.id, "Hello from conn1"); + expect(success).toBe(true); + + // Verify message was received + expect(receivedMessages.length).toBe(1); + expect(receivedMessages[0].from).toBe(state1.id); + expect(receivedMessages[0].message).toBe("Hello from conn1"); + + // Clean up + await conn1.dispose(); + await conn2.dispose(); + }); + }); + }); +} diff --git a/packages/actor-core/src/driver-test-suite/tests/actor-conn.ts b/packages/actor-core/src/driver-test-suite/tests/actor-conn.ts new file mode 100644 index 000000000..1f495c988 --- /dev/null +++ b/packages/actor-core/src/driver-test-suite/tests/actor-conn.ts @@ -0,0 +1,265 @@ +import { describe, test, expect } from "vitest"; +import type { DriverTestConfig } from "../mod"; +import { setupDriverTest } from "../utils"; +import { + COUNTER_APP_PATH, + CONN_PARAMS_APP_PATH, + LIFECYCLE_APP_PATH, + type CounterApp, + type ConnParamsApp, + type LifecycleApp, +} from "../test-apps"; + +export function runActorConnTests(driverTestConfig: DriverTestConfig) { + describe("Actor Connection Tests", () => { + describe("Connection Methods", () => { + test("should connect using .get().connect()", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create actor + await client.counter.create(["test-get"]); + + // Get a handle and connect + const handle = client.counter.get(["test-get"]); + const connection = handle.connect(); + + // Verify connection by performing an action + const count = await connection.increment(5); + expect(count).toBe(5); + + // Clean up + await connection.dispose(); + }); + + test("should connect using .getForId().connect()", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create an actor first to get its ID + const handle = client.counter.getOrCreate(["test-get-for-id"]); + await handle.increment(3); + const actorId = await handle.resolve(); + + // Get a new handle using the actor ID and connect + const idHandle = client.counter.getForId(actorId); + const connection = idHandle.connect(); + + // Verify connection works and state is preserved + const count = await connection.getCount(); + expect(count).toBe(3); + + // Clean up + await connection.dispose(); + }); + + test("should connect using .getOrCreate().connect()", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Get or create actor and connect + const handle = client.counter.getOrCreate(["test-get-or-create"]); + const connection = handle.connect(); + + // Verify connection works + const count = await connection.increment(7); + expect(count).toBe(7); + + // Clean up + await connection.dispose(); + }); + + test("should connect using (await create()).connect()", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create actor and connect + const handle = await client.counter.create(["test-create"]); + const connection = handle.connect(); + + // Verify connection works + const count = await connection.increment(9); + expect(count).toBe(9); + + // Clean up + await connection.dispose(); + }); + }); + + describe("Event Communication", () => { + test("should receive events via broadcast", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create actor and connect + const handle = client.counter.getOrCreate(["test-broadcast"]); + const connection = handle.connect(); + + // Set up event listener + const receivedEvents: number[] = []; + connection.on("newCount", (count: number) => { + receivedEvents.push(count); + }); + + // Trigger broadcast events + await connection.increment(5); + await connection.increment(3); + + // Verify events were received + expect(receivedEvents).toContain(5); + expect(receivedEvents).toContain(8); + + // Clean up + await connection.dispose(); + }); + + test("should handle one-time events with once()", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create actor and connect + const handle = client.counter.getOrCreate(["test-once"]); + const connection = handle.connect(); + + // Set up one-time event listener + const receivedEvents: number[] = []; + connection.once("newCount", (count: number) => { + receivedEvents.push(count); + }); + + // Trigger multiple events, but should only receive the first one + await connection.increment(5); + await connection.increment(3); + + // Verify only the first event was received + expect(receivedEvents).toEqual([5]); + expect(receivedEvents).not.toContain(8); + + // Clean up + await connection.dispose(); + }); + + test("should unsubscribe from events", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create actor and connect + const handle = client.counter.getOrCreate(["test-unsubscribe"]); + const connection = handle.connect(); + + // Set up event listener with unsubscribe + const receivedEvents: number[] = []; + const unsubscribe = connection.on("newCount", (count: number) => { + receivedEvents.push(count); + }); + + // Trigger first event + await connection.increment(5); + + // Unsubscribe + unsubscribe(); + + // Trigger second event, should not be received + await connection.increment(3); + + // Verify only the first event was received + expect(receivedEvents).toEqual([5]); + expect(receivedEvents).not.toContain(8); + + // Clean up + await connection.dispose(); + }); + }); + + describe("Connection Parameters", () => { + test("should pass connection parameters", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + CONN_PARAMS_APP_PATH, + ); + + // Create two connections with different params + const handle1 = client.counter.getOrCreate(["test-params"], { + params: { name: "user1" }, + }); + const handle2 = client.counter.getOrCreate(["test-params"], { + params: { name: "user2" }, + }); + + const conn1 = handle1.connect(); + const conn2 = handle2.connect(); + + // Get initializers to verify connection params were used + const initializers = await conn1.getInitializers(); + + // Verify both connection names were recorded + expect(initializers).toContain("user1"); + expect(initializers).toContain("user2"); + + // Clean up + await conn1.dispose(); + await conn2.dispose(); + }); + }); + + describe("Lifecycle Hooks", () => { + test("should trigger lifecycle hooks", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + LIFECYCLE_APP_PATH, + ); + + const handle = client.counter.getOrCreate(["test-lifecycle"]); + + // Create and connect + const connHandle = client.counter.getOrCreate(["test-lifecycle"], { + params: { trackLifecycle: true }, + }); + const connection = connHandle.connect(); + + // Verify lifecycle events were triggered + const events = await connection.getEvents(); + + // Check lifecycle hooks were called in the correct order + expect(events).toContain("onStart"); + expect(events).toContain("onBeforeConnect"); + expect(events).toContain("onConnect"); + + // Disconnect should trigger onDisconnect + await connection.dispose(); + + // Reconnect to check if onDisconnect was called + const newConnection = handle.connect(); + + const finalEvents = await newConnection.getEvents(); + expect(finalEvents).toContain("onDisconnect"); + + // Clean up + await newConnection.dispose(); + }); + }); + }); +} diff --git a/packages/actor-core/src/driver-test-suite/tests/actor-driver.ts b/packages/actor-core/src/driver-test-suite/tests/actor-driver.ts new file mode 100644 index 000000000..22c89fe24 --- /dev/null +++ b/packages/actor-core/src/driver-test-suite/tests/actor-driver.ts @@ -0,0 +1,16 @@ +import { describe } from "vitest"; +import type { DriverTestConfig } from "../mod"; +import { runActorStateTests } from "./actor-state"; +import { runActorScheduleTests } from "./actor-schedule"; + +export function runActorDriverTests( + driverTestConfig: DriverTestConfig +) { + describe("Actor Driver Tests", () => { + // Run state persistence tests + runActorStateTests(driverTestConfig); + + // Run scheduled alarms tests + runActorScheduleTests(driverTestConfig); + }); +} \ No newline at end of file diff --git a/packages/actor-core/src/driver-test-suite/tests/actor-error-handling.ts b/packages/actor-core/src/driver-test-suite/tests/actor-error-handling.ts new file mode 100644 index 000000000..f59c0c5d4 --- /dev/null +++ b/packages/actor-core/src/driver-test-suite/tests/actor-error-handling.ts @@ -0,0 +1,175 @@ +import { describe, test, expect, vi } from "vitest"; +import type { DriverTestConfig } from "../mod"; +import { setupDriverTest, waitFor } from "../utils"; +import { ERROR_HANDLING_APP_PATH, type ErrorHandlingApp } from "../test-apps"; + +export function runActorErrorHandlingTests(driverTestConfig: DriverTestConfig) { + describe("Actor Error Handling Tests", () => { + describe("UserError Handling", () => { + test("should handle simple UserError with message", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + ERROR_HANDLING_APP_PATH, + ); + + // Try to call an action that throws a simple UserError + const handle = client.errorHandlingActor.getOrCreate(); + + try { + await handle.throwSimpleError(); + // If we get here, the test should fail + expect(true).toBe(false); // This should not be reached + } catch (error: any) { + // Verify the error properties + expect(error.message).toBe("Simple error message"); + // Default code is "user_error" when not specified + expect(error.code).toBe("user_error"); + // No metadata by default + expect(error.metadata).toBeUndefined(); + } + }); + + test("should handle detailed UserError with code and metadata", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + ERROR_HANDLING_APP_PATH, + ); + + // Try to call an action that throws a detailed UserError + const handle = client.errorHandlingActor.getOrCreate(); + + try { + await handle.throwDetailedError(); + // If we get here, the test should fail + expect(true).toBe(false); // This should not be reached + } catch (error: any) { + // Verify the error properties + expect(error.message).toBe("Detailed error message"); + expect(error.code).toBe("detailed_error"); + expect(error.metadata).toBeDefined(); + expect(error.metadata.reason).toBe("test"); + expect(error.metadata.timestamp).toBeDefined(); + } + }); + }); + + describe("Internal Error Handling", () => { + test("should convert internal errors to safe format", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + ERROR_HANDLING_APP_PATH, + ); + + // Try to call an action that throws an internal error + const handle = client.errorHandlingActor.getOrCreate(); + + try { + await handle.throwInternalError(); + // If we get here, the test should fail + expect(true).toBe(false); // This should not be reached + } catch (error: any) { + // Verify the error is converted to a safe format + expect(error.code).toBe("internal_error"); + // Original error details should not be exposed + expect(error.message).not.toBe("This is an internal error"); + } + }); + }); + + // TODO: Does not work with fake timers + describe.skip("Action Timeout", () => { + test("should handle action timeouts with custom duration", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + ERROR_HANDLING_APP_PATH, + ); + + // Call an action that should time out + const handle = client.errorHandlingActor.getOrCreate(); + + // This should throw a timeout error because errorHandlingActor has + // a 500ms timeout and this action tries to run for much longer + const timeoutPromise = handle.timeoutAction(); + + try { + await timeoutPromise; + // If we get here, the test failed - timeout didn't occur + expect(true).toBe(false); // This should not be reached + } catch (error: any) { + // Verify it's a timeout error + expect(error.message).toMatch(/timed out/i); + } + }); + + test("should successfully run actions within timeout", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + ERROR_HANDLING_APP_PATH, + ); + + // Call an action with a delay shorter than the timeout + const handle = client.errorHandlingActor.getOrCreate(); + + // This should succeed because 200ms < 500ms timeout + const result = await handle.delayedAction(200); + expect(result).toBe("Completed after 200ms"); + }); + + test("should respect different timeouts for different actors", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + ERROR_HANDLING_APP_PATH, + ); + + // The following actors have different timeout settings: + // customTimeoutActor: 200ms timeout + // standardTimeoutActor: default timeout (much longer) + + // This should fail - 300ms delay with 200ms timeout + try { + await client.customTimeoutActor.getOrCreate().slowAction(); + // Should not reach here + expect(true).toBe(false); + } catch (error: any) { + expect(error.message).toMatch(/timed out/i); + } + + // This should succeed - 50ms delay with 200ms timeout + const quickResult = await client.customTimeoutActor + .getOrCreate() + .quickAction(); + expect(quickResult).toBe("Quick action completed"); + }); + }); + + describe("Error Recovery", () => { + test("should continue working after errors", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + ERROR_HANDLING_APP_PATH, + ); + + const handle = client.errorHandlingActor.getOrCreate(); + + // Trigger an error + try { + await handle.throwSimpleError(); + } catch (error) { + // Ignore error + } + + // Actor should still work after error + const result = await handle.successfulAction(); + expect(result).toBe("success"); + }); + }); + }); +} + diff --git a/packages/actor-core/src/driver-test-suite/tests/actor-handle.ts b/packages/actor-core/src/driver-test-suite/tests/actor-handle.ts new file mode 100644 index 000000000..74ffdf6e6 --- /dev/null +++ b/packages/actor-core/src/driver-test-suite/tests/actor-handle.ts @@ -0,0 +1,287 @@ +import { describe, test, expect, vi } from "vitest"; +import type { DriverTestConfig } from "../mod"; +import { setupDriverTest, waitFor } from "../utils"; +import { + COUNTER_APP_PATH, + LIFECYCLE_APP_PATH, + type CounterApp, + type LifecycleApp, +} from "../test-apps"; + +export function runActorHandleTests(driverTestConfig: DriverTestConfig) { + describe("Actor Handle Tests", () => { + describe("Access Methods", () => { + test("should use .get() to access an actor", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create actor first + await client.counter.create(["test-get-handle"]); + + // Access using get + const handle = client.counter.get(["test-get-handle"]); + + // Verify Action works + const count = await handle.increment(5); + expect(count).toBe(5); + + const retrievedCount = await handle.getCount(); + expect(retrievedCount).toBe(5); + }); + + test("should use .getForId() to access an actor by ID", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create an actor first to get its ID + const handle = client.counter.getOrCreate(["test-get-for-id-handle"]); + await handle.increment(3); + const actorId = await handle.resolve(); + + // Access using getForId + const idHandle = client.counter.getForId(actorId); + + // Verify Action works and state is preserved + const count = await idHandle.getCount(); + expect(count).toBe(3); + + const newCount = await idHandle.increment(4); + expect(newCount).toBe(7); + }); + + test("should use .getOrCreate() to access or create an actor", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Access using getOrCreate - should create the actor + const handle = client.counter.getOrCreate([ + "test-get-or-create-handle", + ]); + + // Verify Action works + const count = await handle.increment(7); + expect(count).toBe(7); + + // Get the same actor again - should retrieve existing actor + const sameHandle = client.counter.getOrCreate([ + "test-get-or-create-handle", + ]); + const retrievedCount = await sameHandle.getCount(); + expect(retrievedCount).toBe(7); + }); + + test("should use (await create()) to create and return a handle", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create actor and get handle + const handle = await client.counter.create(["test-create-handle"]); + + // Verify Action works + const count = await handle.increment(9); + expect(count).toBe(9); + + const retrievedCount = await handle.getCount(); + expect(retrievedCount).toBe(9); + }); + }); + + describe("Action Functionality", () => { + test("should call actions directly on the handle", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + const handle = client.counter.getOrCreate(["test-action-handle"]); + + // Call multiple actions in sequence + const count1 = await handle.increment(3); + expect(count1).toBe(3); + + const count2 = await handle.increment(5); + expect(count2).toBe(8); + + const retrievedCount = await handle.getCount(); + expect(retrievedCount).toBe(8); + }); + + test("should handle independent handles to the same actor", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create two handles to the same actor + const handle1 = client.counter.getOrCreate(["test-multiple-handles"]); + const handle2 = client.counter.get(["test-multiple-handles"]); + + // Call actions on both handles + await handle1.increment(3); + const count = await handle2.getCount(); + + // Verify both handles access the same state + expect(count).toBe(3); + + const finalCount = await handle2.increment(4); + expect(finalCount).toBe(7); + + const checkCount = await handle1.getCount(); + expect(checkCount).toBe(7); + }); + + test("should resolve an actor's ID", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + const handle = client.counter.getOrCreate(["test-resolve-id"]); + + // Call an action to ensure actor exists + await handle.increment(1); + + // Resolve the ID + const actorId = await handle.resolve(); + + // Verify we got a valid ID (string) + expect(typeof actorId).toBe("string"); + expect(actorId).not.toBe(""); + + // Verify we can use this ID to get the actor + const idHandle = client.counter.getForId(actorId); + const count = await idHandle.getCount(); + expect(count).toBe(1); + }); + }); + + describe("Lifecycle Hooks", () => { + test("should trigger lifecycle hooks on actor creation", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + LIFECYCLE_APP_PATH, + ); + + // Get or create a new actor - this should trigger onStart + const handle = client.counter.getOrCreate(["test-lifecycle-handle"]); + + // Verify onStart was triggered + const initialEvents = await handle.getEvents(); + expect(initialEvents).toContain("onStart"); + + // Create a separate handle to the same actor + const sameHandle = client.counter.getOrCreate([ + "test-lifecycle-handle", + ]); + + // Verify events still include onStart but don't duplicate it + // (onStart should only be called once when the actor is first created) + const events = await sameHandle.getEvents(); + expect(events).toContain("onStart"); + expect(events.filter((e) => e === "onStart").length).toBe(1); + }); + + test("should trigger lifecycle hooks for each Action call", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + LIFECYCLE_APP_PATH, + ); + + // Create a normal handle to view events + const viewHandle = client.counter.getOrCreate(["test-lifecycle-action"]); + + // Initial state should only have onStart + const initialEvents = await viewHandle.getEvents(); + expect(initialEvents).toContain("onStart"); + expect(initialEvents).not.toContain("onBeforeConnect"); + expect(initialEvents).not.toContain("onConnect"); + expect(initialEvents).not.toContain("onDisconnect"); + + // Create a handle with trackLifecycle enabled for testing Action calls + const trackingHandle = client.counter.getOrCreate( + ["test-lifecycle-action"], + { params: { trackLifecycle: true } } + ); + + // Make an Action call + await trackingHandle.increment(5); + + // Check that it triggered the lifecycle hooks + const eventsAfterAction = await viewHandle.getEvents(); + + // Should have onBeforeConnect, onConnect, and onDisconnect for the Action call + expect(eventsAfterAction).toContain("onBeforeConnect"); + expect(eventsAfterAction).toContain("onConnect"); + expect(eventsAfterAction).toContain("onDisconnect"); + + // Each should have count 1 + expect(eventsAfterAction.filter(e => e === "onBeforeConnect").length).toBe(1); + expect(eventsAfterAction.filter(e => e === "onConnect").length).toBe(1); + expect(eventsAfterAction.filter(e => e === "onDisconnect").length).toBe(1); + + // Make another Action call + await trackingHandle.increment(10); + + // Check that it triggered another set of lifecycle hooks + const eventsAfterSecondAction = await viewHandle.getEvents(); + + // Each hook should now have count 2 + expect(eventsAfterSecondAction.filter(e => e === "onBeforeConnect").length).toBe(2); + expect(eventsAfterSecondAction.filter(e => e === "onConnect").length).toBe(2); + expect(eventsAfterSecondAction.filter(e => e === "onDisconnect").length).toBe(2); + }); + + test("should trigger lifecycle hooks for each Action call across multiple handles", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + LIFECYCLE_APP_PATH, + ); + + // Create a normal handle to view events + const viewHandle = client.counter.getOrCreate(["test-lifecycle-multi-handle"]); + + // Create two tracking handles to the same actor + const trackingHandle1 = client.counter.getOrCreate( + ["test-lifecycle-multi-handle"], + { params: { trackLifecycle: true } } + ); + + const trackingHandle2 = client.counter.getOrCreate( + ["test-lifecycle-multi-handle"], + { params: { trackLifecycle: true } } + ); + + // Make Action calls on both handles + await trackingHandle1.increment(5); + await trackingHandle2.increment(10); + + // Check lifecycle hooks + const events = await viewHandle.getEvents(); + + // Should have 1 onStart, 2 each of onBeforeConnect, onConnect, and onDisconnect + expect(events.filter(e => e === "onStart").length).toBe(1); + expect(events.filter(e => e === "onBeforeConnect").length).toBe(2); + expect(events.filter(e => e === "onConnect").length).toBe(2); + expect(events.filter(e => e === "onDisconnect").length).toBe(2); + }); + }); + }); +} diff --git a/packages/actor-core/src/driver-test-suite/tests/actor-metadata.ts b/packages/actor-core/src/driver-test-suite/tests/actor-metadata.ts new file mode 100644 index 000000000..24448ef1e --- /dev/null +++ b/packages/actor-core/src/driver-test-suite/tests/actor-metadata.ts @@ -0,0 +1,146 @@ +import { describe, test, expect } from "vitest"; +import type { DriverTestConfig } from "../mod"; +import { setupDriverTest } from "../utils"; +import { + METADATA_APP_PATH, + type MetadataApp, +} from "../test-apps"; + +export function runActorMetadataTests( + driverTestConfig: DriverTestConfig +) { + describe("Actor Metadata Tests", () => { + describe("Actor Name", () => { + test("should provide access to actor name", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + METADATA_APP_PATH, + ); + + // Get the actor name + const handle = client.metadataActor.getOrCreate(); + const actorName = await handle.getActorName(); + + // Verify it matches the expected name + expect(actorName).toBe("metadataActor"); + }); + + test("should preserve actor name in state during onStart", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + METADATA_APP_PATH, + ); + + // Get the stored actor name + const handle = client.metadataActor.getOrCreate(); + const storedName = await handle.getStoredActorName(); + + // Verify it was stored correctly + expect(storedName).toBe("metadataActor"); + }); + }); + + describe("Actor Tags", () => { + test("should provide access to tags", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + METADATA_APP_PATH, + ); + + // Create actor and set up test tags + const handle = client.metadataActor.getOrCreate(); + await handle.setupTestTags({ + "env": "test", + "purpose": "metadata-test" + }); + + // Get the tags + const tags = await handle.getTags(); + + // Verify the tags are accessible + expect(tags).toHaveProperty("env"); + expect(tags.env).toBe("test"); + expect(tags).toHaveProperty("purpose"); + expect(tags.purpose).toBe("metadata-test"); + }); + + test("should allow accessing individual tags", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + METADATA_APP_PATH, + ); + + // Create actor and set up test tags + const handle = client.metadataActor.getOrCreate(); + await handle.setupTestTags({ + "category": "test-actor", + "version": "1.0" + }); + + // Get individual tags + const category = await handle.getTag("category"); + const version = await handle.getTag("version"); + const nonexistent = await handle.getTag("nonexistent"); + + // Verify the tag values + expect(category).toBe("test-actor"); + expect(version).toBe("1.0"); + expect(nonexistent).toBeNull(); + }); + }); + + describe("Metadata Structure", () => { + test("should provide complete metadata object", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + METADATA_APP_PATH, + ); + + // Create actor and set up test metadata + const handle = client.metadataActor.getOrCreate(); + await handle.setupTestTags({ "type": "metadata-test" }); + await handle.setupTestRegion("us-west-1"); + + // Get all metadata + const metadata = await handle.getMetadata(); + + // Verify structure of metadata + expect(metadata).toHaveProperty("name"); + expect(metadata.name).toBe("metadataActor"); + + expect(metadata).toHaveProperty("tags"); + expect(metadata.tags).toHaveProperty("type"); + expect(metadata.tags.type).toBe("metadata-test"); + + // Region should be set to our test value + expect(metadata).toHaveProperty("region"); + expect(metadata.region).toBe("us-west-1"); + }); + }); + + describe("Region Information", () => { + test("should retrieve region information", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + METADATA_APP_PATH, + ); + + // Create actor and set up test region + const handle = client.metadataActor.getOrCreate(); + await handle.setupTestRegion("eu-central-1"); + + // Get the region + const region = await handle.getRegion(); + + // Verify the region is set correctly + expect(region).toBe("eu-central-1"); + }); + }); + }); +} \ No newline at end of file diff --git a/packages/actor-core/src/driver-test-suite/tests/actor-schedule.ts b/packages/actor-core/src/driver-test-suite/tests/actor-schedule.ts new file mode 100644 index 000000000..9fe8a73e6 --- /dev/null +++ b/packages/actor-core/src/driver-test-suite/tests/actor-schedule.ts @@ -0,0 +1,127 @@ +import { describe, test, expect } from "vitest"; +import type { DriverTestConfig } from "../mod"; +import { setupDriverTest, waitFor } from "../utils"; +import { + SCHEDULED_APP_PATH, + type ScheduledApp, +} from "../test-apps"; + +export function runActorScheduleTests( + driverTestConfig: DriverTestConfig +) { + describe("Actor Schedule Tests", () => { + describe("Scheduled Alarms", () => { + test("executes c.schedule.at() with specific timestamp", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + SCHEDULED_APP_PATH, + ); + + // Create instance + const scheduled = client.scheduled.getOrCreate(); + + // Schedule a task to run in 100ms using timestamp + const timestamp = Date.now() + 100; + await scheduled.scheduleTaskAt(timestamp); + + // Wait for longer than the scheduled time + await waitFor(driverTestConfig, 150); + + // Verify the scheduled task ran + const lastRun = await scheduled.getLastRun(); + const scheduledCount = await scheduled.getScheduledCount(); + + expect(lastRun).toBeGreaterThan(0); + expect(scheduledCount).toBe(1); + }); + + test("executes c.schedule.after() with delay", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + SCHEDULED_APP_PATH, + ); + + // Create instance + const scheduled = client.scheduled.getOrCreate(); + + // Schedule a task to run in 100ms using delay + await scheduled.scheduleTaskAfter(100); + + // Wait for longer than the scheduled time + await waitFor(driverTestConfig, 150); + + // Verify the scheduled task ran + const lastRun = await scheduled.getLastRun(); + const scheduledCount = await scheduled.getScheduledCount(); + + expect(lastRun).toBeGreaterThan(0); + expect(scheduledCount).toBe(1); + }); + + test("scheduled tasks persist across actor restarts", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + SCHEDULED_APP_PATH, + ); + + // Create instance and schedule + const scheduled = client.scheduled.getOrCreate(); + await scheduled.scheduleTaskAfter(200); + + // Wait a little so the schedule is stored but hasn't triggered yet + await waitFor(driverTestConfig, 50); + + // Get a new reference to simulate actor restart + const newInstance = client.scheduled.getOrCreate(); + + // Verify the schedule still exists but hasn't run yet + const initialCount = await newInstance.getScheduledCount(); + expect(initialCount).toBe(0); + + // Wait for the scheduled task to execute + await waitFor(driverTestConfig, 200); + + // Verify the scheduled task ran after "restart" + const scheduledCount = await newInstance.getScheduledCount(); + expect(scheduledCount).toBe(1); + }); + + test("multiple scheduled tasks execute in order", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + SCHEDULED_APP_PATH, + ); + + // Create instance + const scheduled = client.scheduled.getOrCreate(); + + // Reset history to start fresh + await scheduled.clearHistory(); + + // Schedule multiple tasks with different delays + await scheduled.scheduleTaskAfterWithId("first", 50); + await scheduled.scheduleTaskAfterWithId("second", 150); + await scheduled.scheduleTaskAfterWithId("third", 250); + + // Wait for first task only + await waitFor(driverTestConfig, 100); + const history1 = await scheduled.getTaskHistory(); + expect(history1).toEqual(["first"]); + + // Wait for second task + await waitFor(driverTestConfig, 100); + const history2 = await scheduled.getTaskHistory(); + expect(history2).toEqual(["first", "second"]); + + // Wait for third task + await waitFor(driverTestConfig, 100); + const history3 = await scheduled.getTaskHistory(); + expect(history3).toEqual(["first", "second", "third"]); + }); + }); + }); +} \ No newline at end of file diff --git a/packages/actor-core/src/driver-test-suite/tests/actor-state.ts b/packages/actor-core/src/driver-test-suite/tests/actor-state.ts new file mode 100644 index 000000000..3718f5e8f --- /dev/null +++ b/packages/actor-core/src/driver-test-suite/tests/actor-state.ts @@ -0,0 +1,72 @@ +import { describe, test, expect } from "vitest"; +import type { DriverTestConfig } from "../mod"; +import { setupDriverTest } from "../utils"; +import { + COUNTER_APP_PATH, + type CounterApp, +} from "../test-apps"; + +export function runActorStateTests( + driverTestConfig: DriverTestConfig +) { + describe("Actor State Tests", () => { + describe("State Persistence", () => { + test("persists state between actor instances", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create instance and increment + const counterInstance = client.counter.getOrCreate(); + const initialCount = await counterInstance.increment(5); + expect(initialCount).toBe(5); + + // Get a fresh reference to the same actor and verify state persisted + const sameInstance = client.counter.getOrCreate(); + const persistedCount = await sameInstance.increment(3); + expect(persistedCount).toBe(8); + }); + + test("restores state after actor disconnect/reconnect", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create actor and set initial state + const counterInstance = client.counter.getOrCreate(); + await counterInstance.increment(5); + + // Reconnect to the same actor + const reconnectedInstance = client.counter.getOrCreate(); + const persistedCount = await reconnectedInstance.increment(0); + expect(persistedCount).toBe(5); + }); + + test("maintains separate state for different actors", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create first counter with specific key + const counterA = client.counter.getOrCreate(["counter-a"]); + await counterA.increment(5); + + // Create second counter with different key + const counterB = client.counter.getOrCreate(["counter-b"]); + await counterB.increment(10); + + // Verify state is separate + const countA = await counterA.increment(0); + const countB = await counterB.increment(0); + expect(countA).toBe(5); + expect(countB).toBe(10); + }); + }); + }); +} \ No newline at end of file diff --git a/packages/actor-core/src/driver-test-suite/tests/actor-vars.ts b/packages/actor-core/src/driver-test-suite/tests/actor-vars.ts new file mode 100644 index 000000000..2b616b5e7 --- /dev/null +++ b/packages/actor-core/src/driver-test-suite/tests/actor-vars.ts @@ -0,0 +1,114 @@ +import { describe, test, expect } from "vitest"; +import type { DriverTestConfig } from "../mod"; +import { setupDriverTest } from "../utils"; +import { VARS_APP_PATH, type VarsApp } from "../test-apps"; + +export function runActorVarsTests(driverTestConfig: DriverTestConfig) { + describe("Actor Variables", () => { + describe("Static vars", () => { + test("should provide access to static vars", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + VARS_APP_PATH, + ); + + const instance = client.staticVarActor.getOrCreate(); + + // Test accessing vars + const result = await instance.getVars(); + expect(result).toEqual({ counter: 42, name: "test-actor" }); + + // Test accessing specific var property + const name = await instance.getName(); + expect(name).toBe("test-actor"); + }); + }); + + describe("Deep cloning of static vars", () => { + test("should deep clone static vars between actor instances", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + VARS_APP_PATH, + ); + + // Create two separate instances + const instance1 = client.nestedVarActor.getOrCreate(["instance1"]); + const instance2 = client.nestedVarActor.getOrCreate(["instance2"]); + + // Modify vars in the first instance + const modifiedVars = await instance1.modifyNested(); + expect(modifiedVars.nested.value).toBe("modified"); + expect(modifiedVars.nested.array).toContain(4); + expect(modifiedVars.nested.obj.key).toBe("new-value"); + + // Check that the second instance still has the original values + const instance2Vars = await instance2.getVars(); + expect(instance2Vars.nested.value).toBe("original"); + expect(instance2Vars.nested.array).toEqual([1, 2, 3]); + expect(instance2Vars.nested.obj.key).toBe("value"); + }); + }); + + describe("createVars", () => { + test("should support dynamic vars creation", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + VARS_APP_PATH, + ); + + // Create an instance + const instance = client.dynamicVarActor.getOrCreate(); + + // Test accessing dynamically created vars + const vars = await instance.getVars(); + expect(vars).toHaveProperty("random"); + expect(vars).toHaveProperty("computed"); + expect(typeof vars.random).toBe("number"); + expect(typeof vars.computed).toBe("string"); + expect(vars.computed).toMatch(/^Actor-\d+$/); + }); + + test("should create different vars for different instances", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + VARS_APP_PATH, + ); + + // Create two separate instances + const instance1 = client.uniqueVarActor.getOrCreate(["test1"]); + const instance2 = client.uniqueVarActor.getOrCreate(["test2"]); + + // Get vars from both instances + const vars1 = await instance1.getVars(); + const vars2 = await instance2.getVars(); + + // Verify they have different values + expect(vars1.id).not.toBe(vars2.id); + }); + }); + + describe("Driver Context", () => { + test("should provide access to driver context", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + VARS_APP_PATH, + ); + + // Create an instance + const instance = client.driverCtxActor.getOrCreate(); + + // Test accessing driver context through vars + const vars = await instance.getVars(); + + // Driver context might or might not be available depending on the driver + // But the test should run without errors + expect(vars).toHaveProperty('hasDriverCtx'); + }); + }); + }); +} \ No newline at end of file diff --git a/packages/actor-core/src/driver-test-suite/tests/manager-driver.ts b/packages/actor-core/src/driver-test-suite/tests/manager-driver.ts new file mode 100644 index 000000000..832bef2a2 --- /dev/null +++ b/packages/actor-core/src/driver-test-suite/tests/manager-driver.ts @@ -0,0 +1,338 @@ +import { describe, test, expect, vi } from "vitest"; +import { setupDriverTest } from "../utils"; +import { ActorError } from "@/client/mod"; +import { COUNTER_APP_PATH, type CounterApp } from "../test-apps"; +import { DriverTestConfig } from "../mod"; + +export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { + describe("Manager Driver Tests", () => { + describe("Client Connection Methods", () => { + test("connect() - finds or creates an actor", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Basic connect() with no parameters creates a default actor + const counterA = client.counter.getOrCreate(); + await counterA.increment(5); + + // Get the same actor again to verify state persisted + const counterAAgain = client.counter.getOrCreate(); + const count = await counterAAgain.increment(0); + expect(count).toBe(5); + + // Connect with key creates a new actor with specific parameters + const counterB = client.counter.getOrCreate(["counter-b", "testing"]); + + await counterB.increment(10); + const countB = await counterB.increment(0); + expect(countB).toBe(10); + }); + + test("throws ActorAlreadyExists when creating duplicate actors", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create a unique actor with specific key + const uniqueKey = ["duplicate-actor-test", crypto.randomUUID()]; + const counter = client.counter.getOrCreate(uniqueKey); + await counter.increment(5); + + // Expect duplicate actor + try { + await client.counter.create(uniqueKey); + expect.fail("did not error on duplicate create"); + } catch (err) { + expect(err).toBeInstanceOf(ActorError); + expect((err as ActorError).code).toBe("actor_already_exists"); + } + + // Verify the original actor still works and has its state + const count = await counter.increment(0); + expect(count).toBe(5); + }); + }); + + describe("Connection Options", () => { + test("get without create prevents actor creation", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Try to get a nonexistent actor with no create + const nonexistentId = `nonexistent-${crypto.randomUUID()}`; + + // Should fail when actor doesn't exist + try { + await client.counter.get([nonexistentId]).resolve(); + expect.fail("did not error for get"); + } catch (err) { + expect(err).toBeInstanceOf(ActorError); + expect((err as ActorError).code).toBe("actor_not_found"); + } + + // Create the actor + const createdCounter = client.counter.getOrCreate(nonexistentId); + await createdCounter.increment(3); + + // Now no create should work since the actor exists + const retrievedCounter = client.counter.get(nonexistentId); + + const count = await retrievedCounter.increment(0); + expect(count).toBe(3); + }); + + test("connection params are passed to actors", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create an actor with connection params + // Note: In a real test we'd verify these are received by the actor, + // but our simple counter actor doesn't use connection params. + // This test just ensures the params are accepted by the driver. + const counter = client.counter.getOrCreate(undefined, { + params: { + userId: "user-123", + authToken: "token-abc", + settings: { increment: 5 }, + }, + }); + + await counter.increment(1); + const count = await counter.increment(0); + expect(count).toBe(1); + }); + }); + + describe("Actor Creation & Retrieval", () => { + test("creates and retrieves actors by ID", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create a unique ID for this test + const uniqueId = `test-counter-${crypto.randomUUID()}`; + + // Create actor with specific ID + const counter = client.counter.getOrCreate([uniqueId]); + await counter.increment(10); + + // Retrieve the same actor by ID and verify state + const retrievedCounter = client.counter.getOrCreate([uniqueId]); + const count = await retrievedCounter.increment(0); // Get current value + expect(count).toBe(10); + }); + + // TODO: Correctly test region for each provider + //test("creates and retrieves actors with region", async (c) => { + // const { client } = await setupDriverTest(c, + // driverTestConfig, + // COUNTER_APP_PATH + // ); + // + // // Create actor with a specific region + // const counter = client.counter.getOrCreate({ + // create: { + // key: ["metadata-test", "testing"], + // region: "test-region", + // }, + // }); + // + // // Set state to identify this specific instance + // await counter.increment(42); + // + // // Retrieve by ID (since metadata is not used for retrieval) + // const retrievedCounter = client.counter.getOrCreate(["metadata-test"]); + // + // // Verify it's the same instance + // const count = await retrievedCounter.increment(0); + // expect(count).toBe(42); + //}); + }); + + describe("Key Matching", () => { + test("matches actors only with exactly the same keys", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create actor with multiple keys + const originalCounter = client.counter.getOrCreate([ + "counter-match", + "test", + "us-east", + ]); + await originalCounter.increment(10); + + // Should match with exact same keys + const exactMatchCounter = client.counter.getOrCreate([ + "counter-match", + "test", + "us-east", + ]); + const exactMatchCount = await exactMatchCounter.increment(0); + expect(exactMatchCount).toBe(10); + + // Should NOT match with subset of keys - should create new actor + const subsetMatchCounter = client.counter.getOrCreate([ + "counter-match", + "test", + ]); + const subsetMatchCount = await subsetMatchCounter.increment(0); + expect(subsetMatchCount).toBe(0); // Should be a new counter with 0 + + // Should NOT match with just one key - should create new actor + const singleKeyCounter = client.counter.getOrCreate(["counter-match"]); + const singleKeyCount = await singleKeyCounter.increment(0); + expect(singleKeyCount).toBe(0); // Should be a new counter with 0 + }); + + test("string key matches array with single string key", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create actor with string key + const stringKeyCounter = client.counter.getOrCreate("string-key-test"); + await stringKeyCounter.increment(7); + + // Should match with equivalent array key + const arrayKeyCounter = client.counter.getOrCreate(["string-key-test"]); + const count = await arrayKeyCounter.increment(0); + expect(count).toBe(7); + }); + + test("undefined key matches empty array key and no key", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create actor with undefined key + const undefinedKeyCounter = client.counter.getOrCreate(undefined); + await undefinedKeyCounter.increment(12); + + // Should match with empty array key + const emptyArrayKeyCounter = client.counter.getOrCreate([]); + const emptyArrayCount = await emptyArrayKeyCounter.increment(0); + expect(emptyArrayCount).toBe(12); + + // Should match with no key + const noKeyCounter = client.counter.getOrCreate(); + const noKeyCount = await noKeyCounter.increment(0); + expect(noKeyCount).toBe(12); + }); + + test("no keys does not match actors with keys", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create counter with keys + const keyedCounter = client.counter.getOrCreate([ + "counter-with-keys", + "special", + ]); + await keyedCounter.increment(15); + + // Should not match when searching with no keys + const noKeysCounter = client.counter.getOrCreate(); + const count = await noKeysCounter.increment(10); + expect(count).toBe(10); + }); + + test("actors with keys match actors with no keys", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create a counter with no keys + const noKeysCounter = client.counter.getOrCreate(); + await noKeysCounter.increment(25); + + // Get counter with keys - should create a new one + const keyedCounter = client.counter.getOrCreate([ + "new-counter", + "prod", + ]); + const keyedCount = await keyedCounter.increment(0); + + // Should be a new counter, not the one created above + expect(keyedCount).toBe(0); + }); + }); + + describe("Multiple Actor Instances", () => { + // TODO: This test is flakey https://github.com/rivet-gg/actor-core/issues/873 + test("creates multiple actor instances of the same type", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Create multiple instances with different IDs + const instance1 = client.counter.getOrCreate(["multi-1"]); + const instance2 = client.counter.getOrCreate(["multi-2"]); + const instance3 = client.counter.getOrCreate(["multi-3"]); + + // Set different states + await instance1.increment(1); + await instance2.increment(2); + await instance3.increment(3); + + // Retrieve all instances again + const retrieved1 = client.counter.getOrCreate(["multi-1"]); + const retrieved2 = client.counter.getOrCreate(["multi-2"]); + const retrieved3 = client.counter.getOrCreate(["multi-3"]); + + // Verify separate state + expect(await retrieved1.increment(0)).toBe(1); + expect(await retrieved2.increment(0)).toBe(2); + expect(await retrieved3.increment(0)).toBe(3); + }); + + test("handles default instance with no explicit ID", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + COUNTER_APP_PATH, + ); + + // Get default instance (no ID specified) + const defaultCounter = client.counter.getOrCreate(); + + // Set state + await defaultCounter.increment(5); + + // Get default instance again + const sameDefaultCounter = client.counter.getOrCreate(); + + // Verify state is maintained + const count = await sameDefaultCounter.increment(0); + expect(count).toBe(5); + }); + }); + }); +} diff --git a/packages/misc/driver-test-suite/src/utils.ts b/packages/actor-core/src/driver-test-suite/utils.ts similarity index 55% rename from packages/misc/driver-test-suite/src/utils.ts rename to packages/actor-core/src/driver-test-suite/utils.ts index 671837138..f0c455665 100644 --- a/packages/misc/driver-test-suite/src/utils.ts +++ b/packages/actor-core/src/driver-test-suite/utils.ts @@ -1,7 +1,7 @@ -import type { ActorCoreApp } from "actor-core"; +import type { ActorCoreApp } from "@/mod"; import { type TestContext, vi } from "vitest"; -import { createClient, type Client } from "actor-core/client"; -import type { DriverTestConfig } from "./mod"; +import { createClient, type Client } from "@/client/mod"; +import type { DriverTestConfig, } from "./mod"; // Must use `TestContext` since global hooks do not work when running concurrently export async function setupDriverTest>( @@ -20,7 +20,9 @@ export async function setupDriverTest>( c.onTestFinished(cleanup); // Create client - const client = createClient(endpoint); + const client = createClient(endpoint, { + transport: driverTestConfig.transport, + }); if (!driverTestConfig.HACK_skipCleanupNet) { c.onTestFinished(async () => await client.dispose()); } @@ -29,3 +31,15 @@ export async function setupDriverTest>( client, }; } + +export async function waitFor( + driverTestConfig: DriverTestConfig, + ms: number, +): Promise { + if (driverTestConfig.useRealTimers) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } else { + vi.advanceTimersByTime(ms); + return Promise.resolve(); + } +} diff --git a/packages/actor-core/src/inspector/actor.ts b/packages/actor-core/src/inspector/actor.ts index 768b62b79..9ff3f3cc7 100644 --- a/packages/actor-core/src/inspector/actor.ts +++ b/packages/actor-core/src/inspector/actor.ts @@ -108,7 +108,7 @@ export class ActorInspector extends Inspector { enabled: connection._stateEnabled, }, })), - rpcs: this.actor.rpcs, + actions: this.actor.actions, state: { value: this.actor.stateEnabled ? this.actor.state : undefined, enabled: this.actor.stateEnabled, diff --git a/packages/actor-core/src/inspector/protocol/actor/to-client.ts b/packages/actor-core/src/inspector/protocol/actor/to-client.ts index b6c8cd59f..e94d76ac9 100644 --- a/packages/actor-core/src/inspector/protocol/actor/to-client.ts +++ b/packages/actor-core/src/inspector/protocol/actor/to-client.ts @@ -11,7 +11,7 @@ const ConnSchema = z.object({ export const InspectDataSchema = z.object({ connections: z.array(ConnSchema), - rpcs: z.array(z.string()), + actions: z.array(z.string()), state: z.object({ enabled: z.boolean(), value: z.any().optional(), diff --git a/packages/actor-core/src/manager/driver.ts b/packages/actor-core/src/manager/driver.ts index 481f538a2..8d2c46f83 100644 --- a/packages/actor-core/src/manager/driver.ts +++ b/packages/actor-core/src/manager/driver.ts @@ -11,32 +11,32 @@ export interface ManagerDriver { } export interface GetForIdInput { c?: HonoContext; - baseUrl: string; actorId: string; } export interface GetWithKeyInput { c?: HonoContext; - baseUrl: string; name: string; key: ActorKey; } export interface GetActorOutput { c?: HonoContext; - endpoint: string; + actorId: string; name: string; key: ActorKey; + meta?: unknown; } export interface CreateActorInput { c?: HonoContext; - baseUrl: string; name: string; key: ActorKey; region?: string; } export interface CreateActorOutput { - endpoint: string; + actorId: string; + meta?: unknown; } + diff --git a/packages/actor-core/src/manager/protocol/mod.ts b/packages/actor-core/src/manager/protocol/mod.ts index 2c3bceefe..5ca94297f 100644 --- a/packages/actor-core/src/manager/protocol/mod.ts +++ b/packages/actor-core/src/manager/protocol/mod.ts @@ -8,7 +8,7 @@ export const ActorsRequestSchema = z.object({ }); export const ActorsResponseSchema = z.object({ - endpoint: z.string(), + actorId: z.string(), supportedTransports: z.array(TransportSchema), }); diff --git a/packages/actor-core/src/manager/protocol/query.ts b/packages/actor-core/src/manager/protocol/query.ts index 8e49fe7ce..df67d9629 100644 --- a/packages/actor-core/src/manager/protocol/query.ts +++ b/packages/actor-core/src/manager/protocol/query.ts @@ -1,5 +1,14 @@ -import { ActorKeySchema, type ActorKey } from "@/common//utils"; +import { ActorKeySchema } from "@/common//utils"; import { z } from "zod"; +import { EncodingSchema } from "@/actor/protocol/serde"; +import { + HEADER_ACTOR_ID, + HEADER_CONN_ID, + HEADER_CONN_PARAMS, + HEADER_CONN_TOKEN, + HEADER_ENCODING, + HEADER_ACTOR_QUERY, +} from "@/actor/router-endpoints"; export const CreateRequestSchema = z.object({ name: z.string(), @@ -35,9 +44,32 @@ export const ActorQuerySchema = z.union([ }), ]); +export const ConnectRequestSchema = z.object({ + query: ActorQuerySchema.describe(HEADER_ACTOR_QUERY), + encoding: EncodingSchema.describe(HEADER_ENCODING), + connParams: z.string().optional().describe(HEADER_CONN_PARAMS), +}); + +export const ConnectWebSocketRequestSchema = z.object({ + query: ActorQuerySchema.describe("query"), + encoding: EncodingSchema.describe("encoding"), +}); + +export const ConnMessageRequestSchema = z.object({ + actorId: z.string().describe(HEADER_ACTOR_ID), + connId: z.string().describe(HEADER_CONN_ID), + encoding: EncodingSchema.describe(HEADER_ENCODING), + connToken: z.string().describe(HEADER_CONN_TOKEN), +}); + +export const ResolveRequestSchema = z.object({ + query: ActorQuerySchema.describe(HEADER_ACTOR_QUERY), +}); + export type ActorQuery = z.infer; export type GetForKeyRequest = z.infer; export type GetOrCreateRequest = z.infer; +export type ConnectQuery = z.infer; /** * Interface representing a request to create an actor. */ diff --git a/packages/actor-core/src/manager/router.ts b/packages/actor-core/src/manager/router.ts index 4c0ae9d00..c41606b6b 100644 --- a/packages/actor-core/src/manager/router.ts +++ b/packages/actor-core/src/manager/router.ts @@ -1,22 +1,135 @@ -import { ActorsRequestSchema } from "@/manager/protocol/mod"; -import { Hono, type Context as HonoContext } from "hono"; -import { cors } from "hono/cors"; -import { logger } from "./log"; -import { assertUnreachable } from "@/common/utils"; -import { handleRouteError, handleRouteNotFound } from "@/common/router"; -import type { DriverConfig } from "@/driver-helpers/config"; +import * as errors from "@/actor/errors"; +import type * as protoHttpResolve from "@/actor/protocol/http/resolve"; +import type { ToClient } from "@/actor/protocol/message/to-client"; +import { type Encoding, serialize } from "@/actor/protocol/serde"; +import { + type ConnectionHandlers, + getRequestEncoding, + handleConnectionMessage, + handleAction, + handleSseConnect, + handleWebSocketConnect, + HEADER_ACTOR_ID, + HEADER_CONN_ID, + HEADER_CONN_PARAMS, + HEADER_CONN_TOKEN, + HEADER_ENCODING, + HEADER_ACTOR_QUERY, + ALL_HEADERS, + getRequestQuery, +} from "@/actor/router-endpoints"; +import { assertUnreachable } from "@/actor/utils"; import type { AppConfig } from "@/app/config"; import { - createManagerInspectorRouter, + handleRouteError, + handleRouteNotFound, + loggerMiddleware, +} from "@/common/router"; +import { deconstructError } from "@/common/utils"; +import type { DriverConfig } from "@/driver-helpers/config"; +import { type ManagerInspectorConnHandler, + createManagerInspectorRouter, } from "@/inspector/manager"; -import type { UpgradeWebSocket } from "hono/ws"; +import { Hono, type Context as HonoContext, type Next } from "hono"; +import { OpenAPIHono } from "@hono/zod-openapi"; +import { z } from "@hono/zod-openapi"; +import { createRoute } from "@hono/zod-openapi"; +import { cors } from "hono/cors"; +import { streamSSE } from "hono/streaming"; +import type { WSContext } from "hono/ws"; +import invariant from "invariant"; +import type { ManagerDriver } from "./driver"; +import { logger } from "./log"; +import { + ConnectRequestSchema, + ConnectWebSocketRequestSchema, + ConnMessageRequestSchema, + ResolveRequestSchema, +} from "./protocol/query"; +import type { ActorQuery } from "./protocol/query"; +import { VERSION } from "@/utils"; + +type ProxyMode = + | { + inline: { + handlers: ConnectionHandlers; + }; + } + | { + custom: { + onProxyRequest: OnProxyRequest; + onProxyWebSocket: OnProxyWebSocket; + }; + }; + +export type BuildProxyEndpoint = (c: HonoContext, actorId: string) => string; + +export type OnProxyRequest = ( + c: HonoContext, + actorRequest: Request, + actorId: string, + meta?: unknown, +) => Promise; + +export type OnProxyWebSocket = ( + c: HonoContext, + path: string, + actorId: string, + meta?: unknown, +) => Promise; type ManagerRouterHandler = { onConnectInspector?: ManagerInspectorConnHandler; - upgradeWebSocket?: UpgradeWebSocket; + proxyMode: ProxyMode; }; +const OPENAPI_ENCODING = z.string().openapi({ + description: "The encoding format to use for the response (json, cbor)", + example: "json", +}); + +const OPENAPI_ACTOR_QUERY = z.string().openapi({ + description: "Actor query information", +}); + +const OPENAPI_CONN_PARAMS = z.string().openapi({ + description: "Connection parameters", +}); + +const OPENAPI_ACTOR_ID = z.string().openapi({ + description: "Actor ID (used in some endpoints)", + example: "actor-123456", +}); + +const OPENAPI_CONN_ID = z.string().openapi({ + description: "Connection ID", + example: "conn-123456", +}); + +const OPENAPI_CONN_TOKEN = z.string().openapi({ + description: "Connection token", +}); + +function buildOpenApiResponses(schema: T) { + return { + 200: { + description: "Success", + content: { + "application/json": { + schema, + }, + }, + }, + 400: { + description: "User error", + }, + 500: { + description: "Internal error", + }, + }; +} + export function createManagerRouter( appConfig: AppConfig, driverConfig: DriverConfig, @@ -27,117 +140,735 @@ export function createManagerRouter( throw new Error("config.drivers.manager is not defined."); } const driver = driverConfig.drivers.manager; - const app = new Hono(); + const app = new OpenAPIHono(); + + const upgradeWebSocket = driverConfig.getUpgradeWebSocket?.( + app as unknown as Hono, + ); + + app.use("*", loggerMiddleware(logger())); - // Apply CORS middleware if configured if (appConfig.cors) { - app.use("*", cors(appConfig.cors)); + const corsConfig = appConfig.cors; + + app.use("*", async (c, next) => { + const path = c.req.path; + + // Don't apply to WebSocket routes + if (path === "/actors/connect/websocket" || path === "/inspect") { + return next(); + } + + return cors({ + ...corsConfig, + allowHeaders: [...(appConfig.cors?.allowHeaders ?? []), ...ALL_HEADERS], + })(c, next); + }); } + // GET / app.get("/", (c) => { return c.text( "This is an ActorCore server.\n\nLearn more at https://actorcore.org", ); }); + // GET /health app.get("/health", (c) => { return c.text("ok"); }); - app.post("/manager/actors", async (c: HonoContext) => { - const { query } = ActorsRequestSchema.parse(await c.req.json()); - logger().debug("query", { query }); + // POST /actors/resolve + { + const ResolveQuerySchema = z + .object({ + query: z.any().openapi({ + example: { getForId: { actorId: "actor-123" } }, + }), + }) + .openapi("ResolveQuery"); - const url = new URL(c.req.url); + const ResolveResponseSchema = z + .object({ + i: z.string().openapi({ + example: "actor-123", + }), + }) + .openapi("ResolveResponse"); - // Determine base URL to build endpoints from - // - // This is used to build actor endpoints - let baseUrl = url.origin; - if (appConfig.basePath) { - const basePath = appConfig.basePath; - if (!basePath.startsWith("/")) - throw new Error("config.basePath must start with /"); - if (basePath.endsWith("/")) - throw new Error("config.basePath must not end with /"); - baseUrl += basePath; - } + const resolveRoute = createRoute({ + method: "post", + path: "/actors/resolve", + request: { + body: { + content: { + "application/json": { + schema: ResolveQuerySchema, + }, + }, + }, + headers: z.object({ + [HEADER_ACTOR_QUERY]: OPENAPI_ACTOR_QUERY, + }), + }, + responses: buildOpenApiResponses(ResolveResponseSchema), + }); - // Get the actor from the manager - let actorOutput: { endpoint: string }; - if ("getForId" in query) { - const output = await driver.getForId({ - c, - baseUrl: baseUrl, - actorId: query.getForId.actorId, - }); - if (!output) - throw new Error( - `Actor does not exist for ID: ${query.getForId.actorId}`, - ); - actorOutput = output; - } else if ("getForKey" in query) { - const existingActor = await driver.getWithKey({ + app.openapi(resolveRoute, (c) => handleResolveRequest(c, driver)); + } + + // GET /actors/connect/websocket + { + const wsRoute = createRoute({ + method: "get", + path: "/actors/connect/websocket", + request: { + query: z.object({ + encoding: OPENAPI_ENCODING, + query: OPENAPI_ACTOR_QUERY, + }), + }, + responses: { + 101: { + description: "WebSocket upgrade", + }, + }, + }); + + app.openapi(wsRoute, (c) => + handleWebSocketConnectRequest( c, - baseUrl: baseUrl, - name: query.getForKey.name, - key: query.getForKey.key, - }); - if (!existingActor) { - throw new Error("Actor not found with key."); - } + upgradeWebSocket, + appConfig, + driverConfig, + driver, + handler, + ), + ); + } + + // GET /actors/connect/sse + { + const sseRoute = createRoute({ + method: "get", + path: "/actors/connect/sse", + request: { + headers: z.object({ + [HEADER_ENCODING]: OPENAPI_ENCODING, + [HEADER_ACTOR_QUERY]: OPENAPI_ACTOR_QUERY, + [HEADER_CONN_PARAMS]: OPENAPI_CONN_PARAMS.optional(), + }), + }, + responses: { + 200: { + description: "SSE stream", + content: { + "text/event-stream": { + schema: z.unknown(), + }, + }, + }, + }, + }); + + app.openapi(sseRoute, (c) => + handleSseConnectRequest(c, appConfig, driverConfig, driver, handler), + ); + } + + // POST /actors/action/:action + { + const ActionParamsSchema = z + .object({ + action: z.string().openapi({ + param: { + name: "action", + in: "path", + }, + example: "myAction", + }), + }) + .openapi("ActionParams"); + + const ActionRequestSchema = z + .object({ + query: z.any().openapi({ + example: { getForId: { actorId: "actor-123" } }, + }), + body: z + .any() + .optional() + .openapi({ + example: { param1: "value1", param2: 123 }, + }), + }) + .openapi("ActionRequest"); + + const ActionResponseSchema = z.any().openapi("ActionResponse"); + + const actionRoute = createRoute({ + method: "post", + path: "/actors/actions/{action}", + request: { + params: ActionParamsSchema, + body: { + content: { + "application/json": { + schema: ActionRequestSchema, + }, + }, + }, + headers: z.object({ + [HEADER_ENCODING]: OPENAPI_ENCODING, + [HEADER_CONN_PARAMS]: OPENAPI_CONN_PARAMS.optional(), + }), + }, + responses: buildOpenApiResponses(ActionResponseSchema), + }); + + app.openapi(actionRoute, (c) => + handleActionRequest(c, appConfig, driverConfig, driver, handler), + ); + } + + // POST /actors/message + { + const ConnectionMessageRequestSchema = z + .object({ + message: z.any().openapi({ + example: { type: "message", content: "Hello, actor!" }, + }), + }) + .openapi("ConnectionMessageRequest"); + + const ConnectionMessageResponseSchema = z + .any() + .openapi("ConnectionMessageResponse"); + + const messageRoute = createRoute({ + method: "post", + path: "/actors/message", + request: { + body: { + content: { + "application/json": { + schema: ConnectionMessageRequestSchema, + }, + }, + }, + headers: z.object({ + [HEADER_ACTOR_ID]: OPENAPI_ACTOR_ID, + [HEADER_CONN_ID]: OPENAPI_CONN_ID, + [HEADER_ENCODING]: OPENAPI_ENCODING, + [HEADER_CONN_TOKEN]: OPENAPI_CONN_TOKEN, + }), + }, + responses: buildOpenApiResponses(ConnectionMessageResponseSchema), + }); + + app.openapi(messageRoute, (c) => + handleMessageRequest(c, appConfig, handler), + ); + } + + if (appConfig.inspector.enabled) { + app.route( + "/inspect", + createManagerInspectorRouter( + upgradeWebSocket, + handler.onConnectInspector, + appConfig.inspector, + ), + ); + } + + app.doc("/openapi.json", { + openapi: "3.0.0", + info: { + version: VERSION, + title: "ActorCore API", + }, + }); + + app.notFound(handleRouteNotFound); + app.onError(handleRouteError); + + return app as unknown as Hono; +} + +/** + * Query the manager driver to get or create an actor based on the provided query + */ +export async function queryActor( + c: HonoContext, + query: ActorQuery, + driver: ManagerDriver, +): Promise<{ actorId: string; meta?: unknown }> { + logger().debug("querying actor", { query }); + let actorOutput: { actorId: string; meta?: unknown }; + if ("getForId" in query) { + const output = await driver.getForId({ + c, + actorId: query.getForId.actorId, + }); + if (!output) throw new errors.ActorNotFound(query.getForId.actorId); + actorOutput = output; + } else if ("getForKey" in query) { + const existingActor = await driver.getWithKey({ + c, + name: query.getForKey.name, + key: query.getForKey.key, + }); + if (!existingActor) { + throw new errors.ActorNotFound( + `${query.getForKey.name}:${JSON.stringify(query.getForKey.key)}`, + ); + } + actorOutput = existingActor; + } else if ("getOrCreateForKey" in query) { + const existingActor = await driver.getWithKey({ + c, + name: query.getOrCreateForKey.name, + key: query.getOrCreateForKey.key, + }); + if (existingActor) { + // Actor exists actorOutput = existingActor; - } else if ("getOrCreateForKey" in query) { - const existingActor = await driver.getWithKey({ + } else { + // Create if needed + const createOutput = await driver.createActor({ c, - baseUrl: baseUrl, name: query.getOrCreateForKey.name, key: query.getOrCreateForKey.key, + region: query.getOrCreateForKey.region, }); - if (existingActor) { - // Actor exists - actorOutput = existingActor; - } else { - // Create if needed - actorOutput = await driver.createActor({ - c, - baseUrl: baseUrl, - name: query.getOrCreateForKey.name, - key: query.getOrCreateForKey.key, - region: query.getOrCreateForKey.region, + actorOutput = { + actorId: createOutput.actorId, + meta: createOutput.meta, + }; + } + } else if ("create" in query) { + const createOutput = await driver.createActor({ + c, + name: query.create.name, + key: query.create.key, + region: query.create.region, + }); + actorOutput = { + actorId: createOutput.actorId, + meta: createOutput.meta, + }; + } else { + throw new errors.InvalidRequest("Invalid query format"); + } + + logger().debug("actor query result", { + actorId: actorOutput.actorId, + meta: actorOutput.meta, + }); + return { actorId: actorOutput.actorId, meta: actorOutput.meta }; +} + +/** + * Handle SSE connection request + */ +async function handleSseConnectRequest( + c: HonoContext, + appConfig: AppConfig, + driverConfig: DriverConfig, + driver: ManagerDriver, + handler: ManagerRouterHandler, +): Promise { + let encoding: Encoding | undefined; + try { + encoding = getRequestEncoding(c.req, false); + logger().debug("sse connection request received", { encoding }); + + const params = ConnectRequestSchema.safeParse({ + query: getRequestQuery(c, false), + encoding: c.req.header(HEADER_ENCODING), + params: c.req.header(HEADER_CONN_PARAMS), + }); + + if (!params.success) { + logger().error("invalid connection parameters", { + error: params.error, + }); + throw new errors.InvalidRequest(params.error); + } + + const query = params.data.query; + + // Get the actor ID and meta + const { actorId, meta } = await queryActor(c, query, driver); + invariant(actorId, "Missing actor ID"); + logger().debug("sse connection to actor", { actorId, meta }); + + // Handle based on mode + if ("inline" in handler.proxyMode) { + logger().debug("using inline proxy mode for sse connection"); + // Use the shared SSE handler + return await handleSseConnect( + c, + appConfig, + driverConfig, + handler.proxyMode.inline.handlers.onConnectSse, + actorId, + ); + } else if ("custom" in handler.proxyMode) { + logger().debug("using custom proxy mode for sse connection"); + const url = new URL("http://actor/connect/sse"); + const proxyRequest = new Request(url, c.req.raw); + proxyRequest.headers.set(HEADER_ENCODING, params.data.encoding); + if (params.data.connParams) { + proxyRequest.headers.set(HEADER_CONN_PARAMS, params.data.connParams); + } + return await handler.proxyMode.custom.onProxyRequest( + c, + proxyRequest, + actorId, + meta, + ); + } else { + assertUnreachable(handler.proxyMode); + } + } catch (error) { + // If we receive an error during setup, we send the error and close the socket immediately + // + // We have to return the error over SSE since SSE clients cannot read vanilla HTTP responses + + const { code, message, metadata } = deconstructError(error, logger(), { + sseEvent: "setup", + }); + + return streamSSE(c, async (stream) => { + try { + if (encoding) { + // Serialize and send the connection error + const errorMsg: ToClient = { + b: { + e: { + c: code, + m: message, + md: metadata, + }, + }, + }; + + // Send the error message to the client + const serialized = serialize(errorMsg, encoding); + await stream.writeSSE({ + data: + typeof serialized === "string" + ? serialized + : Buffer.from(serialized).toString("base64"), + }); + } else { + // We don't know the encoding, send an error and close + await stream.writeSSE({ + data: code, + event: "error", + }); + } + } catch (serializeError) { + logger().error("failed to send error to sse client", { + error: serializeError, + }); + await stream.writeSSE({ + data: "internal error during error handling", + event: "error", }); } - } else if ("create" in query) { - actorOutput = await driver.createActor({ + + // Stream will exit completely once function exits + }); + } +} + +/** + * Handle WebSocket connection request + */ +async function handleWebSocketConnectRequest( + c: HonoContext, + upgradeWebSocket: + | (( + createEvents: (c: HonoContext) => any, + ) => (c: HonoContext, next: Next) => Promise) + | undefined, + appConfig: AppConfig, + driverConfig: DriverConfig, + driver: ManagerDriver, + handler: ManagerRouterHandler, +): Promise { + invariant(upgradeWebSocket, "WebSockets not supported"); + + let encoding: Encoding | undefined; + try { + logger().debug("websocket connection request received"); + + // We can't use the standard headers with WebSockets + // + // All other information will be sent over the socket itself, since that data needs to be E2EE + const params = ConnectWebSocketRequestSchema.safeParse({ + query: getRequestQuery(c, true), + encoding: c.req.query("encoding"), + }); + if (!params.success) { + logger().error("invalid connection parameters", { + error: params.error, + }); + throw new errors.InvalidRequest(params.error); + } + + // Get the actor ID and meta + const { actorId, meta } = await queryActor(c, params.data.query, driver); + logger().debug("found actor for websocket connection", { actorId, meta }); + invariant(actorId, "missing actor id"); + + if ("inline" in handler.proxyMode) { + logger().debug("using inline proxy mode for websocket connection"); + invariant( + handler.proxyMode.inline.handlers.onConnectWebSocket, + "onConnectWebSocket not provided", + ); + + const onConnectWebSocket = + handler.proxyMode.inline.handlers.onConnectWebSocket; + return upgradeWebSocket((c) => { + return handleWebSocketConnect( + c, + appConfig, + driverConfig, + onConnectWebSocket, + actorId, + )(); + })(c, noopNext()); + } else if ("custom" in handler.proxyMode) { + logger().debug("using custom proxy mode for websocket connection"); + return await handler.proxyMode.custom.onProxyWebSocket( c, - baseUrl: baseUrl, - name: query.create.name, - key: query.create.key, - region: query.create.region, + `/connect/websocket?encoding=${params.data.encoding}`, + actorId, + meta, + ); + } else { + assertUnreachable(handler.proxyMode); + } + } catch (error) { + // If we receive an error during setup, we send the error and close the socket immediately + // + // We have to return the error over WS since WebSocket clients cannot read vanilla HTTP responses + + const { code, message, metadata } = deconstructError(error, logger(), { + wsEvent: "setup", + }); + + return await upgradeWebSocket(() => ({ + onOpen: async (_evt: unknown, ws: WSContext) => { + if (encoding) { + try { + // Serialize and send the connection error + const errorMsg: ToClient = { + b: { + e: { + c: code, + m: message, + md: metadata, + }, + }, + }; + + // Send the error message to the client + const serialized = serialize(errorMsg, encoding); + ws.send(serialized); + + // Close the connection with an error code + ws.close(1011, code); + } catch (serializeError) { + logger().error("failed to send error to websocket client", { + error: serializeError, + }); + ws.close(1011, "internal error during error handling"); + } + } else { + // We don't know the encoding so we send what we can + ws.close(1011, code); + } + }, + }))(c, noopNext()); + } +} + +/** + * Handle a connection message request to an actor + */ +async function handleMessageRequest( + c: HonoContext, + appConfig: AppConfig, + handler: ManagerRouterHandler, +): Promise { + logger().debug("connection message request received"); + try { + const params = ConnMessageRequestSchema.safeParse({ + actorId: c.req.header(HEADER_ACTOR_ID), + connId: c.req.header(HEADER_CONN_ID), + encoding: c.req.header(HEADER_ENCODING), + connToken: c.req.header(HEADER_CONN_TOKEN), + }); + if (!params.success) { + logger().error("invalid connection parameters", { + error: params.error, }); + throw new errors.InvalidRequest(params.error); + } + const { actorId, connId, encoding, connToken } = params.data; + + // Handle based on mode + if ("inline" in handler.proxyMode) { + logger().debug("using inline proxy mode for connection message"); + // Use shared connection message handler with direct parameters + return handleConnectionMessage( + c, + appConfig, + handler.proxyMode.inline.handlers.onConnMessage, + connId, + connToken as string, + actorId, + ); + } else if ("custom" in handler.proxyMode) { + logger().debug("using custom proxy mode for connection message"); + const url = new URL(`http://actor/connections/${connId}/message`); + + const proxyRequest = new Request(url, c.req.raw); + proxyRequest.headers.set(HEADER_ENCODING, encoding); + proxyRequest.headers.set(HEADER_CONN_ID, connId); + proxyRequest.headers.set(HEADER_CONN_TOKEN, connToken); + + return await handler.proxyMode.custom.onProxyRequest( + c, + proxyRequest, + actorId, + ); } else { - assertUnreachable(query); + assertUnreachable(handler.proxyMode); } + } catch (error) { + logger().error("error proxying connection message", { error }); - return c.json({ - endpoint: actorOutput.endpoint, - supportedTransports: ["websocket", "sse"], + // Use ProxyError if it's not already an ActorError + if (!errors.ActorError.isActorError(error)) { + throw new errors.ProxyError("connection message", error); + } else { + throw error; + } + } +} + +/** + * Handle an action request to an actor + */ +async function handleActionRequest( + c: HonoContext, + appConfig: AppConfig, + driverConfig: DriverConfig, + driver: ManagerDriver, + handler: ManagerRouterHandler, +): Promise { + try { + const actionName = c.req.param("action"); + logger().debug("action call received", { actionName }); + + const params = ConnectRequestSchema.safeParse({ + query: getRequestQuery(c, false), + encoding: c.req.header(HEADER_ENCODING), + params: c.req.header(HEADER_CONN_PARAMS), }); - }); - if (appConfig.inspector.enabled) { - app.route( - "/manager/inspect", - createManagerInspectorRouter( - handler.upgradeWebSocket, - handler.onConnectInspector, - appConfig.inspector, - ), - ); + if (!params.success) { + logger().error("invalid connection parameters", { + error: params.error, + }); + throw new errors.InvalidRequest(params.error); + } + + // Get the actor ID and meta + const { actorId, meta } = await queryActor(c, params.data.query, driver); + logger().debug("found actor for action", { actorId, meta }); + invariant(actorId, "Missing actor ID"); + + // Handle based on mode + if ("inline" in handler.proxyMode) { + logger().debug("using inline proxy mode for action call"); + // Use shared action handler with direct parameter + return handleAction( + c, + appConfig, + driverConfig, + handler.proxyMode.inline.handlers.onAction, + actionName, + actorId, + ); + } else if ("custom" in handler.proxyMode) { + logger().debug("using custom proxy mode for action call"); + const url = new URL( + `http://actor/action/${encodeURIComponent(actionName)}`, + ); + const proxyRequest = new Request(url, c.req.raw); + return await handler.proxyMode.custom.onProxyRequest( + c, + proxyRequest, + actorId, + meta, + ); + } else { + assertUnreachable(handler.proxyMode); + } + } catch (error) { + logger().error("error in action handler", { error }); + + // Use ProxyError if it's not already an ActorError + if (!errors.ActorError.isActorError(error)) { + throw new errors.ProxyError("Action call", error); + } else { + throw error; + } } +} - app.notFound(handleRouteNotFound); - app.onError(handleRouteError); +/** + * Handle the resolve request to get an actor ID from a query + */ +async function handleResolveRequest( + c: HonoContext, + driver: ManagerDriver, +): Promise { + const encoding = getRequestEncoding(c.req, false); + logger().debug("resolve request encoding", { encoding }); + + const params = ResolveRequestSchema.safeParse({ + query: getRequestQuery(c, false), + }); + if (!params.success) { + logger().error("invalid connection parameters", { + error: params.error, + }); + throw new errors.InvalidRequest(params.error); + } + + // Get the actor ID and meta + const { actorId, meta } = await queryActor(c, params.data.query, driver); + logger().debug("resolved actor", { actorId, meta }); + invariant(actorId, "Missing actor ID"); + + // Format response according to protocol + const response: protoHttpResolve.ResolveResponse = { + i: actorId, + }; + const serialized = serialize(response, encoding); + return c.body(serialized); +} - return app; -} \ No newline at end of file +/** Generates a `Next` handler to pass to middleware in order to be able to call arbitrary middleware. */ +function noopNext(): Next { + return async () => {}; +} diff --git a/packages/actor-core/src/test/driver/actor.ts b/packages/actor-core/src/test/driver/actor.ts index 4840e9f81..7088f7970 100644 --- a/packages/actor-core/src/test/driver/actor.ts +++ b/packages/actor-core/src/test/driver/actor.ts @@ -1,5 +1,5 @@ import type { ActorDriver, AnyActorInstance } from "@/driver-helpers/mod"; -import type { TestGlobalState } from "./global_state"; +import type { TestGlobalState } from "./global-state"; export interface ActorDriverContext { // Used to test that the actor context works from tests diff --git a/packages/actor-core/src/test/driver/global_state.ts b/packages/actor-core/src/test/driver/global-state.ts similarity index 100% rename from packages/actor-core/src/test/driver/global_state.ts rename to packages/actor-core/src/test/driver/global-state.ts diff --git a/packages/actor-core/src/test/driver/manager.ts b/packages/actor-core/src/test/driver/manager.ts index dcbe115ca..fe9f599c9 100644 --- a/packages/actor-core/src/test/driver/manager.ts +++ b/packages/actor-core/src/test/driver/manager.ts @@ -6,7 +6,9 @@ import type { GetWithKeyInput, ManagerDriver, } from "@/driver-helpers/mod"; -import type { TestGlobalState } from "./global_state"; +import { ActorAlreadyExists } from "@/actor/errors"; +import type { TestGlobalState } from "./global-state"; +import * as crypto from "node:crypto"; import { ManagerInspector } from "@/inspector/manager"; import type { ActorCoreApp } from "@/app/mod"; @@ -29,7 +31,6 @@ export class TestManagerDriver implements ManagerDriver { } async getForId({ - baseUrl, actorId, }: GetForIdInput): Promise { // Validate the actor exists @@ -39,41 +40,73 @@ export class TestManagerDriver implements ManagerDriver { } return { - endpoint: buildActorEndpoint(baseUrl, actorId), + actorId, name: actor.name, key: actor.key, }; } async getWithKey({ - baseUrl, name, key, }: GetWithKeyInput): Promise { // NOTE: This is a slow implementation that checks each actor individually. // This can be optimized with an index in the future. - // Search through all actors to find a match with the same key const actor = this.#state.findActor((actor) => { - if (actor.name !== name) return false; - - // Compare key arrays - if (!actor.key || actor.key.length !== key.length) { + if (actor.name !== name) { return false; } - // Check if all elements in key are in actor.key - for (let i = 0; i < key.length; i++) { - if (key[i] !== actor.key[i]) { + // handle empty key + if (key === null || key === undefined) { + return actor.key === null || actor.key === undefined; + } + + // handle array + if (Array.isArray(key)) { + if (!Array.isArray(actor.key)) { + return false; + } + if (key.length !== actor.key.length) { + return false; + } + // Check if all elements in key are in actor.key + for (let i = 0; i < key.length; i++) { + if (key[i] !== actor.key[i]) { + return false; + } + } + return true; + } + + // Handle object + if (typeof key === "object" && !Array.isArray(key)) { + if (typeof actor.key !== "object" || Array.isArray(actor.key)) { + return false; + } + if (actor.key === null) { return false; } + + // Check if all keys in key are in actor.key + const keyObj = key as Record; + const actorKeyObj = actor.key as unknown as Record; + for (const k in keyObj) { + if (!(k in actorKeyObj) || keyObj[k] !== actorKeyObj[k]) { + return false; + } + } + return true; } - return true; + + // handle scalar + return key === actor.key; }); if (actor) { return { - endpoint: buildActorEndpoint(baseUrl, actor.id), + actorId: actor.id, name, key: actor.key, }; @@ -83,21 +116,22 @@ export class TestManagerDriver implements ManagerDriver { } async createActor({ - baseUrl, name, key, }: CreateActorInput): Promise { + // Check if actor with the same name and key already exists + const existingActor = await this.getWithKey({ name, key }); + if (existingActor) { + throw new ActorAlreadyExists(name, key); + } + const actorId = crypto.randomUUID(); this.#state.createActor(actorId, name, key); this.inspector.onActorsChange(this.#state.getAllActors()); return { - endpoint: buildActorEndpoint(baseUrl, actorId), + actorId, }; } } - -function buildActorEndpoint(baseUrl: string, actorId: string) { - return `${baseUrl}/actors/${actorId}`; -} \ No newline at end of file diff --git a/packages/actor-core/src/test/driver/mod.ts b/packages/actor-core/src/test/driver/mod.ts index 6939019d8..51438dd00 100644 --- a/packages/actor-core/src/test/driver/mod.ts +++ b/packages/actor-core/src/test/driver/mod.ts @@ -1,3 +1,3 @@ -export { TestGlobalState } from "./global_state"; +export { TestGlobalState } from "./global-state"; export { TestActorDriver } from "./actor"; export { TestManagerDriver } from "./manager"; diff --git a/packages/actor-core/src/topologies/common/generic-conn-driver.ts b/packages/actor-core/src/topologies/common/generic-conn-driver.ts index 8d9eb46b0..3b38c0b71 100644 --- a/packages/actor-core/src/topologies/common/generic-conn-driver.ts +++ b/packages/actor-core/src/topologies/common/generic-conn-driver.ts @@ -137,10 +137,6 @@ export type GenericHttpDriverState = Record; export function createGeneircHttpDriver() { return { - sendMessage: () => { - logger().warn("attempting to send message to http connection"); - }, - disconnect: async () => { // Noop }, diff --git a/packages/actor-core/src/topologies/coordinate/topology.ts b/packages/actor-core/src/topologies/coordinate/topology.ts index 5dab1fc81..09b2b6a9b 100644 --- a/packages/actor-core/src/topologies/coordinate/topology.ts +++ b/packages/actor-core/src/topologies/coordinate/topology.ts @@ -12,6 +12,16 @@ import { handleRouteError, handleRouteNotFound } from "@/common/router"; import type { DriverConfig } from "@/driver-helpers/config"; import type { AppConfig } from "@/app/config"; import { createManagerRouter } from "@/manager/router"; +import type { + ConnectWebSocketOpts, + ConnectSseOpts, + ActionOpts, + ConnsMessageOpts, + ConnectWebSocketOutput, + ConnectSseOutput, + ActionOutput, + ConnectionHandlers, +} from "@/actor/router-endpoints"; export interface GlobalState { nodeId: string; @@ -53,77 +63,71 @@ export class CoordinateTopology { const upgradeWebSocket = driverConfig.getUpgradeWebSocket?.(app); - // Build manager router - const managerRouter = createManagerRouter(appConfig, driverConfig, { - upgradeWebSocket, - onConnectInspector: () => { - throw new errors.Unsupported("inspect"); - }, - }); - - // Forward requests to actor - const actorRouter = createActorRouter(appConfig, driverConfig, { - upgradeWebSocket, - onConnectWebSocket: async (opts) => { - const actorId = opts.req.param("actorId"); - if (!actorId) throw new errors.InternalError("Missing actor ID"); + // Share connection handlers for both routers + const connectionHandlers: ConnectionHandlers = { + onConnectWebSocket: async ( + opts: ConnectWebSocketOpts, + ): Promise => { return await serveWebSocket( appConfig, driverConfig, actorDriver, CoordinateDriver, globalState, - actorId, + opts.actorId, opts, ); }, - onConnectSse: async (opts) => { - const actorId = opts.req.param("actorId"); - if (!actorId) throw new errors.InternalError("Missing actor ID"); + onConnectSse: async (opts: ConnectSseOpts): Promise => { return await serveSse( appConfig, driverConfig, actorDriver, CoordinateDriver, globalState, - actorId, + opts.actorId, opts, ); }, - onRpc: async () => { + onAction: async (opts: ActionOpts): Promise => { // TODO: throw new errors.InternalError("UNIMPLEMENTED"); }, - onConnMessage: async ({ req, connId, connToken, message }) => { - const actorId = req.param("actorId"); - if (!actorId) throw new errors.InternalError("Missing actor ID"); - + onConnMessage: async (opts: ConnsMessageOpts): Promise => { await publishMessageToLeader( appConfig, driverConfig, CoordinateDriver, globalState, - actorId, + opts.actorId, { b: { lm: { - ai: actorId, - ci: connId, - ct: connToken, - m: message, + ai: opts.actorId, + ci: opts.connId, + ct: opts.connToken, + m: opts.message, }, }, }, - req.raw.signal, + opts.req.raw.signal, ); }, - onConnectInspector: async () => { + }; + + // Build manager router + const managerRouter = createManagerRouter(appConfig, driverConfig, { + proxyMode: { + inline: { + handlers: connectionHandlers, + }, + }, + onConnectInspector: () => { throw new errors.Unsupported("inspect"); }, }); app.route("/", managerRouter); - app.route("/actors/:actorId", actorRouter); app.notFound(handleRouteNotFound); app.onError(handleRouteError); diff --git a/packages/actor-core/src/topologies/partition/toplogy.ts b/packages/actor-core/src/topologies/partition/toplogy.ts index 39ab5e9d7..78cca6622 100644 --- a/packages/actor-core/src/topologies/partition/toplogy.ts +++ b/packages/actor-core/src/topologies/partition/toplogy.ts @@ -24,43 +24,62 @@ import type { ActorKey } from "@/common/utils"; import type { DriverConfig } from "@/driver-helpers/config"; import type { AppConfig } from "@/app/config"; import type { ActorInspectorConnection } from "@/inspector/actor"; -import { createManagerRouter } from "@/manager/router"; +import { + createManagerRouter, + OnProxyWebSocket, + type OnProxyRequest, +} from "@/manager/router"; import type { ManagerInspectorConnection } from "@/inspector/manager"; +import type { + ConnectWebSocketOpts, + ConnectSseOpts, + ActionOpts, + ConnsMessageOpts, + ConnectWebSocketOutput, + ConnectSseOutput, + ActionOutput, +} from "@/actor/router-endpoints"; export class PartitionTopologyManager { - router = new Hono(); + router: Hono; - constructor(appConfig: AppConfig, driverConfig: DriverConfig) { - this.router.route( - "/", - createManagerRouter(appConfig, driverConfig, { - upgradeWebSocket: driverConfig.getUpgradeWebSocket?.(this.router), - onConnectInspector: async () => { - const inspector = driverConfig.drivers?.manager?.inspector; - if (!inspector) throw new errors.Unsupported("inspector"); - - let conn: ManagerInspectorConnection | undefined; - return { - onOpen: async (ws) => { - conn = inspector.createConnection(ws); - }, - onMessage: async (message) => { - if (!conn) { - logger().warn("`conn` does not exist"); - return; - } + constructor( + appConfig: AppConfig, + driverConfig: DriverConfig, + proxyCustomConfig: { + onProxyRequest: OnProxyRequest; + onProxyWebSocket: OnProxyWebSocket; + }, + ) { + this.router = createManagerRouter(appConfig, driverConfig, { + proxyMode: { + custom: proxyCustomConfig, + }, + onConnectInspector: async () => { + const inspector = driverConfig.drivers?.manager?.inspector; + if (!inspector) throw new errors.Unsupported("inspector"); + + let conn: ManagerInspectorConnection | undefined; + return { + onOpen: async (ws) => { + conn = inspector.createConnection(ws); + }, + onMessage: async (message) => { + if (!conn) { + logger().warn("`conn` does not exist"); + return; + } - inspector.processMessage(conn, message); - }, - onClose: async () => { - if (conn) { - inspector.removeConnection(conn); - } - }, - }; - }, - }), - ); + inspector.processMessage(conn, message); + }, + onClose: async () => { + if (conn) { + inspector.removeConnection(conn); + } + }, + }; + }, + }); } } @@ -90,15 +109,16 @@ export class PartitionTopologyActor { const genericConnGlobalState = new GenericConnGlobalState(); this.#connDrivers = createGenericConnDrivers(genericConnGlobalState); - // Build actor router - const actorRouter = new Hono(); - - // This route rhas to be mounted at the root since the root router must be passed to `upgradeWebSocket` - actorRouter.route( - "/", - createActorRouter(appConfig, driverConfig, { - upgradeWebSocket: driverConfig.getUpgradeWebSocket?.(actorRouter), - onConnectWebSocket: async ({ req, encoding, params: connParams }) => { + // TODO: Store this actor router globally so we're not re-initializing it for every DO + this.router = createActorRouter(appConfig, driverConfig, { + getActorId: async () => { + if (this.#actorStartedPromise) await this.#actorStartedPromise.promise; + return this.actor.id; + }, + connectionHandlers: { + onConnectWebSocket: async ( + opts: ConnectWebSocketOpts, + ): Promise => { if (this.#actorStartedPromise) await this.#actorStartedPromise.promise; @@ -107,7 +127,7 @@ export class PartitionTopologyActor { const connId = generateConnId(); const connToken = generateConnToken(); - const connState = await actor.prepareConn(connParams, req.raw); + const connState = await actor.prepareConn(opts.params, opts.req.raw); let conn: AnyConn | undefined; return { @@ -119,11 +139,12 @@ export class PartitionTopologyActor { conn = await actor.createConn( connId, connToken, - - connParams, + opts.params, connState, CONN_DRIVER_GENERIC_WEBSOCKET, - { encoding } satisfies GenericWebSocketDriverState, + { + encoding: opts.encoding, + } satisfies GenericWebSocketDriverState, ); }, onMessage: async (message) => { @@ -145,7 +166,9 @@ export class PartitionTopologyActor { }, }; }, - onConnectSse: async ({ req, encoding, params: connParams }) => { + onConnectSse: async ( + opts: ConnectSseOpts, + ): Promise => { if (this.#actorStartedPromise) await this.#actorStartedPromise.promise; @@ -154,7 +177,7 @@ export class PartitionTopologyActor { const connId = generateConnId(); const connToken = generateConnToken(); - const connState = await actor.prepareConn(connParams, req.raw); + const connState = await actor.prepareConn(opts.params, opts.req.raw); let conn: AnyConn | undefined; return { @@ -166,10 +189,10 @@ export class PartitionTopologyActor { conn = await actor.createConn( connId, connToken, - connParams, + opts.params, connState, CONN_DRIVER_GENERIC_SSE, - { encoding } satisfies GenericSseDriverState, + { encoding: opts.encoding } satisfies GenericSseDriverState, ); }, onClose: async () => { @@ -181,7 +204,7 @@ export class PartitionTopologyActor { }, }; }, - onRpc: async ({ req, params: connParams, rpcName, rpcArgs }) => { + onAction: async (opts: ActionOpts): Promise => { let conn: AnyConn | undefined; try { // Wait for init to finish @@ -192,19 +215,26 @@ export class PartitionTopologyActor { if (!actor) throw new Error("Actor should be defined"); // Create conn - const connState = await actor.prepareConn(connParams, req.raw); + const connState = await actor.prepareConn( + opts.params, + opts.req.raw, + ); conn = await actor.createConn( generateConnId(), generateConnToken(), - connParams, + opts.params, connState, CONN_DRIVER_GENERIC_HTTP, {} satisfies GenericHttpDriverState, ); - // Call RPC + // Call action const ctx = new ActionContext(actor.actorContext!, conn!); - const output = await actor.executeRpc(ctx, rpcName, rpcArgs); + const output = await actor.executeAction( + ctx, + opts.actionName, + opts.actionArgs, + ); return { output }; } finally { @@ -213,7 +243,7 @@ export class PartitionTopologyActor { } } }, - onConnMessage: async ({ connId, connToken, message }) => { + onConnMessage: async (opts: ConnsMessageOpts): Promise => { // Wait for init to finish if (this.#actorStartedPromise) await this.#actorStartedPromise.promise; @@ -222,50 +252,47 @@ export class PartitionTopologyActor { if (!actor) throw new Error("Actor should be defined"); // Find connection - const conn = actor.conns.get(connId); + const conn = actor.conns.get(opts.connId); if (!conn) { - throw new errors.ConnNotFound(connId); + throw new errors.ConnNotFound(opts.connId); } // Authenticate connection - if (conn._token !== connToken) { + if (conn._token !== opts.connToken) { throw new errors.IncorrectConnToken(); } // Process message - await actor.processMessage(message, conn); + await actor.processMessage(opts.message, conn); }, - onConnectInspector: async () => { - if (this.#actorStartedPromise) - await this.#actorStartedPromise.promise; - - const actor = this.#actor; - if (!actor) throw new Error("Actor should be defined"); - - let conn: ActorInspectorConnection | undefined; - return { - onOpen: async (ws) => { - conn = actor.inspector.createConnection(ws); - }, - onMessage: async (message) => { - if (!conn) { - logger().warn("`conn` does not exist"); - return; - } - - actor.inspector.processMessage(conn, message); - }, - onClose: async () => { - if (conn) { - actor.inspector.removeConnection(conn); - } - }, - }; - }, - }), - ); + }, + onConnectInspector: async () => { + if (this.#actorStartedPromise) await this.#actorStartedPromise.promise; + + const actor = this.#actor; + if (!actor) throw new Error("Actor should be defined"); + + let conn: ActorInspectorConnection | undefined; + return { + onOpen: async (ws) => { + conn = actor.inspector.createConnection(ws); + }, + onMessage: async (message) => { + if (!conn) { + logger().warn("`conn` does not exist"); + return; + } - this.router = actorRouter; + actor.inspector.processMessage(conn, message); + }, + onClose: async () => { + if (conn) { + actor.inspector.removeConnection(conn); + } + }, + }; + }, + }); } async start(id: string, name: string, key: ActorKey, region: string) { @@ -294,4 +321,4 @@ export class PartitionTopologyActor { this.#actorStartedPromise?.resolve(); this.#actorStartedPromise = undefined; } -} \ No newline at end of file +} diff --git a/packages/actor-core/src/topologies/standalone/topology.ts b/packages/actor-core/src/topologies/standalone/topology.ts index dfd835f0d..dc6e824df 100644 --- a/packages/actor-core/src/topologies/standalone/topology.ts +++ b/packages/actor-core/src/topologies/standalone/topology.ts @@ -5,7 +5,6 @@ import { generateConnId, generateConnToken, } from "@/actor/connection"; -import { createActorRouter } from "@/actor/router"; import { logger } from "./log"; import * as errors from "@/actor/errors"; import { @@ -22,8 +21,17 @@ import { ActionContext } from "@/actor/action"; import type { DriverConfig } from "@/driver-helpers/config"; import type { AppConfig } from "@/app/config"; import { createManagerRouter } from "@/manager/router"; -import type { ActorInspectorConnection } from "@/inspector/actor"; import type { ManagerInspectorConnection } from "@/inspector/manager"; +import type { + ConnectWebSocketOpts, + ConnectWebSocketOutput, + ConnectSseOpts, + ConnectSseOutput, + ConnsMessageOpts, + ActionOpts, + ActionOutput, + ConnectionHandlers, +} from "@/actor/router-endpoints"; class ActorHandler { /** Will be undefined if not yet loaded. */ @@ -75,8 +83,6 @@ export class StandaloneTopology { // Load actor meta const actorMetadata = await this.#driverConfig.drivers.manager.getForId({ - // HACK: The endpoint doesn't matter here, so we're passing a bogon IP - baseUrl: "http://192.0.2.0", actorId, }); if (!actorMetadata) throw new Error(`No actor found for ID ${actorId}`); @@ -124,47 +130,16 @@ export class StandaloneTopology { const upgradeWebSocket = driverConfig.getUpgradeWebSocket?.(app); - // Build manager router - const managerRouter = createManagerRouter(appConfig, driverConfig, { - upgradeWebSocket, - onConnectInspector: async () => { - const inspector = driverConfig.drivers?.manager?.inspector; - if (!inspector) throw new errors.Unsupported("inspector"); - - let conn: ManagerInspectorConnection | undefined; - return { - onOpen: async (ws) => { - conn = inspector.createConnection(ws); - }, - onMessage: async (message) => { - if (!conn) { - logger().warn("`conn` does not exist"); - return; - } - - inspector.processMessage(conn, message); - }, - onClose: async () => { - if (conn) { - inspector.removeConnection(conn); - } - }, - }; - }, - }); - - // Build actor router - const actorRouter = createActorRouter(appConfig, driverConfig, { - upgradeWebSocket, - onConnectWebSocket: async ({ req, encoding, params: connParams }) => { - const actorId = req.param("actorId"); - if (!actorId) throw new errors.InternalError("Missing actor ID"); - - const { handler, actor } = await this.#getActor(actorId); + // Create shared connection handlers that will be used by both manager and actor routers + const sharedConnectionHandlers: ConnectionHandlers = { + onConnectWebSocket: async ( + opts: ConnectWebSocketOpts, + ): Promise => { + const { handler, actor } = await this.#getActor(opts.actorId); const connId = generateConnId(); const connToken = generateConnToken(); - const connState = await actor.prepareConn(connParams, req.raw); + const connState = await actor.prepareConn(opts.params, opts.req.raw); let conn: AnyConn | undefined; return { @@ -176,11 +151,10 @@ export class StandaloneTopology { conn = await actor.createConn( connId, connToken, - - connParams, + opts.params, connState, CONN_DRIVER_GENERIC_WEBSOCKET, - { encoding } satisfies GenericWebSocketDriverState, + { encoding: opts.encoding } satisfies GenericWebSocketDriverState, ); }, onMessage: async (message) => { @@ -202,15 +176,12 @@ export class StandaloneTopology { }, }; }, - onConnectSse: async ({ req, encoding, params: connParams }) => { - const actorId = req.param("actorId"); - if (!actorId) throw new errors.InternalError("Missing actor ID"); - - const { handler, actor } = await this.#getActor(actorId); + onConnectSse: async (opts: ConnectSseOpts): Promise => { + const { handler, actor } = await this.#getActor(opts.actorId); const connId = generateConnId(); const connToken = generateConnToken(); - const connState = await actor.prepareConn(connParams, req.raw); + const connState = await actor.prepareConn(opts.params, opts.req.raw); let conn: AnyConn | undefined; return { @@ -222,10 +193,10 @@ export class StandaloneTopology { conn = await actor.createConn( connId, connToken, - connParams, + opts.params, connState, CONN_DRIVER_GENERIC_SSE, - { encoding } satisfies GenericSseDriverState, + { encoding: opts.encoding } satisfies GenericSseDriverState, ); }, onClose: async () => { @@ -237,67 +208,72 @@ export class StandaloneTopology { }, }; }, - onRpc: async ({ req, params: connParams, rpcName, rpcArgs }) => { - const actorId = req.param("actorId"); - if (!actorId) throw new errors.InternalError("Missing actor ID"); - + onAction: async (opts: ActionOpts): Promise => { let conn: AnyConn | undefined; try { - const { actor } = await this.#getActor(actorId); + const { actor } = await this.#getActor(opts.actorId); // Create conn - const connState = await actor.prepareConn(connParams, req.raw); + const connState = await actor.prepareConn(opts.params, opts.req.raw); conn = await actor.createConn( generateConnId(), generateConnToken(), - connParams, + opts.params, connState, CONN_DRIVER_GENERIC_HTTP, {} satisfies GenericHttpDriverState, ); - // Call RPC + // Call action const ctx = new ActionContext(actor.actorContext!, conn); - const output = await actor.executeRpc(ctx, rpcName, rpcArgs); + const output = await actor.executeAction( + ctx, + opts.actionName, + opts.actionArgs, + ); return { output }; } finally { if (conn) { - const { actor } = await this.#getActor(actorId); + const { actor } = await this.#getActor(opts.actorId); actor.__removeConn(conn); } } }, - onConnMessage: async ({ req, connId, connToken, message }) => { - const actorId = req.param("actorId"); - if (!actorId) throw new errors.InternalError("Missing actor ID"); - - const { actor } = await this.#getActor(actorId); + onConnMessage: async (opts: ConnsMessageOpts): Promise => { + const { actor } = await this.#getActor(opts.actorId); // Find connection - const conn = actor.conns.get(connId); + const conn = actor.conns.get(opts.connId); if (!conn) { - throw new errors.ConnNotFound(connId); + throw new errors.ConnNotFound(opts.connId); } // Authenticate connection - if (conn._token !== connToken) { + if (conn._token !== opts.connToken) { throw new errors.IncorrectConnToken(); } // Process message - await actor.processMessage(message, conn); + await actor.processMessage(opts.message, conn); }, - onConnectInspector: async ({ req }) => { - const actorId = req.param("actorId"); - if (!actorId) throw new errors.InternalError("Missing actor ID"); + }; - const { actor } = await this.#getActor(actorId); + // Build manager router + const managerRouter = createManagerRouter(appConfig, driverConfig, { + proxyMode: { + inline: { + handlers: sharedConnectionHandlers, + }, + }, + onConnectInspector: async () => { + const inspector = driverConfig.drivers?.manager?.inspector; + if (!inspector) throw new errors.Unsupported("inspector"); - let conn: ActorInspectorConnection | undefined; + let conn: ManagerInspectorConnection | undefined; return { onOpen: async (ws) => { - conn = actor.inspector.createConnection(ws); + conn = inspector.createConnection(ws); }, onMessage: async (message) => { if (!conn) { @@ -305,11 +281,11 @@ export class StandaloneTopology { return; } - actor.inspector.processMessage(conn, message); + inspector.processMessage(conn, message); }, onClose: async () => { if (conn) { - actor.inspector.removeConnection(conn); + inspector.removeConnection(conn); } }, }; @@ -317,9 +293,7 @@ export class StandaloneTopology { }); app.route("/", managerRouter); - // Mount the actor router - app.route("/actors/:actorId", actorRouter); this.router = app; } -} \ No newline at end of file +} diff --git a/packages/actor-core/src/utils.ts b/packages/actor-core/src/utils.ts index 9bd5bb45d..16dee2629 100644 --- a/packages/actor-core/src/utils.ts +++ b/packages/actor-core/src/utils.ts @@ -1 +1,24 @@ export { assertUnreachable } from "./common/utils"; +import pkgJson from "../package.json" with { type: "json" }; + +export const VERSION = pkgJson.version; + +let _userAgent: string | undefined = undefined; + +export function httpUserAgent(): string { + // Return cached value if already initialized + if (_userAgent !== undefined) { + return _userAgent; + } + + // Library + let userAgent = `ActorCore/${VERSION}`; + + // Navigator + const navigatorObj = typeof navigator !== "undefined" ? navigator : undefined; + if (navigatorObj?.userAgent) userAgent += ` ${navigatorObj.userAgent}`; + + _userAgent = userAgent; + + return userAgent; +} diff --git a/packages/actor-core/tests/action-timeout.test.ts b/packages/actor-core/tests/action-timeout.test.ts deleted file mode 100644 index ac88d3647..000000000 --- a/packages/actor-core/tests/action-timeout.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { actor, setup } from "@/mod"; -import { describe, test, expect, vi } from "vitest"; -import { setupTest } from "@/test/mod"; - -describe("Action Timeout", () => { - test("should timeout actions that exceed the configured timeout", async (c) => { - // Create an actor with a custom timeout of 100ms - const timeoutActor = actor({ - state: { value: 0 }, - options: { - action: { - timeout: 100, // 100ms timeout - }, - }, - actions: { - // Quick action that should succeed - quickAction: async (c) => { - return "quick response"; - }, - // Slow action that should timeout - slowAction: async (c) => { - // Start a promise that resolves after 500ms - const delayPromise = new Promise((resolve) => - setTimeout(resolve, 500), - ); - - // Advance only to the timeout threshold to trigger the timeout - await vi.advanceTimersByTimeAsync(150); - - // The action should have timed out by now, but we'll try to return a value - // This return should never happen because the timeout should occur first - await delayPromise; - return "slow response"; - }, - }, - }); - - const app = setup({ - actors: { timeoutActor }, - }); - - const { client } = await setupTest(c, app); - const instance = await client.timeoutActor.connect(); - - // The quick action should complete successfully - const quickResult = await instance.quickAction(); - expect(quickResult).toBe("quick response"); - - // The slow action should throw a timeout error - await expect(instance.slowAction()).rejects.toThrow("Action timed out."); - }); - - test("should respect the default timeout", async (c) => { - // Create an actor with the default timeout (60000ms) - const defaultTimeoutActor = actor({ - state: { value: 0 }, - actions: { - // This should complete within the default timeout - normalAction: async (c) => { - const delayPromise = new Promise((resolve) => - setTimeout(resolve, 50), - ); - await vi.advanceTimersByTimeAsync(50); - await delayPromise; - return "normal response"; - }, - }, - }); - - const app = setup({ - actors: { defaultTimeoutActor }, - }); - - const { client } = await setupTest(c, app); - const instance = await client.defaultTimeoutActor.connect(); - - // This action should complete successfully - const result = await instance.normalAction(); - expect(result).toBe("normal response"); - }); - - test("non-promise action results should not be affected by timeout", async (c) => { - // Create an actor that returns non-promise values - const syncActor = actor({ - state: { value: 0 }, - options: { - action: { - timeout: 100, // 100ms timeout - }, - }, - actions: { - // Synchronous action that returns immediately - syncAction: (c) => { - return "sync response"; - }, - }, - }); - - const app = setup({ - actors: { syncActor }, - }); - - const { client } = await setupTest(c, app); - const instance = await client.syncActor.connect(); - - // Synchronous action should not be affected by timeout - const result = await instance.syncAction(); - expect(result).toBe("sync response"); - }); - - test("should allow configuring different timeouts for different actors", async (c) => { - // Create an actor with a very short timeout - const shortTimeoutActor = actor({ - state: { value: 0 }, - options: { - action: { - timeout: 50, // 50ms timeout - }, - }, - actions: { - delayedAction: async (c) => { - // Start a promise that resolves after 100ms - const delayPromise = new Promise((resolve) => - setTimeout(resolve, 100), - ); - - // Advance past the timeout threshold - await vi.advanceTimersByTimeAsync(70); - - // The action should have timed out by now - await delayPromise; - return "delayed response"; - }, - }, - }); - - // Create an actor with a longer timeout - const longerTimeoutActor = actor({ - state: { value: 0 }, - options: { - action: { - timeout: 200, // 200ms timeout - }, - }, - actions: { - delayedAction: async (c) => { - // Start a promise that resolves after 100ms - const delayPromise = new Promise((resolve) => - setTimeout(resolve, 100), - ); - - // Advance less than the timeout threshold - await vi.advanceTimersByTimeAsync(100); - - // This should complete before the timeout - await delayPromise; - return "delayed response"; - }, - }, - }); - - const app = setup({ - actors: { - shortTimeoutActor, - longerTimeoutActor, - }, - }); - - const { client } = await setupTest(c, app); - - // The short timeout actor should fail - const shortInstance = await client.shortTimeoutActor.connect(); - await expect(shortInstance.delayedAction()).rejects.toThrow( - "Action timed out.", - ); - - // The longer timeout actor should succeed - const longerInstance = await client.longerTimeoutActor.connect(); - const result = await longerInstance.delayedAction(); - expect(result).toBe("delayed response"); - }); -}); diff --git a/packages/actor-core/tests/action-types.test.ts b/packages/actor-core/tests/action-types.test.ts deleted file mode 100644 index 9f68625eb..000000000 --- a/packages/actor-core/tests/action-types.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { actor, setup, UserError } from "@/mod"; -import { describe, test, expect, vi } from "vitest"; -import { setupTest } from "@/test/mod"; - -describe("Action Types", () => { - test("should support synchronous actions", async (c) => { - const syncActor = actor({ - state: { value: 0 }, - actions: { - // Simple synchronous action that returns a value directly - increment: (c, amount: number = 1) => { - c.state.value += amount; - return c.state.value; - }, - // Synchronous action that returns an object - getInfo: (c) => { - return { - currentValue: c.state.value, - timestamp: Date.now(), - }; - }, - // Synchronous action with no return value (void) - reset: (c) => { - c.state.value = 0; - }, - }, - }); - - const app = setup({ - actors: { syncActor }, - }); - - const { client } = await setupTest(c, app); - const instance = await client.syncActor.connect(); - - // Test increment action - let result = await instance.increment(5); - expect(result).toBe(5); - - result = await instance.increment(3); - expect(result).toBe(8); - - // Test getInfo action - const info = await instance.getInfo(); - expect(info.currentValue).toBe(8); - expect(typeof info.timestamp).toBe("number"); - - // Test reset action (void return) - await instance.reset(); - result = await instance.increment(0); - expect(result).toBe(0); - }); - - test("should support asynchronous actions", async (c) => { - const asyncActor = actor({ - state: { value: 0, data: null as any }, - actions: { - // Async action with a delay - delayedIncrement: async (c, amount: number = 1) => { - const delayPromise = new Promise((resolve) => - setTimeout(resolve, 50), - ); - await vi.advanceTimersByTimeAsync(50); - await delayPromise; - c.state.value += amount; - return c.state.value; - }, - // Async action that simulates an API call - fetchData: async (c, id: string) => { - // Simulate fetch delay - const delayPromise = new Promise((resolve) => - setTimeout(resolve, 50), - ); - await vi.advanceTimersByTimeAsync(50); - await delayPromise; - - // Simulate response data - const data = { id, timestamp: Date.now() }; - c.state.data = data; - return data; - }, - // Async action with error handling - asyncWithError: async (c, shouldError: boolean) => { - const delayPromise = new Promise((resolve) => - setTimeout(resolve, 50), - ); - await vi.advanceTimersByTimeAsync(50); - await delayPromise; - - if (shouldError) { - throw new UserError("Intentional error"); - } - - return "Success"; - }, - }, - }); - - const app = setup({ - actors: { asyncActor }, - }); - - const { client } = await setupTest(c, app); - const instance = await client.asyncActor.connect(); - - // Test delayed increment - const result = await instance.delayedIncrement(5); - expect(result).toBe(5); - - // Test fetch data - const data = await instance.fetchData("test-123"); - expect(data.id).toBe("test-123"); - expect(typeof data.timestamp).toBe("number"); - - // Test successful async operation - const success = await instance.asyncWithError(false); - expect(success).toBe("Success"); - - // Test error in async operation - const errorPromise = instance.asyncWithError(true); - await expect(errorPromise).rejects.toThrow("Intentional error"); - }); - - test("should handle promises returned from actions correctly", async (c) => { - const promiseActor = actor({ - state: { results: [] as string[] }, - actions: { - // Action that returns a resolved promise - resolvedPromise: (c) => { - return Promise.resolve("resolved value"); - }, - // Action that returns a promise that resolves after a delay - delayedPromise: (c): Promise => { - const delayPromise = new Promise((resolve) => { - setTimeout(() => { - c.state.results.push("delayed"); - resolve("delayed value"); - }, 50); - }); - return vi.advanceTimersByTimeAsync(50).then(() => delayPromise); - }, - // Action that returns a rejected promise - rejectedPromise: (c) => { - return Promise.reject(new UserError("promised rejection")); - }, - // Action to check the collected results - getResults: (c) => { - return c.state.results; - }, - }, - }); - - const app = setup({ - actors: { promiseActor }, - }); - - const { client } = await setupTest(c, app); - const instance = await client.promiseActor.connect(); - - // Test resolved promise - const resolvedValue = await instance.resolvedPromise(); - expect(resolvedValue).toBe("resolved value"); - - // Test delayed promise - const delayedValue = await instance.delayedPromise(); - expect(delayedValue).toBe("delayed value"); - - // Test rejected promise - await expect(instance.rejectedPromise()).rejects.toThrow( - "promised rejection", - ); - - // Check state was updated by the delayed promise - const results = await instance.getResults(); - expect(results).toContain("delayed"); - }); -}); diff --git a/packages/actor-core/tests/definition.test.ts b/packages/actor-core/tests/actor-types.test.ts similarity index 100% rename from packages/actor-core/tests/definition.test.ts rename to packages/actor-core/tests/actor-types.test.ts diff --git a/packages/actor-core/tests/basic.test.ts b/packages/actor-core/tests/basic.test.ts deleted file mode 100644 index cae0fdbdf..000000000 --- a/packages/actor-core/tests/basic.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { actor, setup } from "@/mod"; -import { test } from "vitest"; -import { setupTest } from "@/test/mod"; - -test("basic actor setup", async (c) => { - const counter = actor({ - state: { count: 0 }, - actions: { - increment: (c, x: number) => { - c.state.count += x; - c.broadcast("newCount", c.state.count); - return c.state.count; - }, - }, - }); - - const app = setup({ - actors: { counter }, - }); - - const { client } = await setupTest(c, app); - - const counterInstance = await client.counter.connect(); - await counterInstance.increment(1); -}); diff --git a/packages/actor-core/tests/driver-test-suite.test.ts b/packages/actor-core/tests/driver-test-suite.test.ts new file mode 100644 index 000000000..0eb22fd4a --- /dev/null +++ b/packages/actor-core/tests/driver-test-suite.test.ts @@ -0,0 +1,19 @@ +import { + runDriverTests, + createTestRuntime, +} from "@/driver-test-suite/mod"; +import { TestGlobalState } from "@/test/driver/global-state"; +import { TestActorDriver } from "@/test/driver/actor"; +import { TestManagerDriver } from "@/test/driver/manager"; + +runDriverTests({ + async start(appPath: string) { + return await createTestRuntime(appPath, async (app) => { + const memoryState = new TestGlobalState(); + return { + actorDriver: new TestActorDriver(memoryState), + managerDriver: new TestManagerDriver(app, memoryState), + }; + }); + }, +}); diff --git a/packages/actor-core/tests/vars.test.ts b/packages/actor-core/tests/vars.test.ts deleted file mode 100644 index 6bfe89ea0..000000000 --- a/packages/actor-core/tests/vars.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { actor, setup } from "@/mod"; -import { describe, test, expect, vi } from "vitest"; -import { setupTest } from "@/test/mod"; - -describe("Actor Vars", () => { - describe("Static vars", () => { - test("should provide access to static vars", async (c) => { - // Define actor with static vars - const varActor = actor({ - state: { value: 0 }, - connState: { hello: "world" }, - vars: { counter: 42, name: "test-actor" }, - actions: { - getVars: (c) => { - return c.vars; - }, - getName: (c) => { - return c.vars.name; - }, - }, - }); - - const app = setup({ - actors: { varActor }, - }); - - const { client } = await setupTest(c, app); - const instance = await client.varActor.connect(); - - // Test accessing vars - const result = await instance.getVars(); - expect(result).toEqual({ counter: 42, name: "test-actor" }); - - // Test accessing specific var property - const name = await instance.getName(); - expect(name).toBe("test-actor"); - }); - }); - - describe("Deep cloning of static vars", () => { - test("should deep clone static vars between actor instances", async (c) => { - // Define actor with nested object in vars - const nestedVarActor = actor({ - state: { value: 0 }, - connState: { hello: "world" }, - vars: { - counter: 42, - nested: { - value: "original", - array: [1, 2, 3], - obj: { key: "value" }, - }, - }, - actions: { - getVars: (c) => { - return c.vars; - }, - modifyNested: (c) => { - // Attempt to modify the nested object - c.vars.nested.value = "modified"; - c.vars.nested.array.push(4); - c.vars.nested.obj.key = "new-value"; - return c.vars; - }, - }, - }); - - const app = setup({ - actors: { nestedVarActor }, - }); - - const { client } = await setupTest(c, app); - - // Create two separate instances - const instance1 = await client.nestedVarActor.connect( - ["instance1"] - ); - const instance2 = await client.nestedVarActor.connect( - ["instance2"] - ); - - // Modify vars in the first instance - const modifiedVars = await instance1.modifyNested(); - expect(modifiedVars.nested.value).toBe("modified"); - expect(modifiedVars.nested.array).toContain(4); - expect(modifiedVars.nested.obj.key).toBe("new-value"); - - // Check that the second instance still has the original values - const instance2Vars = await instance2.getVars(); - expect(instance2Vars.nested.value).toBe("original"); - expect(instance2Vars.nested.array).toEqual([1, 2, 3]); - expect(instance2Vars.nested.obj.key).toBe("value"); - }); - }); - - describe("createVars", () => { - test("should support dynamic vars creation", async (c) => { - // Define actor with createVars function - const dynamicVarActor = actor({ - state: { value: 0 }, - connState: { hello: "world" }, - createVars: () => { - return { - random: Math.random(), - computed: `Actor-${Math.floor(Math.random() * 1000)}`, - }; - }, - actions: { - getVars: (c) => { - return c.vars; - }, - }, - }); - - const app = setup({ - actors: { dynamicVarActor }, - }); - - const { client } = await setupTest(c, app); - - // Create an instance - const instance = await client.dynamicVarActor.connect(); - - // Test accessing dynamically created vars - const vars = await instance.getVars(); - expect(vars).toHaveProperty("random"); - expect(vars).toHaveProperty("computed"); - expect(typeof vars.random).toBe("number"); - expect(typeof vars.computed).toBe("string"); - expect(vars.computed).toMatch(/^Actor-\d+$/); - }); - - test("should create different vars for different instances", async (c) => { - // Define actor with createVars function that generates unique values - const uniqueVarActor = actor({ - state: { value: 0 }, - connState: { hello: "world" }, - createVars: () => { - return { - id: Math.floor(Math.random() * 1000000), - }; - }, - actions: { - getVars: (c) => { - return c.vars; - }, - }, - }); - - const app = setup({ - actors: { uniqueVarActor }, - }); - - const { client } = await setupTest(c, app); - - // Create two separate instances - const instance1 = await client.uniqueVarActor.connect( - ["test1"] - ); - const instance2 = await client.uniqueVarActor.connect( - ["test2"] - ); - - // Get vars from both instances - const vars1 = await instance1.getVars(); - const vars2 = await instance2.getVars(); - - // Verify they have different values - expect(vars1.id).not.toBe(vars2.id); - }); - }); - - describe("Driver Context", () => { - test("should provide access to driver context", async (c) => { - // Reset timers to avoid test timeouts - vi.useRealTimers(); - - // Define actor with createVars that uses driver context - interface DriverVars { - hasDriverCtx: boolean; - } - - const driverCtxActor = actor({ - state: { value: 0 }, - connState: { hello: "world" }, - createVars: (c, driverCtx: any): DriverVars => { - // In test environment, we get a context with a state property - return { - hasDriverCtx: driverCtx?.isTest, - }; - }, - actions: { - getVars: (c) => { - return c.vars as DriverVars; - }, - }, - }); - - const app = setup({ - actors: { driverCtxActor }, - }); - - // Set up the test - const { client } = await setupTest(c, app); - - // Create an instance - const instance = await client.driverCtxActor.connect(); - - // Test accessing driver context through vars - const vars = await instance.getVars(); - - // Verify we can access driver context - expect(vars.hasDriverCtx).toBe(true); - }); - }); -}); diff --git a/packages/actor-core/tsconfig.json b/packages/actor-core/tsconfig.json index b30a98e6e..2e6f308da 100644 --- a/packages/actor-core/tsconfig.json +++ b/packages/actor-core/tsconfig.json @@ -1,9 +1,12 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "types": ["deno", "node"], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + // Used for test fixtures + "actor-core": ["./src/mod.ts"] } }, - "include": ["src/**/*", "tests/**/*"] + "include": ["src/**/*", "tests/**/*", "scripts/**/*", "fixtures/driver-test-suite/**/*"] } diff --git a/packages/actor-core/tsup.config.bundled_xvi1jgwbzx.mjs b/packages/actor-core/tsup.config.bundled_xvi1jgwbzx.mjs new file mode 100644 index 000000000..e01a2a057 --- /dev/null +++ b/packages/actor-core/tsup.config.bundled_xvi1jgwbzx.mjs @@ -0,0 +1,22 @@ +// ../../tsup.base.ts +var tsup_base_default = { + target: "node16", + platform: "node", + format: ["cjs", "esm"], + sourcemap: true, + clean: true, + dts: true, + minify: false, + // IMPORTANT: Splitting is required to fix a bug with ESM (https://github.com/egoist/tsup/issues/992#issuecomment-1763540165) + splitting: true, + skipNodeModulesBundle: true, + publicDir: true +}; + +// tsup.config.ts +import { defineConfig } from "tsup"; +var tsup_config_default = defineConfig(tsup_base_default); +export { + tsup_config_default as default +}; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiLi4vLi4vdHN1cC5iYXNlLnRzIiwgInRzdXAuY29uZmlnLnRzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJjb25zdCBfX2luamVjdGVkX2ZpbGVuYW1lX18gPSBcIi9Vc2Vycy9uYXRoYW4vcml2ZXQvYWN0b3ItY29yZS90c3VwLmJhc2UudHNcIjtjb25zdCBfX2luamVjdGVkX2Rpcm5hbWVfXyA9IFwiL1VzZXJzL25hdGhhbi9yaXZldC9hY3Rvci1jb3JlXCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9Vc2Vycy9uYXRoYW4vcml2ZXQvYWN0b3ItY29yZS90c3VwLmJhc2UudHNcIjtpbXBvcnQgdHlwZSB7IE9wdGlvbnMgfSBmcm9tIFwidHN1cFwiO1xuXG5leHBvcnQgZGVmYXVsdCB7XG5cdHRhcmdldDogXCJub2RlMTZcIixcblx0cGxhdGZvcm06IFwibm9kZVwiLFxuXHRmb3JtYXQ6IFtcImNqc1wiLCBcImVzbVwiXSxcblx0c291cmNlbWFwOiB0cnVlLFxuXHRjbGVhbjogdHJ1ZSxcblx0ZHRzOiB0cnVlLFxuXHRtaW5pZnk6IGZhbHNlLFxuXHQvLyBJTVBPUlRBTlQ6IFNwbGl0dGluZyBpcyByZXF1aXJlZCB0byBmaXggYSBidWcgd2l0aCBFU00gKGh0dHBzOi8vZ2l0aHViLmNvbS9lZ29pc3QvdHN1cC9pc3N1ZXMvOTkyI2lzc3VlY29tbWVudC0xNzYzNTQwMTY1KVxuXHRzcGxpdHRpbmc6IHRydWUsXG5cdHNraXBOb2RlTW9kdWxlc0J1bmRsZTogdHJ1ZSxcblx0cHVibGljRGlyOiB0cnVlLFxufSBzYXRpc2ZpZXMgT3B0aW9ucztcbiIsICJjb25zdCBfX2luamVjdGVkX2ZpbGVuYW1lX18gPSBcIi9Vc2Vycy9uYXRoYW4vcml2ZXQvYWN0b3ItY29yZS9wYWNrYWdlcy9hY3Rvci1jb3JlL3RzdXAuY29uZmlnLnRzXCI7Y29uc3QgX19pbmplY3RlZF9kaXJuYW1lX18gPSBcIi9Vc2Vycy9uYXRoYW4vcml2ZXQvYWN0b3ItY29yZS9wYWNrYWdlcy9hY3Rvci1jb3JlXCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9Vc2Vycy9uYXRoYW4vcml2ZXQvYWN0b3ItY29yZS9wYWNrYWdlcy9hY3Rvci1jb3JlL3RzdXAuY29uZmlnLnRzXCI7aW1wb3J0IGRlZmF1bHRDb25maWcgZnJvbSBcIi4uLy4uL3RzdXAuYmFzZS50c1wiO1xuaW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSBcInRzdXBcIjtcblxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKGRlZmF1bHRDb25maWcpO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUVBLElBQU8sb0JBQVE7QUFBQSxFQUNkLFFBQVE7QUFBQSxFQUNSLFVBQVU7QUFBQSxFQUNWLFFBQVEsQ0FBQyxPQUFPLEtBQUs7QUFBQSxFQUNyQixXQUFXO0FBQUEsRUFDWCxPQUFPO0FBQUEsRUFDUCxLQUFLO0FBQUEsRUFDTCxRQUFRO0FBQUE7QUFBQSxFQUVSLFdBQVc7QUFBQSxFQUNYLHVCQUF1QjtBQUFBLEVBQ3ZCLFdBQVc7QUFDWjs7O0FDYkEsU0FBUyxvQkFBb0I7QUFFN0IsSUFBTyxzQkFBUSxhQUFhLGlCQUFhOyIsCiAgIm5hbWVzIjogW10KfQo= diff --git a/packages/actor-core/turbo.json b/packages/actor-core/turbo.json index 95960709b..dfed220e5 100644 --- a/packages/actor-core/turbo.json +++ b/packages/actor-core/turbo.json @@ -1,4 +1,14 @@ { "$schema": "https://turbo.build/schema.json", - "extends": ["//"] + "extends": ["//"], + "tasks": { + "dump-openapi": { + "inputs": ["package.json", "packages/actor-core/src/manager/router.ts"] + }, + "build": { + "dependsOn": ["^build", "dump-openapi"], + "inputs": ["src/**", "tsconfig.json", "tsup.config.ts", "package.json"], + "outputs": ["dist/**"] + } + } } diff --git a/packages/drivers/file-system/package.json b/packages/drivers/file-system/package.json index 7d9a753ba..46ce31df9 100644 --- a/packages/drivers/file-system/package.json +++ b/packages/drivers/file-system/package.json @@ -27,7 +27,6 @@ "actor-core": "*" }, "devDependencies": { - "@actor-core/driver-test-suite": "workspace:*", "@types/invariant": "^2", "@types/node": "^22.14.0", "actor-core": "workspace:*", diff --git a/packages/drivers/file-system/src/actor.ts b/packages/drivers/file-system/src/actor.ts index 27d7871b5..791816bd6 100644 --- a/packages/drivers/file-system/src/actor.ts +++ b/packages/drivers/file-system/src/actor.ts @@ -1,5 +1,5 @@ import type { ActorDriver, AnyActorInstance } from "actor-core/driver-helpers"; -import type { FileSystemGlobalState } from "./global_state"; +import type { FileSystemGlobalState } from "./global-state"; export type ActorDriverContext = Record; diff --git a/packages/drivers/file-system/src/global_state.ts b/packages/drivers/file-system/src/global-state.ts similarity index 100% rename from packages/drivers/file-system/src/global_state.ts rename to packages/drivers/file-system/src/global-state.ts diff --git a/packages/drivers/file-system/src/manager.ts b/packages/drivers/file-system/src/manager.ts index e9fe12f31..1df511ff2 100644 --- a/packages/drivers/file-system/src/manager.ts +++ b/packages/drivers/file-system/src/manager.ts @@ -7,8 +7,9 @@ import type { GetWithKeyInput, ManagerDriver, } from "actor-core/driver-helpers"; +import { ActorAlreadyExists } from "actor-core/errors"; import { logger } from "./log"; -import type { FileSystemGlobalState } from "./global_state"; +import type { FileSystemGlobalState } from "./global-state"; import type { ActorCoreApp } from "actor-core"; import { ManagerInspector } from "actor-core/inspector"; @@ -31,7 +32,6 @@ export class FileSystemManagerDriver implements ManagerDriver { } async getForId({ - baseUrl, actorId, }: GetForIdInput): Promise { // Validate the actor exists @@ -44,9 +44,10 @@ export class FileSystemManagerDriver implements ManagerDriver { const state = this.#state.loadActorState(actorId); return { - endpoint: buildActorEndpoint(baseUrl, actorId), + actorId, name: state.name, key: state.key, + meta: undefined, }; } catch (error) { logger().error("failed to read actor state", { actorId, error }); @@ -55,7 +56,6 @@ export class FileSystemManagerDriver implements ManagerDriver { } async getWithKey({ - baseUrl, name, key, }: GetWithKeyInput): Promise { @@ -65,12 +65,12 @@ export class FileSystemManagerDriver implements ManagerDriver { // Search through all actors to find a match const actor = this.#state.findActor((actor) => { if (actor.name !== name) return false; - + // If actor doesn't have a key, it's not a match if (!actor.key || actor.key.length !== key.length) { return false; } - + // Check if all elements in key are in actor.key for (let i = 0; i < key.length; i++) { if (key[i] !== actor.key[i]) { @@ -82,9 +82,10 @@ export class FileSystemManagerDriver implements ManagerDriver { if (actor) { return { - endpoint: buildActorEndpoint(baseUrl, actor.id), + actorId: actor.id, name, key: actor.key, + meta: undefined, }; } @@ -92,22 +93,24 @@ export class FileSystemManagerDriver implements ManagerDriver { } async createActor({ - baseUrl, name, key, }: CreateActorInput): Promise { + // Check if actor with the same name and key already exists + const existingActor = await this.getWithKey({ name, key }); + if (existingActor) { + throw new ActorAlreadyExists(name, key); + } + const actorId = crypto.randomUUID(); await this.#state.createActor(actorId, name, key); - + // Notify inspector about actor changes this.inspector.onActorsChange(this.#state.getAllActors()); - + return { - endpoint: buildActorEndpoint(baseUrl, actorId), + actorId, + meta: undefined, }; } } - -function buildActorEndpoint(baseUrl: string, actorId: string) { - return `${baseUrl}/actors/${actorId}`; -} diff --git a/packages/drivers/file-system/src/mod.ts b/packages/drivers/file-system/src/mod.ts index 41d2183fb..e2edfc32d 100644 --- a/packages/drivers/file-system/src/mod.ts +++ b/packages/drivers/file-system/src/mod.ts @@ -1,5 +1,5 @@ export { getStoragePath } from "./utils"; export { FileSystemActorDriver } from "./actor"; export { FileSystemManagerDriver } from "./manager"; -export { FileSystemGlobalState } from "./global_state"; +export { FileSystemGlobalState } from "./global-state"; diff --git a/packages/drivers/file-system/tests/driver-tests.test.ts b/packages/drivers/file-system/tests/driver-tests.test.ts index 9ebe9be1b..9d95d9c01 100644 --- a/packages/drivers/file-system/tests/driver-tests.test.ts +++ b/packages/drivers/file-system/tests/driver-tests.test.ts @@ -1,7 +1,7 @@ import { runDriverTests, createTestRuntime, -} from "@actor-core/driver-test-suite"; +} from "actor-core/driver-test-suite"; import { FileSystemActorDriver, FileSystemManagerDriver, diff --git a/packages/drivers/memory/package.json b/packages/drivers/memory/package.json index a56b7ef96..bf40cc129 100644 --- a/packages/drivers/memory/package.json +++ b/packages/drivers/memory/package.json @@ -27,7 +27,6 @@ "actor-core": "*" }, "devDependencies": { - "@actor-core/driver-test-suite": "workspace:*", "actor-core": "workspace:*", "tsup": "^8.4.0", "typescript": "^5.5.2" diff --git a/packages/drivers/memory/src/actor.ts b/packages/drivers/memory/src/actor.ts index 2f8f2659f..069d513bc 100644 --- a/packages/drivers/memory/src/actor.ts +++ b/packages/drivers/memory/src/actor.ts @@ -1,5 +1,5 @@ import type { ActorDriver, AnyActorInstance } from "actor-core/driver-helpers"; -import type { MemoryGlobalState } from "./global_state"; +import type { MemoryGlobalState } from "./global-state"; export type ActorDriverContext = Record; diff --git a/packages/drivers/memory/src/global_state.ts b/packages/drivers/memory/src/global-state.ts similarity index 100% rename from packages/drivers/memory/src/global_state.ts rename to packages/drivers/memory/src/global-state.ts diff --git a/packages/drivers/memory/src/manager.ts b/packages/drivers/memory/src/manager.ts index 8750617cc..44f694619 100644 --- a/packages/drivers/memory/src/manager.ts +++ b/packages/drivers/memory/src/manager.ts @@ -6,7 +6,9 @@ import type { GetWithKeyInput, ManagerDriver, } from "actor-core/driver-helpers"; -import type { MemoryGlobalState } from "./global_state"; +import { ActorAlreadyExists } from "actor-core/errors"; +import type { MemoryGlobalState } from "./global-state"; +import * as crypto from "node:crypto"; import { ManagerInspector } from "actor-core/inspector"; import type { ActorCoreApp } from "actor-core"; @@ -29,7 +31,6 @@ export class MemoryManagerDriver implements ManagerDriver { } async getForId({ - baseUrl, actorId, }: GetForIdInput): Promise { // Validate the actor exists @@ -39,14 +40,14 @@ export class MemoryManagerDriver implements ManagerDriver { } return { - endpoint: buildActorEndpoint(baseUrl, actorId), + actorId: actor.id, name: actor.name, key: actor.key, + meta: undefined, }; } async getWithKey({ - baseUrl, name, key, }: GetWithKeyInput): Promise { @@ -56,12 +57,12 @@ export class MemoryManagerDriver implements ManagerDriver { // Search through all actors to find a match const actor = this.#state.findActor((actor) => { if (actor.name !== name) return false; - + // If actor doesn't have a key, it's not a match if (!actor.key || actor.key.length !== key.length) { return false; } - + // Check if all elements in key are in actor.key for (let i = 0; i < key.length; i++) { if (key[i] !== actor.key[i]) { @@ -73,9 +74,10 @@ export class MemoryManagerDriver implements ManagerDriver { if (actor) { return { - endpoint: buildActorEndpoint(baseUrl, actor.id), + actorId: actor.id, name, key: actor.key, + meta: undefined, }; } @@ -83,21 +85,20 @@ export class MemoryManagerDriver implements ManagerDriver { } async createActor({ - baseUrl, name, key, }: CreateActorInput): Promise { + // Check if actor with the same name and key already exists + const existingActor = await this.getWithKey({ name, key }); + if (existingActor) { + throw new ActorAlreadyExists(name, key); + } + const actorId = crypto.randomUUID(); this.#state.createActor(actorId, name, key); this.inspector.onActorsChange(this.#state.getAllActors()); - return { - endpoint: buildActorEndpoint(baseUrl, actorId), - }; + return { actorId, meta: undefined }; } } - -function buildActorEndpoint(baseUrl: string, actorId: string) { - return `${baseUrl}/actors/${actorId}`; -} diff --git a/packages/drivers/memory/src/mod.ts b/packages/drivers/memory/src/mod.ts index 532759fdb..522714b88 100644 --- a/packages/drivers/memory/src/mod.ts +++ b/packages/drivers/memory/src/mod.ts @@ -1,3 +1,3 @@ -export { MemoryGlobalState } from "./global_state"; +export { MemoryGlobalState } from "./global-state"; export { MemoryActorDriver } from "./actor"; export { MemoryManagerDriver } from "./manager"; diff --git a/packages/drivers/memory/tests/driver-tests.test.ts b/packages/drivers/memory/tests/driver-tests.test.ts index 894894916..6879333c3 100644 --- a/packages/drivers/memory/tests/driver-tests.test.ts +++ b/packages/drivers/memory/tests/driver-tests.test.ts @@ -1,4 +1,4 @@ -import { runDriverTests, createTestRuntime } from "@actor-core/driver-test-suite"; +import { runDriverTests, createTestRuntime } from "actor-core/driver-test-suite"; import { MemoryActorDriver, MemoryManagerDriver, diff --git a/packages/drivers/redis/package.json b/packages/drivers/redis/package.json index 492b89b93..1b38858cb 100644 --- a/packages/drivers/redis/package.json +++ b/packages/drivers/redis/package.json @@ -59,7 +59,6 @@ "actor-core": "workspace:*" }, "devDependencies": { - "@actor-core/driver-test-suite": "workspace:*", "@types/node": "^22.13.1", "actor-core": "workspace:*", "tsup": "^8.4.0", diff --git a/packages/drivers/redis/src/manager.ts b/packages/drivers/redis/src/manager.ts index 927328f8c..3276ada13 100644 --- a/packages/drivers/redis/src/manager.ts +++ b/packages/drivers/redis/src/manager.ts @@ -6,7 +6,9 @@ import type { GetWithKeyInput, ManagerDriver, } from "actor-core/driver-helpers"; +import { ActorAlreadyExists } from "actor-core/errors"; import type Redis from "ioredis"; +import * as crypto from "node:crypto"; import { KEYS } from "./keys"; import { ManagerInspector } from "actor-core/inspector"; import type { ActorCoreApp } from "actor-core"; @@ -53,7 +55,6 @@ export class RedisManagerDriver implements ManagerDriver { } async getForId({ - baseUrl, actorId, }: GetForIdInput): Promise { // Get metadata from Redis @@ -68,14 +69,14 @@ export class RedisManagerDriver implements ManagerDriver { const { name, key } = metadata; return { - endpoint: buildActorEndpoint(baseUrl, actorId), + actorId, name, key, + meta: undefined, }; } async getWithKey({ - baseUrl, name, key, }: GetWithKeyInput): Promise { @@ -87,14 +88,19 @@ export class RedisManagerDriver implements ManagerDriver { return undefined; } - return this.getForId({ baseUrl, actorId }); + return this.getForId({ actorId }); } async createActor({ - baseUrl, name, key, }: CreateActorInput): Promise { + // Check if actor with the same name and key already exists + const existingActor = await this.getWithKey({ name, key }); + if (existingActor) { + throw new ActorAlreadyExists(name, key); + } + const actorId = crypto.randomUUID(); const actorKeyRedisKey = this.#generateActorKeyRedisKey(name, key); @@ -121,7 +127,8 @@ export class RedisManagerDriver implements ManagerDriver { ]); return { - endpoint: buildActorEndpoint(baseUrl, actorId.toString()), + actorId, + meta: undefined, }; } @@ -171,8 +178,3 @@ export class RedisManagerDriver implements ManagerDriver { .replace(/:/g, "\\:"); // Escape colons (our delimiter) } } - -function buildActorEndpoint(baseUrl: string, actorId: string) { - return `${baseUrl}/actors/${actorId}`; -} - diff --git a/packages/drivers/redis/tests/driver-tests.test.ts b/packages/drivers/redis/tests/driver-tests.test.ts index 71f018908..08b66a2df 100644 --- a/packages/drivers/redis/tests/driver-tests.test.ts +++ b/packages/drivers/redis/tests/driver-tests.test.ts @@ -1,7 +1,7 @@ import { runDriverTests, createTestRuntime, -} from "@actor-core/driver-test-suite"; +} from "actor-core/driver-test-suite"; import { RedisActorDriver, RedisCoordinateDriver, @@ -120,7 +120,6 @@ test("Valkey container starts and stops properly", async () => { host: "localhost", connectTimeout: 1000, }); - await newRedis.connect(); await newRedis.quit(); throw new Error("Valkey connection should have failed"); } catch (error) { diff --git a/packages/frameworks/framework-base/src/mod.ts b/packages/frameworks/framework-base/src/mod.ts index a38ace141..422adfec6 100644 --- a/packages/frameworks/framework-base/src/mod.ts +++ b/packages/frameworks/framework-base/src/mod.ts @@ -1,148 +1,148 @@ -import type { - ActorConn, - ActorAccessor, - ExtractAppFromClient, - ExtractActorsFromApp, - ClientRaw, - AnyActorDefinition, -} from "actor-core/client"; - -/** - * Shallow compare objects. - * Copied from https://github.com/TanStack/query/blob/3c5d8e348cc53e46aea6c74767f3181fc77c2308/packages/query-core/src/utils.ts#L298-L299 - */ -export function shallowEqualObjects< - // biome-ignore lint/suspicious/noExplicitAny: we do not care about the shape - T extends Record, ->(a: T | undefined, b: T | undefined): boolean { - if (a === undefined && b === undefined) { - return true; - } - if (!a || !b || Object.keys(a).length !== Object.keys(b).length) { - return false; - } - - for (const key in a) { - if (a[key] !== b[key]) { - if (typeof a[key] === "object" && typeof b[key] === "object") { - return shallowEqualObjects(a[key], b[key]); - } - return false; - } - } - - return true; -} - -namespace State { - export type Value = - | { state: "init"; actor: undefined; isLoading: false } - | { state: "creating"; actor: undefined; isLoading: true } - | { state: "created"; actor: ActorConn; isLoading: false } - | { state: "error"; error: unknown; actor: undefined; isLoading: false }; - - export const INIT = (): Value => ({ - state: "init", - actor: undefined, - isLoading: false, - }); - export const CREATING = (): Value => ({ - state: "creating", - actor: undefined, - isLoading: true, - }); - export const CREATED = ( - actor: ActorConn, - ): Value => ({ - state: "created", - actor, - isLoading: false, - }); - export const ERRORED = ( - error: unknown, - ): Value => ({ - state: "error", - actor: undefined, - error, - isLoading: false, - }); -} - -export class ActorManager< - C extends ClientRaw, - App extends ExtractAppFromClient, - Registry extends ExtractActorsFromApp, - ActorName extends keyof Registry, - AD extends Registry[ActorName], -> { - #client: C; - #name: Exclude; - #options: Parameters["connect"]>; - - #listeners: (() => void)[] = []; - - #state: State.Value = State.INIT(); - - #createPromise: Promise> | null = null; - - constructor( - client: C, - name: Exclude, - options: Parameters["connect"]>, - ) { - this.#client = client; - this.#name = name; - this.#options = options; - } - - setOptions(options: Parameters["connect"]>) { - if (shallowEqualObjects(options, this.#options)) { - if (!this.#state.actor) { - this.create(); - } - return; - } - - this.#state.actor?.dispose(); - - this.#state = { ...State.INIT() }; - this.#options = options; - this.#update(); - this.create(); - } - - async create() { - if (this.#createPromise) { - return this.#createPromise; - } - this.#state = { ...State.CREATING() }; - this.#update(); - try { - this.#createPromise = this.#client.connect(this.#name, ...this.#options); - const actor = (await this.#createPromise) as ActorConn; - this.#state = { ...State.CREATED(actor) }; - this.#createPromise = null; - } catch (e) { - this.#state = { ...State.ERRORED(e) }; - } finally { - this.#update(); - } - } - - getState() { - return this.#state; - } - - subscribe(cb: () => void) { - this.#listeners.push(cb); - return () => { - this.#listeners = this.#listeners.filter((l) => l !== cb); - }; - } - - #update() { - for (const cb of this.#listeners) { - cb(); - } - } -} +//import type { +// ActorConn, +// ActorAccessor, +// ExtractAppFromClient, +// ExtractActorsFromApp, +// ClientRaw, +// AnyActorDefinition, +//} from "actor-core/client"; +// +///** +// * Shallow compare objects. +// * Copied from https://github.com/TanStack/query/blob/3c5d8e348cc53e46aea6c74767f3181fc77c2308/packages/query-core/src/utils.ts#L298-L299 +// */ +//export function shallowEqualObjects< +// // biome-ignore lint/suspicious/noExplicitAny: we do not care about the shape +// T extends Record, +//>(a: T | undefined, b: T | undefined): boolean { +// if (a === undefined && b === undefined) { +// return true; +// } +// if (!a || !b || Object.keys(a).length !== Object.keys(b).length) { +// return false; +// } +// +// for (const key in a) { +// if (a[key] !== b[key]) { +// if (typeof a[key] === "object" && typeof b[key] === "object") { +// return shallowEqualObjects(a[key], b[key]); +// } +// return false; +// } +// } +// +// return true; +//} +// +//namespace State { +// export type Value = +// | { state: "init"; actor: undefined; isLoading: false } +// | { state: "creating"; actor: undefined; isLoading: true } +// | { state: "created"; actor: ActorConn; isLoading: false } +// | { state: "error"; error: unknown; actor: undefined; isLoading: false }; +// +// export const INIT = (): Value => ({ +// state: "init", +// actor: undefined, +// isLoading: false, +// }); +// export const CREATING = (): Value => ({ +// state: "creating", +// actor: undefined, +// isLoading: true, +// }); +// export const CREATED = ( +// actor: ActorConn, +// ): Value => ({ +// state: "created", +// actor, +// isLoading: false, +// }); +// export const ERRORED = ( +// error: unknown, +// ): Value => ({ +// state: "error", +// actor: undefined, +// error, +// isLoading: false, +// }); +//} +// +//export class ActorManager< +// C extends ClientRaw, +// App extends ExtractAppFromClient, +// Registry extends ExtractActorsFromApp, +// ActorName extends keyof Registry, +// AD extends Registry[ActorName], +//> { +// #client: C; +// #name: Exclude; +// #options: Parameters["connect"]>; +// +// #listeners: (() => void)[] = []; +// +// #state: State.Value = State.INIT(); +// +// #createPromise: Promise> | null = null; +// +// constructor( +// client: C, +// name: Exclude, +// options: Parameters["connect"]>, +// ) { +// this.#client = client; +// this.#name = name; +// this.#options = options; +// } +// +// setOptions(options: Parameters["connect"]>) { +// if (shallowEqualObjects(options, this.#options)) { +// if (!this.#state.actor) { +// this.create(); +// } +// return; +// } +// +// this.#state.actor?.dispose(); +// +// this.#state = { ...State.INIT() }; +// this.#options = options; +// this.#update(); +// this.create(); +// } +// +// async create() { +// if (this.#createPromise) { +// return this.#createPromise; +// } +// this.#state = { ...State.CREATING() }; +// this.#update(); +// try { +// this.#createPromise = this.#client.connect(this.#name, ...this.#options); +// const actor = (await this.#createPromise) as ActorConn; +// this.#state = { ...State.CREATED(actor) }; +// this.#createPromise = null; +// } catch (e) { +// this.#state = { ...State.ERRORED(e) }; +// } finally { +// this.#update(); +// } +// } +// +// getState() { +// return this.#state; +// } +// +// subscribe(cb: () => void) { +// this.#listeners.push(cb); +// return () => { +// this.#listeners = this.#listeners.filter((l) => l !== cb); +// }; +// } +// +// #update() { +// for (const cb of this.#listeners) { +// cb(); +// } +// } +//} diff --git a/packages/frameworks/react/src/mod.tsx b/packages/frameworks/react/src/mod.tsx index 3cfa50ca9..8135b7fa8 100644 --- a/packages/frameworks/react/src/mod.tsx +++ b/packages/frameworks/react/src/mod.tsx @@ -1,77 +1,77 @@ -"use client"; -import type { - ActorAccessor, - ActorConn, - ExtractAppFromClient, - ExtractActorsFromApp, - ClientRaw, -} from "actor-core/client"; -import { ActorManager } from "@actor-core/framework-base"; -import { - useCallback, - useEffect, - useRef, - useState, - useSyncExternalStore, -} from "react"; - -export function createReactActorCore(client: Client) { - type App = ExtractAppFromClient; - type Registry = ExtractActorsFromApp; - return { - useActor: function useActor< - N extends keyof Registry, - AD extends Registry[N], - >( - name: Exclude, - ...options: Parameters["connect"]> - ) { - const [manager] = useState( - () => - new ActorManager(client, name, options), - ); - - const state = useSyncExternalStore( - useCallback( - (onUpdate) => { - return manager.subscribe(onUpdate); - }, - [manager], - ), - () => manager.getState(), - () => manager.getState(), - ); - - useEffect(() => { - manager.setOptions(options); - }, [options, manager]); - - return [state] as const; - }, - useActorEvent( - opts: { actor: ActorConn | undefined; event: string }, - cb: (...args: unknown[]) => void, - ) { - const ref = useRef(cb); - - useEffect(() => { - ref.current = cb; - }, [cb]); - - useEffect(() => { - if (!opts.actor) { - return noop; - } - const unsub = opts.actor.on(opts.event, (...args: unknown[]) => { - ref.current(...args); - }); - - return unsub; - }, [opts.actor, opts.event]); - }, - }; -} - -function noop() { - // noop -} +//"use client"; +//import type { +// ActorAccessor, +// ActorConn, +// ExtractAppFromClient, +// ExtractActorsFromApp, +// ClientRaw, +//} from "actor-core/client"; +//import { ActorManager } from "@actor-core/framework-base"; +//import { +// useCallback, +// useEffect, +// useRef, +// useState, +// useSyncExternalStore, +//} from "react"; +// +//export function createReactActorCore(client: Client) { +// type App = ExtractAppFromClient; +// type Registry = ExtractActorsFromApp; +// return { +// useActor: function useActor< +// N extends keyof Registry, +// AD extends Registry[N], +// >( +// name: Exclude, +// ...options: Parameters["connect"]> +// ) { +// const [manager] = useState( +// () => +// new ActorManager(client, name, options), +// ); +// +// const state = useSyncExternalStore( +// useCallback( +// (onUpdate) => { +// return manager.subscribe(onUpdate); +// }, +// [manager], +// ), +// () => manager.getState(), +// () => manager.getState(), +// ); +// +// useEffect(() => { +// manager.setOptions(options); +// }, [options, manager]); +// +// return [state] as const; +// }, +// useActorEvent( +// opts: { actor: ActorConn | undefined; event: string }, +// cb: (...args: unknown[]) => void, +// ) { +// const ref = useRef(cb); +// +// useEffect(() => { +// ref.current = cb; +// }, [cb]); +// +// useEffect(() => { +// if (!opts.actor) { +// return noop; +// } +// const unsub = opts.actor.on(opts.event, (...args: unknown[]) => { +// ref.current(...args); +// }); +// +// return unsub; +// }, [opts.actor, opts.event]); +// }, +// }; +//} +// +//function noop() { +// // noop +//} diff --git a/packages/misc/driver-test-suite/fixtures/apps/scheduled.ts b/packages/misc/driver-test-suite/fixtures/apps/scheduled.ts deleted file mode 100644 index d586dd53e..000000000 --- a/packages/misc/driver-test-suite/fixtures/apps/scheduled.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { actor, setup } from "actor-core"; - -const scheduled = actor({ - state: { - lastRun: 0, - scheduledCount: 0, - }, - actions: { - scheduleTask: (c, delayMs: number) => { - const timestamp = Date.now() + delayMs; - c.schedule.at(timestamp, "onScheduledTask"); - return timestamp; - }, - getLastRun: (c) => { - return c.state.lastRun; - }, - getScheduledCount: (c) => { - return c.state.scheduledCount; - }, - onScheduledTask: (c) => { - c.state.lastRun = Date.now(); - c.state.scheduledCount++; - c.broadcast("scheduled", { - time: c.state.lastRun, - count: c.state.scheduledCount, - }); - }, - }, -}); - -export const app = setup({ - actors: { scheduled }, -}); - -export type App = typeof app; diff --git a/packages/misc/driver-test-suite/package.json b/packages/misc/driver-test-suite/package.json deleted file mode 100644 index 4993fb051..000000000 --- a/packages/misc/driver-test-suite/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "@actor-core/driver-test-suite", - "version": "0.8.0", - "files": [ - "src", - "dist", - "package.json" - ], - "type": "module", - "exports": { - "import": { - "types": "./dist/mod.d.ts", - "default": "./dist/mod.js" - }, - "require": { - "types": "./dist/mod.d.cts", - "default": "./dist/mod.cjs" - } - }, - "sideEffects": false, - "scripts": { - "build": "tsup src/mod.ts", - "check-types": "tsc --noEmit" - }, - "peerDependencies": { - "actor-core": "workspace:*" - }, - "devDependencies": { - "tsup": "^8.4.0", - "typescript": "^5.7.3" - }, - "dependencies": { - "@hono/node-server": "^1.14.0", - "@hono/node-ws": "^1.1.1", - "@types/node": "^22.13.1", - "actor-core": "workspace:*", - "bundle-require": "^5.1.0", - "vitest": "^3.1.1" - } -} diff --git a/packages/misc/driver-test-suite/src/log.ts b/packages/misc/driver-test-suite/src/log.ts deleted file mode 100644 index ccc558d59..000000000 --- a/packages/misc/driver-test-suite/src/log.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { getLogger } from "actor-core/log"; - -export const LOGGER_NAME = "driver-test-suite"; - -export function logger() { - return getLogger(LOGGER_NAME); -} diff --git a/packages/misc/driver-test-suite/src/tests/actor-driver.ts b/packages/misc/driver-test-suite/src/tests/actor-driver.ts deleted file mode 100644 index 6aec43f08..000000000 --- a/packages/misc/driver-test-suite/src/tests/actor-driver.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, test, expect, vi } from "vitest"; -import type { DriverTestConfig } from "@/mod"; -import { setupDriverTest } from "@/utils"; -import { resolve } from "node:path"; -import type { App as CounterApp } from "../../fixtures/apps/counter"; -import type { App as ScheduledApp } from "../../fixtures/apps/scheduled"; - -/** - * Waits for the specified time, using either real setTimeout or vi.advanceTimersByTime - * based on the driverTestConfig. - */ -export async function waitFor( - driverTestConfig: DriverTestConfig, - ms: number, -): Promise { - if (driverTestConfig.useRealTimers) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } else { - vi.advanceTimersByTime(ms); - return Promise.resolve(); - } -} -export function runActorDriverTests(driverTestConfig: DriverTestConfig) { - describe("Actor Driver Tests", () => { - describe("State Persistence", () => { - test("persists state between actor instances", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - resolve(__dirname, "../fixtures/apps/counter.ts"), - ); - - // Create instance and increment - const counterInstance = await client.counter.connect(); - const initialCount = await counterInstance.increment(5); - expect(initialCount).toBe(5); - - // Get a fresh reference to the same actor and verify state persisted - const sameInstance = await client.counter.connect(); - const persistedCount = await sameInstance.increment(3); - expect(persistedCount).toBe(8); - }); - - test("restores state after actor disconnect/reconnect", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - resolve(__dirname, "../fixtures/apps/counter.ts"), - ); - - // Create actor and set initial state - const counterInstance = await client.counter.connect(); - await counterInstance.increment(5); - - // Disconnect the actor - await counterInstance.dispose(); - - // Reconnect to the same actor - const reconnectedInstance = await client.counter.connect(); - const persistedCount = await reconnectedInstance.increment(0); - expect(persistedCount).toBe(5); - }); - - test("maintains separate state for different actors", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - resolve(__dirname, "../fixtures/apps/counter.ts"), - ); - - // Create first counter with specific key - const counterA = await client.counter.connect(["counter-a"]); - await counterA.increment(5); - - // Create second counter with different key - const counterB = await client.counter.connect(["counter-b"]); - await counterB.increment(10); - - // Verify state is separate - const countA = await counterA.increment(0); - const countB = await counterB.increment(0); - expect(countA).toBe(5); - expect(countB).toBe(10); - }); - }); - - describe("Scheduled Alarms", () => { - test("executes scheduled alarms", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - resolve(__dirname, "../fixtures/apps/scheduled.ts"), - ); - - // Create instance - const alarmInstance = await client.scheduled.connect(); - - // Schedule a task to run in 100ms - await alarmInstance.scheduleTask(100); - - // Wait for longer than the scheduled time - await waitFor(driverTestConfig, 150); - - // Verify the scheduled task ran - const lastRun = await alarmInstance.getLastRun(); - const scheduledCount = await alarmInstance.getScheduledCount(); - - expect(lastRun).toBeGreaterThan(0); - expect(scheduledCount).toBe(1); - }); - }); - }); -} \ No newline at end of file diff --git a/packages/misc/driver-test-suite/src/tests/manager-driver.ts b/packages/misc/driver-test-suite/src/tests/manager-driver.ts deleted file mode 100644 index dec88f427..000000000 --- a/packages/misc/driver-test-suite/src/tests/manager-driver.ts +++ /dev/null @@ -1,376 +0,0 @@ -import { describe, test, expect } from "vitest"; -import type { DriverTestConfig } from "@/mod"; -import { setupDriverTest } from "@/utils"; -import { resolve } from "node:path"; -import type { App as CounterApp } from "../../fixtures/apps/counter"; - -export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { - describe("Manager Driver Tests", () => { - describe("Client Connection Methods", () => { - test("connect() - finds or creates an actor", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - resolve(__dirname, "../fixtures/apps/counter.ts"), - ); - - // Basic connect() with no parameters creates a default actor - const counterA = await client.counter.connect(); - await counterA.increment(5); - - // Get the same actor again to verify state persisted - const counterAAgain = await client.counter.connect(); - const count = await counterAAgain.increment(0); - expect(count).toBe(5); - - // Connect with key creates a new actor with specific parameters - const counterB = await client.counter.connect(["counter-b", "testing"]); - - await counterB.increment(10); - const countB = await counterB.increment(0); - expect(countB).toBe(10); - }); - - test("create() - always creates a new actor", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - resolve(__dirname, "../fixtures/apps/counter.ts"), - ); - - // Create with basic options - const counterA = await client.counter.createAndConnect([ - "explicit-create", - ]); - await counterA.increment(7); - - // Create with the same ID should overwrite or return a conflict - try { - // Should either create a new actor with the same ID (overwriting) - // or throw an error (if the driver prevents ID conflicts) - const counterADuplicate = await client.counter.connect(undefined, { - create: { - key: ["explicit-create"], - }, - }); - await counterADuplicate.increment(1); - - // If we get here, the driver allows ID overwrites - // Verify that state was reset or overwritten - const newCount = await counterADuplicate.increment(0); - expect(newCount).toBe(1); // Not 8 (7+1) if it's a new instance - } catch (error) { - // This is also valid behavior if the driver prevents ID conflicts - // No assertion needed - } - - // Create with full options - const counterB = await client.counter.createAndConnect([ - "full-options", - "testing", - "counter", - ]); - - await counterB.increment(3); - const countB = await counterB.increment(0); - expect(countB).toBe(3); - }); - }); - - describe("Connection Options", () => { - test("noCreate option prevents actor creation", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - resolve(__dirname, "../fixtures/apps/counter.ts"), - ); - - // Try to get a nonexistent actor with noCreate - const nonexistentId = `nonexistent-${Date.now()}`; - - // Should fail when actor doesn't exist - let error: unknown; - try { - await client.counter.connect([nonexistentId], { - noCreate: true, - }); - } catch (err) { - error = err; - } - - // Verify we got an error - expect(error).toBeTruthy(); - - // Create the actor - const counter = await client.counter.connect(undefined, { - create: { - key: [nonexistentId], - }, - }); - await counter.increment(3); - - // Now noCreate should work since the actor exists - const retrievedCounter = await client.counter.connect([nonexistentId], { - noCreate: true, - }); - - const count = await retrievedCounter.increment(0); - expect(count).toBe(3); - }); - - test("connection params are passed to actors", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - resolve(__dirname, "../fixtures/apps/counter.ts"), - ); - - // Create an actor with connection params - // Note: In a real test we'd verify these are received by the actor, - // but our simple counter actor doesn't use connection params. - // This test just ensures the params are accepted by the driver. - const counter = await client.counter.connect(undefined, { - params: { - userId: "user-123", - authToken: "token-abc", - settings: { increment: 5 }, - }, - }); - - await counter.increment(1); - const count = await counter.increment(0); - expect(count).toBe(1); - }); - }); - - describe("Actor Creation & Retrieval", () => { - test("creates and retrieves actors by ID", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - resolve(__dirname, "../fixtures/apps/counter.ts"), - ); - - // Create a unique ID for this test - const uniqueId = `test-counter-${Date.now()}`; - - // Create actor with specific ID - const counter = await client.counter.connect([uniqueId]); - await counter.increment(10); - - // Retrieve the same actor by ID and verify state - const retrievedCounter = await client.counter.connect([uniqueId]); - const count = await retrievedCounter.increment(0); // Get current value - expect(count).toBe(10); - }); - - // TODO: Correctly test region for each provider - //test("creates and retrieves actors with region", async (c) => { - // const { client } = await setupDriverTest(c, - // driverTestConfig, - // resolve(__dirname, "../fixtures/apps/counter.ts"), - // ); - // - // // Create actor with a specific region - // const counter = await client.counter.connect({ - // create: { - // key: ["metadata-test", "testing"], - // region: "test-region", - // }, - // }); - // - // // Set state to identify this specific instance - // await counter.increment(42); - // - // // Retrieve by ID (since metadata is not used for retrieval) - // const retrievedCounter = await client.counter.connect(["metadata-test"]); - // - // // Verify it's the same instance - // const count = await retrievedCounter.increment(0); - // expect(count).toBe(42); - //}); - }); - - describe("Key Matching", () => { - test("finds actors with equal or superset of specified keys", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - resolve(__dirname, "../fixtures/apps/counter.ts"), - ); - - // Create actor with multiple keys - const originalCounter = await client.counter.connect([ - "counter-match", - "test", - "us-east", - ]); - await originalCounter.increment(10); - - // Should match with exact same keys - const exactMatchCounter = await client.counter.connect([ - "counter-match", - "test", - "us-east", - ]); - const exactMatchCount = await exactMatchCounter.increment(0); - expect(exactMatchCount).toBe(10); - - // Should match with subset of keys - const subsetMatchCounter = await client.counter.connect([ - "counter-match", - "test", - ]); - const subsetMatchCount = await subsetMatchCounter.increment(0); - expect(subsetMatchCount).toBe(10); - - // Should match with just one key - const singleKeyCounter = await client.counter.connect([ - "counter-match", - ]); - const singleKeyCount = await singleKeyCounter.increment(0); - expect(singleKeyCount).toBe(10); - }); - - test("no keys match actors with keys", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - resolve(__dirname, "../fixtures/apps/counter.ts"), - ); - - // Create counter with keys - const keyedCounter = await client.counter.connect([ - "counter-with-keys", - "special", - ]); - await keyedCounter.increment(15); - - // Should match when searching with no keys - const noKeysCounter = await client.counter.connect(); - const count = await noKeysCounter.increment(0); - - // Should have matched existing actor - expect(count).toBe(15); - }); - - test("actors with keys match actors with no keys", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - resolve(__dirname, "../fixtures/apps/counter.ts"), - ); - - // Create a counter with no keys - const noKeysCounter = await client.counter.connect(); - await noKeysCounter.increment(25); - - // Get counter with keys - should create a new one - const keyedCounter = await client.counter.connect([ - "new-counter", - "prod", - ]); - const keyedCount = await keyedCounter.increment(0); - - // Should be a new counter, not the one created above - expect(keyedCount).toBe(0); - }); - - test("specifying different keys for connect and create results in the expected keys", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - resolve(__dirname, "../fixtures/apps/counter.ts"), - ); - - // Create a counter with specific create keys - const counter = await client.counter.connect(["key-test", "test"], { - create: { - key: ["key-test", "test", "1.0"], - }, - }); - await counter.increment(5); - - // Should match when searching with original search keys - const foundWithSearchKeys = await client.counter.connect([ - "key-test", - "test", - ]); - const countWithSearchKeys = await foundWithSearchKeys.increment(0); - expect(countWithSearchKeys).toBe(5); - - // Should also match when searching with any subset of the create keys - const foundWithExtraKeys = await client.counter.connect([ - "key-test", - "1.0", - ]); - const countWithExtraKeys = await foundWithExtraKeys.increment(0); - expect(countWithExtraKeys).toBe(5); - - // Create a new counter with just search keys but different create keys - const newCounter = await client.counter.connect(["secondary"], { - create: { - key: ["secondary", "low", "true"], - }, - }); - await newCounter.increment(10); - - // Should not find when searching with keys not in create keys - const notFound = await client.counter.connect(["secondary", "active"]); - const notFoundCount = await notFound.increment(0); - expect(notFoundCount).toBe(0); // New counter - }); - }); - - describe("Multiple Actor Instances", () => { - // TODO: This test is flakey https://github.com/rivet-gg/actor-core/issues/873 - //test("creates multiple actor instances of the same type", async (c) => { - // const { client } = await setupDriverTest(c, - // driverTestConfig, - // resolve(__dirname, "../fixtures/apps/counter.ts"), - // ); - // - // // Create multiple instances with different IDs - // const instance1 = await client.counter.connect(["multi-1"]); - // const instance2 = await client.counter.connect(["multi-2"]); - // const instance3 = await client.counter.connect(["multi-3"]); - // - // // Set different states - // await instance1.increment(1); - // await instance2.increment(2); - // await instance3.increment(3); - // - // // Retrieve all instances again - // const retrieved1 = await client.counter.connect(["multi-1"]); - // const retrieved2 = await client.counter.connect(["multi-2"]); - // const retrieved3 = await client.counter.connect(["multi-3"]); - // - // // Verify separate state - // expect(await retrieved1.increment(0)).toBe(1); - // expect(await retrieved2.increment(0)).toBe(2); - // expect(await retrieved3.increment(0)).toBe(3); - //}); - - test("handles default instance with no explicit ID", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - resolve(__dirname, "../fixtures/apps/counter.ts"), - ); - - // Get default instance (no ID specified) - const defaultCounter = await client.counter.connect(); - - // Set state - await defaultCounter.increment(5); - - // Get default instance again - const sameDefaultCounter = await client.counter.connect(); - - // Verify state is maintained - const count = await sameDefaultCounter.increment(0); - expect(count).toBe(5); - }); - }); - }); -} diff --git a/packages/misc/driver-test-suite/tsconfig.json b/packages/misc/driver-test-suite/tsconfig.json deleted file mode 100644 index accb9677a..000000000 --- a/packages/misc/driver-test-suite/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src/**/*"] -} diff --git a/packages/misc/driver-test-suite/tsup.config.bundled_6lmockkaxzl.mjs b/packages/misc/driver-test-suite/tsup.config.bundled_6lmockkaxzl.mjs deleted file mode 100644 index 103df5517..000000000 --- a/packages/misc/driver-test-suite/tsup.config.bundled_6lmockkaxzl.mjs +++ /dev/null @@ -1,22 +0,0 @@ -// ../../../tsup.base.ts -var tsup_base_default = { - target: "node16", - platform: "node", - format: ["cjs", "esm"], - sourcemap: true, - clean: true, - dts: true, - minify: false, - // IMPORTANT: Splitting is required to fix a bug with ESM (https://github.com/egoist/tsup/issues/992#issuecomment-1763540165) - splitting: true, - skipNodeModulesBundle: true, - publicDir: true -}; - -// tsup.config.ts -import { defineConfig } from "tsup"; -var tsup_config_default = defineConfig(tsup_base_default); -export { - tsup_config_default as default -}; -//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiLi4vLi4vLi4vdHN1cC5iYXNlLnRzIiwgInRzdXAuY29uZmlnLnRzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJjb25zdCBfX2luamVjdGVkX2ZpbGVuYW1lX18gPSBcIi9Vc2Vycy9uYXRoYW4vcml2ZXQvYWN0b3ItY29yZS90c3VwLmJhc2UudHNcIjtjb25zdCBfX2luamVjdGVkX2Rpcm5hbWVfXyA9IFwiL1VzZXJzL25hdGhhbi9yaXZldC9hY3Rvci1jb3JlXCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9Vc2Vycy9uYXRoYW4vcml2ZXQvYWN0b3ItY29yZS90c3VwLmJhc2UudHNcIjtpbXBvcnQgdHlwZSB7IE9wdGlvbnMgfSBmcm9tIFwidHN1cFwiO1xuXG5leHBvcnQgZGVmYXVsdCB7XG5cdHRhcmdldDogXCJub2RlMTZcIixcblx0cGxhdGZvcm06IFwibm9kZVwiLFxuXHRmb3JtYXQ6IFtcImNqc1wiLCBcImVzbVwiXSxcblx0c291cmNlbWFwOiB0cnVlLFxuXHRjbGVhbjogdHJ1ZSxcblx0ZHRzOiB0cnVlLFxuXHRtaW5pZnk6IGZhbHNlLFxuXHQvLyBJTVBPUlRBTlQ6IFNwbGl0dGluZyBpcyByZXF1aXJlZCB0byBmaXggYSBidWcgd2l0aCBFU00gKGh0dHBzOi8vZ2l0aHViLmNvbS9lZ29pc3QvdHN1cC9pc3N1ZXMvOTkyI2lzc3VlY29tbWVudC0xNzYzNTQwMTY1KVxuXHRzcGxpdHRpbmc6IHRydWUsXG5cdHNraXBOb2RlTW9kdWxlc0J1bmRsZTogdHJ1ZSxcblx0cHVibGljRGlyOiB0cnVlLFxufSBzYXRpc2ZpZXMgT3B0aW9ucztcbiIsICJjb25zdCBfX2luamVjdGVkX2ZpbGVuYW1lX18gPSBcIi9Vc2Vycy9uYXRoYW4vcml2ZXQvYWN0b3ItY29yZS9wYWNrYWdlcy9kcml2ZXJzL3JlZGlzL3RzdXAuY29uZmlnLnRzXCI7Y29uc3QgX19pbmplY3RlZF9kaXJuYW1lX18gPSBcIi9Vc2Vycy9uYXRoYW4vcml2ZXQvYWN0b3ItY29yZS9wYWNrYWdlcy9kcml2ZXJzL3JlZGlzXCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9Vc2Vycy9uYXRoYW4vcml2ZXQvYWN0b3ItY29yZS9wYWNrYWdlcy9kcml2ZXJzL3JlZGlzL3RzdXAuY29uZmlnLnRzXCI7aW1wb3J0IGRlZmF1bHRDb25maWcgZnJvbSBcIi4uLy4uLy4uL3RzdXAuYmFzZS50c1wiO1xuaW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSBcInRzdXBcIjtcblxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKGRlZmF1bHRDb25maWcpO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUVBLElBQU8sb0JBQVE7QUFBQSxFQUNkLFFBQVE7QUFBQSxFQUNSLFVBQVU7QUFBQSxFQUNWLFFBQVEsQ0FBQyxPQUFPLEtBQUs7QUFBQSxFQUNyQixXQUFXO0FBQUEsRUFDWCxPQUFPO0FBQUEsRUFDUCxLQUFLO0FBQUEsRUFDTCxRQUFRO0FBQUE7QUFBQSxFQUVSLFdBQVc7QUFBQSxFQUNYLHVCQUF1QjtBQUFBLEVBQ3ZCLFdBQVc7QUFDWjs7O0FDYkEsU0FBUyxvQkFBb0I7QUFFN0IsSUFBTyxzQkFBUSxhQUFhLGlCQUFhOyIsCiAgIm5hbWVzIjogW10KfQo= diff --git a/packages/misc/driver-test-suite/tsup.config.ts b/packages/misc/driver-test-suite/tsup.config.ts deleted file mode 100644 index 677cffb7b..000000000 --- a/packages/misc/driver-test-suite/tsup.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import defaultConfig from "../../../tsup.base.ts"; -import { defineConfig } from "tsup"; - -export default defineConfig(defaultConfig); diff --git a/packages/misc/driver-test-suite/turbo.json b/packages/misc/driver-test-suite/turbo.json deleted file mode 100644 index 95960709b..000000000 --- a/packages/misc/driver-test-suite/turbo.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://turbo.build/schema.json", - "extends": ["//"] -} diff --git a/packages/misc/driver-test-suite/vitest.config.ts b/packages/misc/driver-test-suite/vitest.config.ts deleted file mode 100644 index 3e3d05481..000000000 --- a/packages/misc/driver-test-suite/vitest.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import defaultConfig from "../../../vitest.base.ts"; -import { defineConfig } from "vitest/config"; -import { resolve } from "path"; - -export default defineConfig({ - ...defaultConfig, - resolve: { - alias: { - "@": resolve(__dirname, "./src"), - }, - }, -}); - diff --git a/packages/platforms/cloudflare-workers/package.json b/packages/platforms/cloudflare-workers/package.json index 28ae3b59b..0b2b8b507 100644 --- a/packages/platforms/cloudflare-workers/package.json +++ b/packages/platforms/cloudflare-workers/package.json @@ -30,7 +30,6 @@ "actor-core": "*" }, "devDependencies": { - "@actor-core/driver-test-suite": "workspace:*", "@cloudflare/workers-types": "^4.20250129.0", "@types/invariant": "^2", "actor-core": "workspace:*", diff --git a/packages/platforms/cloudflare-workers/src/actor_driver.ts b/packages/platforms/cloudflare-workers/src/actor-driver.ts similarity index 100% rename from packages/platforms/cloudflare-workers/src/actor_driver.ts rename to packages/platforms/cloudflare-workers/src/actor-driver.ts diff --git a/packages/platforms/cloudflare-workers/src/actor_handler_do.ts b/packages/platforms/cloudflare-workers/src/actor-handler-do.ts similarity index 97% rename from packages/platforms/cloudflare-workers/src/actor_handler_do.ts rename to packages/platforms/cloudflare-workers/src/actor-handler-do.ts index e4bf1dac7..647fa9f04 100644 --- a/packages/platforms/cloudflare-workers/src/actor_handler_do.ts +++ b/packages/platforms/cloudflare-workers/src/actor-handler-do.ts @@ -6,7 +6,7 @@ import { PartitionTopologyActor } from "actor-core/topologies/partition"; import { CloudflareDurableObjectGlobalState, CloudflareWorkersActorDriver, -} from "./actor_driver"; +} from "./actor-driver"; import { upgradeWebSocket } from "./websocket"; const KEYS = { @@ -101,8 +101,6 @@ export function createActorDurableObject( if (!config.drivers.actor) { config.drivers.actor = new CloudflareWorkersActorDriver(globalState); } - if (!config.getUpgradeWebSocket) - config.getUpgradeWebSocket = () => upgradeWebSocket; const actorTopology = new PartitionTopologyActor(app.config, config); // Register DO with global state diff --git a/packages/platforms/cloudflare-workers/src/handler.ts b/packages/platforms/cloudflare-workers/src/handler.ts index 417ef4470..a40c16c02 100644 --- a/packages/platforms/cloudflare-workers/src/handler.ts +++ b/packages/platforms/cloudflare-workers/src/handler.ts @@ -2,14 +2,15 @@ import { type DurableObjectConstructor, type ActorHandlerInterface, createActorDurableObject, -} from "./actor_handler_do"; +} from "./actor-handler-do"; import { ConfigSchema, type InputConfig } from "./config"; import { assertUnreachable } from "actor-core/utils"; import type { Hono } from "hono"; import { PartitionTopologyManager } from "actor-core/topologies/partition"; import { logger } from "./log"; -import { CloudflareWorkersManagerDriver } from "./manager_driver"; +import { CloudflareWorkersManagerDriver } from "./manager-driver"; import { ActorCoreApp } from "actor-core"; +import { upgradeWebSocket } from "./websocket"; /** Cloudflare Workers env */ export interface Bindings { @@ -17,7 +18,10 @@ export interface Bindings { ACTOR_DO: DurableObjectNamespace; } -export function createHandler(app: ActorCoreApp, inputConfig?: InputConfig): { +export function createHandler( + app: ActorCoreApp, + inputConfig?: InputConfig, +): { handler: ExportedHandler; ActorHandler: DurableObjectConstructor; } { @@ -48,34 +52,61 @@ export function createRouter( if (!driverConfig.drivers.manager) driverConfig.drivers.manager = new CloudflareWorkersManagerDriver(); + // Setup WebSockets + if (!driverConfig.getUpgradeWebSocket) + driverConfig.getUpgradeWebSocket = () => upgradeWebSocket; + // Create Durable Object const ActorHandler = createActorDurableObject(app, driverConfig); driverConfig.topology = driverConfig.topology ?? "partition"; if (driverConfig.topology === "partition") { - const managerTopology = new PartitionTopologyManager(app.config, driverConfig); + const managerTopology = new PartitionTopologyManager( + app.config, + driverConfig, + { + onProxyRequest: async (c, actorRequest, actorId): Promise => { + logger().debug("forwarding request to durable object", { + actorId, + method: actorRequest.method, + url: actorRequest.url, + }); - // Force the router to have access to the Cloudflare bindings - const router = managerTopology.router as unknown as Hono<{ - Bindings: Bindings; - }>; + const id = c.env.ACTOR_DO.idFromString(actorId); + const stub = c.env.ACTOR_DO.get(id); + + return await stub.fetch(actorRequest); + }, + onProxyWebSocket: async (c, path, actorId) => { + logger().debug("forwarding websocket to durable object", { + actorId, + path, + }); - // Forward requests to actor - router.all("/actors/:actorId/:path{.+}", (c) => { - const actorId = c.req.param("actorId"); - const subpath = `/${c.req.param("path")}`; - logger().debug("forwarding request", { actorId, subpath }); + // Validate upgrade + const upgradeHeader = c.req.header("Upgrade"); + if (!upgradeHeader || upgradeHeader !== "websocket") { + return new Response("Expected Upgrade: websocket", { + status: 426, + }); + } - const id = c.env.ACTOR_DO.idFromString(actorId); - const stub = c.env.ACTOR_DO.get(id); + // Update path on URL + const newUrl = new URL(`http://actor${path}`); + const actorRequest = new Request(newUrl, c.req.raw); - // Modify the path to remove the prefix - const url = new URL(c.req.url); - url.pathname = subpath; - const actorRequest = new Request(url.toString(), c.req.raw); + const id = c.env.ACTOR_DO.idFromString(actorId); + const stub = c.env.ACTOR_DO.get(id); - return stub.fetch(actorRequest); - }); + return await stub.fetch(actorRequest); + }, + }, + ); + + // Force the router to have access to the Cloudflare bindings + const router = managerTopology.router as unknown as Hono<{ + Bindings: Bindings; + }>; return { router, ActorHandler }; } else if ( diff --git a/packages/platforms/cloudflare-workers/src/manager_driver.ts b/packages/platforms/cloudflare-workers/src/manager-driver.ts similarity index 86% rename from packages/platforms/cloudflare-workers/src/manager_driver.ts rename to packages/platforms/cloudflare-workers/src/manager-driver.ts index 0de27610c..afc2ed3db 100644 --- a/packages/platforms/cloudflare-workers/src/manager_driver.ts +++ b/packages/platforms/cloudflare-workers/src/manager-driver.ts @@ -5,6 +5,7 @@ import type { CreateActorInput, GetActorOutput, } from "actor-core/driver-helpers"; +import { ActorAlreadyExists } from "actor-core/errors"; import { Bindings } from "./mod"; import { logger } from "./log"; import { serializeNameAndKey, serializeKey } from "./util"; @@ -37,7 +38,6 @@ const KEYS = { export class CloudflareWorkersManagerDriver implements ManagerDriver { async getForId({ c, - baseUrl, actorId, }: GetForIdInput<{ Bindings: Bindings }>): Promise< GetActorOutput | undefined @@ -54,16 +54,19 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { return undefined; } + // Generate durable ID from actorId for meta + const durableId = c.env.ACTOR_DO.idFromString(actorId); + return { - endpoint: buildActorEndpoint(baseUrl, actorId), + actorId, name: actorData.name, key: actorData.key, + meta: durableId, }; } async getWithKey({ c, - baseUrl, name, key, }: GetWithKeyInput<{ Bindings: Bindings }>): Promise< @@ -99,19 +102,23 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { name, key, }); - return this.#buildActorOutput(c, baseUrl, actorId); + return this.#buildActorOutput(c, actorId); } async createActor({ c, - baseUrl, name, key, - region, }: CreateActorInput<{ Bindings: Bindings }>): Promise { if (!c) throw new Error("Missing Hono context"); const log = logger(); + // Check if actor with the same name and key already exists + const existingActor = await this.getWithKey({ c, name, key }); + if (existingActor) { + throw new ActorAlreadyExists(name, key); + } + // Create a deterministic ID from the actor name and key // This ensures that actors with the same name and key will have the same ID const nameKeyString = serializeNameAndKey(name, key); @@ -136,16 +143,16 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { await c.env.ACTOR_KV.put(KEYS.ACTOR.keyIndex(name, key), actorId); return { - endpoint: buildActorEndpoint(baseUrl, actorId), + actorId, name, key, + meta: durableId, }; } // Helper method to build actor output from an ID async #buildActorOutput( c: any, - baseUrl: string, actorId: string, ): Promise { const actorData = (await c.env.ACTOR_KV.get(KEYS.ACTOR.metadata(actorId), { @@ -156,14 +163,14 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { return undefined; } + // Generate durable ID for meta + const durableId = c.env.ACTOR_DO.idFromString(actorId); + return { - endpoint: buildActorEndpoint(baseUrl, actorId), + actorId, name: actorData.name, key: actorData.key, + meta: durableId, }; } } - -function buildActorEndpoint(baseUrl: string, actorId: string) { - return `${baseUrl}/actors/${actorId}`; -} diff --git a/packages/platforms/cloudflare-workers/tests/driver-tests.test.ts b/packages/platforms/cloudflare-workers/tests/driver-tests.test.ts index 93b718933..1341b44c3 100644 --- a/packages/platforms/cloudflare-workers/tests/driver-tests.test.ts +++ b/packages/platforms/cloudflare-workers/tests/driver-tests.test.ts @@ -1,4 +1,4 @@ -import { runDriverTests } from "@actor-core/driver-test-suite"; +import { runDriverTests } from "actor-core/driver-test-suite"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; @@ -129,6 +129,7 @@ export { handler as default, ActorHandler }; wranglerProcess.stdout?.on("data", (data) => { const output = data.toString(); + console.log(`wrangler: ${output}`); if (output.includes(`Ready on http://localhost:${port}`)) { if (!isResolved) { isResolved = true; @@ -139,7 +140,7 @@ export { handler as default, ActorHandler }; }); wranglerProcess.stderr?.on("data", (data) => { - console.error(`wrangler error: ${data}`); + console.error(`wrangler: ${data}`); }); wranglerProcess.on("error", (error) => { diff --git a/packages/platforms/cloudflare-workers/tests/id-generation.test.ts b/packages/platforms/cloudflare-workers/tests/id-generation.test.ts index 77b14632e..00550f851 100644 --- a/packages/platforms/cloudflare-workers/tests/id-generation.test.ts +++ b/packages/platforms/cloudflare-workers/tests/id-generation.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, vi } from "vitest"; import { serializeNameAndKey } from "../src/util"; -import { CloudflareWorkersManagerDriver } from "../src/manager_driver"; +import { CloudflareWorkersManagerDriver } from "../src/manager-driver"; describe("Deterministic ID generation", () => { test("should generate consistent IDs for the same name and key", () => { diff --git a/packages/platforms/rivet/.actorcore/entrypoint-counter.js b/packages/platforms/rivet/.actorcore/entrypoint-counter.js new file mode 100644 index 000000000..f6ace2d0c --- /dev/null +++ b/packages/platforms/rivet/.actorcore/entrypoint-counter.js @@ -0,0 +1,3 @@ +import { createActorHandler } from "@actor-core/rivet"; +import { app } from "../tmp/actor-core-test-fa4426da-4d36-40b3-aa42-7e8f444d32f6/app.ts"; +export default createActorHandler({ app }); \ No newline at end of file diff --git a/packages/platforms/rivet/package.json b/packages/platforms/rivet/package.json index d59a54eb8..8ea0111f5 100644 --- a/packages/platforms/rivet/package.json +++ b/packages/platforms/rivet/package.json @@ -30,9 +30,9 @@ "actor-core": "*" }, "devDependencies": { - "@actor-core/driver-test-suite": "workspace:*", "@rivet-gg/actor-core": "^25.1.0", "@types/deno": "^2.0.0", + "@types/invariant": "^2", "@types/node": "^22.13.1", "actor-core": "workspace:*", "tsup": "^8.4.0", @@ -41,6 +41,7 @@ }, "dependencies": { "hono": "^4.7.0", + "invariant": "^2.2.4", "zod": "^3.24.2" } } diff --git a/packages/platforms/rivet/src/actor_driver.ts b/packages/platforms/rivet/src/actor-driver.ts similarity index 81% rename from packages/platforms/rivet/src/actor_driver.ts rename to packages/platforms/rivet/src/actor-driver.ts index 19d02ae55..71409b6c0 100644 --- a/packages/platforms/rivet/src/actor_driver.ts +++ b/packages/platforms/rivet/src/actor-driver.ts @@ -17,8 +17,12 @@ export class RivetActorDriver implements ActorDriver { } async readPersistedData(_actorId: string): Promise { - // Use "state" as the key for persisted data - return await this.#ctx.kv.get(["actor-core", "data"]); + let data = await this.#ctx.kv.get(["actor-core", "data"]); + + // HACK: Modify to be undefined if null. This will be fixed in Actors v2. + if (data === null) data = undefined; + + return data; } async writePersistedData(_actorId: string, data: unknown): Promise { diff --git a/packages/platforms/rivet/src/actor_handler.ts b/packages/platforms/rivet/src/actor-handler.ts similarity index 90% rename from packages/platforms/rivet/src/actor_handler.ts rename to packages/platforms/rivet/src/actor-handler.ts index 5ebba7fec..e42ca51f3 100644 --- a/packages/platforms/rivet/src/actor_handler.ts +++ b/packages/platforms/rivet/src/actor-handler.ts @@ -1,13 +1,14 @@ import { setupLogging } from "actor-core/log"; import type { ActorContext } from "@rivet-gg/actor-core"; -import type { ActorKey } from "actor-core"; import { upgradeWebSocket } from "hono/deno"; import { logger } from "./log"; import type { RivetHandler } from "./util"; +import { deserializeKeyFromTag } from "./util"; import { PartitionTopologyActor } from "actor-core/topologies/partition"; import { ConfigSchema, type InputConfig } from "./config"; -import { RivetActorDriver } from "./actor_driver"; -import { rivetRequest } from "./rivet_client"; +import { RivetActorDriver } from "./actor-driver"; +import { rivetRequest } from "./rivet-client"; +import invariant from "invariant"; export function createActorHandler(inputConfig: InputConfig): RivetHandler { const driverConfig = ConfigSchema.parse(inputConfig); @@ -135,17 +136,6 @@ export function createActorHandler(inputConfig: InputConfig): RivetHandler { // Helper function to extract key array from Rivet's tag format function extractKeyFromRivetTags(tags: Record): string[] { - const key: string[] = []; - - // Extract key values from tags using the numerical suffix pattern - for (let i = 0; ; i++) { - const tagKey = `key${i}`; - if (tagKey in tags) { - key.push(tags[tagKey]); - } else { - break; - } - } - - return key; -} \ No newline at end of file + invariant(typeof tags.key === "string", "key tag does not exist"); + return deserializeKeyFromTag(tags.key); +} diff --git a/packages/platforms/rivet/src/manager_driver.ts b/packages/platforms/rivet/src/manager-driver.ts similarity index 66% rename from packages/platforms/rivet/src/manager_driver.ts rename to packages/platforms/rivet/src/manager-driver.ts index 76efabb96..21dffe607 100644 --- a/packages/platforms/rivet/src/manager_driver.ts +++ b/packages/platforms/rivet/src/manager-driver.ts @@ -1,9 +1,25 @@ +import { assertUnreachable } from "actor-core/utils"; +import { ActorAlreadyExists } from "actor-core/errors"; +import type { + ManagerDriver, + GetForIdInput, + GetWithKeyInput, + CreateActorInput, + GetActorOutput, +} from "actor-core/driver-helpers"; +import { logger } from "./log"; +import { type RivetClientConfig, rivetRequest } from "./rivet-client"; +import { serializeKeyForTag, deserializeKeyFromTag } from "./util"; export interface ActorState { key: string[]; destroyedAt?: number; } +export interface GetActorMeta { + endpoint: string; +} + export class RivetManagerDriver implements ManagerDriver { #clientConfig: RivetClientConfig; @@ -22,8 +38,8 @@ export class RivetManagerDriver implements ManagerDriver { `/actors/${encodeURIComponent(actorId)}`, ); - // Check if actor exists, is public, and not destroyed - if ((res.actor.tags as Record).access !== "public" || res.actor.destroyedAt) { + // Check if actor exists and not destroyed + if (res.actor.destroyedAt) { return undefined; } @@ -31,11 +47,20 @@ export class RivetManagerDriver implements ManagerDriver { if (!("name" in res.actor.tags)) { throw new Error(`Actor ${res.actor.id} missing 'name' in tags.`); } + if (res.actor.tags.role !== "actor") { + throw new Error(`Actor ${res.actor.id} does not have an actor role.`); + } + if (res.actor.tags.framework !== "actor-core") { + throw new Error(`Actor ${res.actor.id} is not an ActorCore actor.`); + } return { - endpoint: buildActorEndpoint(res.actor), + actorId: res.actor.id, name: res.actor.tags.name, key: this.#extractKeyFromRivetTags(res.actor.tags), + meta: { + endpoint: buildActorEndpoint(res.actor), + } satisfies GetActorMeta, }; } catch (error) { // Handle not found or other errors @@ -49,7 +74,7 @@ export class RivetManagerDriver implements ManagerDriver { }: GetWithKeyInput): Promise { // Convert key array to Rivet's tag format const rivetTags = this.#convertKeyToRivetTags(name, key); - + // Query actors with matching tags const { actors } = await rivetRequest( this.#clientConfig, @@ -59,11 +84,6 @@ export class RivetManagerDriver implements ManagerDriver { // Filter actors to ensure they're valid const validActors = actors.filter((a: RivetActor) => { - // Verify actor is public - if ((a.tags as Record).access !== "public") { - return false; - } - // Verify all ports have hostname and port for (const portName in a.network.ports) { const port = a.network.ports[portName]; @@ -77,9 +97,10 @@ export class RivetManagerDriver implements ManagerDriver { } // For consistent results, sort by ID if multiple actors match - const actor = validActors.length > 1 - ? validActors.sort((a, b) => a.id.localeCompare(b.id))[0] - : validActors[0]; + const actor = + validActors.length > 1 + ? validActors.sort((a, b) => a.id.localeCompare(b.id))[0] + : validActors[0]; // Ensure actor has required tags if (!("name" in actor.tags)) { @@ -87,9 +108,12 @@ export class RivetManagerDriver implements ManagerDriver { } return { - endpoint: buildActorEndpoint(actor), + actorId: actor.id, name: actor.tags.name, key: this.#extractKeyFromRivetTags(actor.tags), + meta: { + endpoint: buildActorEndpoint(actor), + } satisfies GetActorMeta, }; } @@ -98,21 +122,29 @@ export class RivetManagerDriver implements ManagerDriver { key, region, }: CreateActorInput): Promise { - // Find a matching build that's public and current - const build = await this.#getBuildWithTags({ - name, - current: "true", - access: "public", - }); - - if (!build) { - throw new Error("Build not found with tags or is private"); + // Check if actor with the same name and key already exists + const existingActor = await this.getWithKey({ name, key }); + if (existingActor) { + throw new ActorAlreadyExists(name, key); } // Create the actor request + let actorLogLevel: string | undefined = undefined; + if (typeof Deno !== "undefined") { + actorLogLevel = Deno.env.get("_ACTOR_LOG_LEVEL"); + } else if (typeof process !== "undefined") { + // Do this after Deno since `process` is sometimes polyfilled + actorLogLevel = process.env._ACTOR_LOG_LEVEL; + } + const createRequest = { tags: this.#convertKeyToRivetTags(name, key), - build: build.id, + build_tags: { + name, + role: "actor", + framework: "actor-core", + current: "true", + }, region, network: { ports: { @@ -122,22 +154,33 @@ export class RivetManagerDriver implements ManagerDriver { }, }, }, + runtime: { + environment: actorLogLevel + ? { + _LOG_LEVEL: actorLogLevel, + } + : {}, + }, + lifecycle: { + durable: true, + }, }; logger().info("creating actor", { ...createRequest }); - + // Create the actor - const { actor } = await rivetRequest( - this.#clientConfig, - "POST", - "/actors", - createRequest, - ); + const { actor } = await rivetRequest< + typeof createRequest, + { actor: RivetActor } + >(this.#clientConfig, "POST", "/actors", createRequest); return { - endpoint: buildActorEndpoint(actor), + actorId: actor.id, name, key: this.#extractKeyFromRivetTags(actor.tags), + meta: { + endpoint: buildActorEndpoint(actor), + } satisfies GetActorMeta, }; } @@ -145,11 +188,12 @@ export class RivetManagerDriver implements ManagerDriver { #convertKeyToRivetTags(name: string, key: string[]): Record { return { name, - access: "public", key: serializeKeyForTag(key), + role: "actor", + framework: "actor-core", }; } - + // Helper method to extract key array from Rivet's tag-based format #extractKeyFromRivetTags(tags: Record): string[] { return deserializeKeyFromTag(tags.key); @@ -165,17 +209,14 @@ export class RivetManagerDriver implements ManagerDriver { `/builds?tags_json=${encodeURIComponent(JSON.stringify(buildTags))}`, ); - // Filter to public builds - const publicBuilds = builds.filter(b => b.tags.access === "public"); - - if (publicBuilds.length === 0) { + if (builds.length === 0) { return undefined; } - + // For consistent results, sort by ID if multiple builds match - return publicBuilds.length > 1 - ? publicBuilds.sort((a, b) => a.id.localeCompare(b.id))[0] - : publicBuilds[0]; + return builds.length > 1 + ? builds.sort((a, b) => a.id.localeCompare(b.id))[0] + : builds[0]; } } @@ -183,7 +224,7 @@ function buildActorEndpoint(actor: RivetActor): string { // Fetch port const httpPort = actor.network.ports.http; if (!httpPort) throw new Error("missing http port"); - const hostname = httpPort.hostname; + let hostname = httpPort.hostname; if (!hostname) throw new Error("missing hostname"); const port = httpPort.port; if (!port) throw new Error("missing port"); @@ -206,23 +247,13 @@ function buildActorEndpoint(actor: RivetActor): string { const path = httpPort.path ?? ""; + // HACK: Fix hostname inside of Docker Compose + if (hostname === "127.0.0.1") hostname = "rivet-guard"; + return `${isTls ? "https" : "http"}://${hostname}:${port}${path}`; } -import { assertUnreachable } from "actor-core/utils"; -import type { ActorKey } from "actor-core"; -import { - ManagerDriver, - GetForIdInput, - GetWithKeyInput, - CreateActorInput, - GetActorOutput, -} from "actor-core/driver-helpers"; -import { logger } from "./log"; -import { type RivetClientConfig, rivetRequest } from "./rivet_client"; -import { serializeKeyForTag, deserializeKeyFromTag } from "./util"; - // biome-ignore lint/suspicious/noExplicitAny: will add api types later type RivetActor = any; // biome-ignore lint/suspicious/noExplicitAny: will add api types later -type RivetBuild = any; \ No newline at end of file +type RivetBuild = any; diff --git a/packages/platforms/rivet/src/manager_handler.ts b/packages/platforms/rivet/src/manager-handler.ts similarity index 64% rename from packages/platforms/rivet/src/manager_handler.ts rename to packages/platforms/rivet/src/manager-handler.ts index 984c3912e..eb3d40e4a 100644 --- a/packages/platforms/rivet/src/manager_handler.ts +++ b/packages/platforms/rivet/src/manager-handler.ts @@ -1,11 +1,15 @@ import { setupLogging } from "actor-core/log"; import type { ActorContext } from "@rivet-gg/actor-core"; import { logger } from "./log"; -import { RivetManagerDriver } from "./manager_driver"; -import type { RivetClientConfig } from "./rivet_client"; +import { GetActorMeta, RivetManagerDriver } from "./manager-driver"; +import type { RivetClientConfig } from "./rivet-client"; import type { RivetHandler } from "./util"; +import { createWebSocketProxy } from "./ws-proxy"; import { PartitionTopologyManager } from "actor-core/topologies/partition"; import { type InputConfig, ConfigSchema } from "./config"; +import { proxy } from "hono/proxy"; +import invariant from "invariant"; +import { upgradeWebSocket } from "hono/deno"; export function createManagerHandler(inputConfig: InputConfig): RivetHandler { const driverConfig = ConfigSchema.parse(inputConfig); @@ -69,11 +73,46 @@ export function createManagerHandler(inputConfig: InputConfig): RivetHandler { driverConfig.drivers.manager = new RivetManagerDriver(clientConfig); } + // Setup WebSocket upgrader + if (!driverConfig.getUpgradeWebSocket) { + driverConfig.getUpgradeWebSocket = () => upgradeWebSocket; + } + // Create manager topology driverConfig.topology = driverConfig.topology ?? "partition"; const managerTopology = new PartitionTopologyManager( driverConfig.app.config, driverConfig, + { + onProxyRequest: async (c, actorRequest, _actorId, metaRaw) => { + invariant(metaRaw, "meta not provided"); + const meta = metaRaw as GetActorMeta; + + const parsedRequestUrl = new URL(actorRequest.url); + const actorUrl = `${meta.endpoint}${parsedRequestUrl.pathname}${parsedRequestUrl.search}`; + + logger().debug("proxying request to rivet actor", { + method: actorRequest.method, + url: actorUrl, + }); + + const proxyRequest = new Request(actorUrl, actorRequest); + return await proxy(proxyRequest); + }, + onProxyWebSocket: async (c, path, actorId, metaRaw) => { + invariant(metaRaw, "meta not provided"); + const meta = metaRaw as GetActorMeta; + + const actorUrl = `${meta.endpoint}${path}`; + + logger().debug("proxying websocket to rivet actor", { + url: actorUrl, + }); + + // TODO: fix as any + return createWebSocketProxy(c, actorUrl) as any; + }, + }, ); const app = managerTopology.router; diff --git a/packages/platforms/rivet/src/mod.ts b/packages/platforms/rivet/src/mod.ts index 5f101b11f..3d2d3febc 100644 --- a/packages/platforms/rivet/src/mod.ts +++ b/packages/platforms/rivet/src/mod.ts @@ -1,3 +1,3 @@ -export { createActorHandler } from "./actor_handler"; -export { createManagerHandler } from "./manager_handler"; +export { createActorHandler } from "./actor-handler"; +export { createManagerHandler } from "./manager-handler"; export type { InputConfig as Config } from "./config"; diff --git a/packages/platforms/rivet/src/rivet_client.ts b/packages/platforms/rivet/src/rivet-client.ts similarity index 92% rename from packages/platforms/rivet/src/rivet_client.ts rename to packages/platforms/rivet/src/rivet-client.ts index 188a9f5bb..073ee2870 100644 --- a/packages/platforms/rivet/src/rivet_client.ts +++ b/packages/platforms/rivet/src/rivet-client.ts @@ -1,3 +1,5 @@ +import { httpUserAgent } from "actor-core/utils"; + export interface RivetClientConfig { endpoint: string; token: string; @@ -23,6 +25,7 @@ export async function rivetRequest( method, headers: { "Content-Type": "application/json", + "User-Agent": httpUserAgent(), Authorization: `Bearer ${config.token}`, }, body: body ? JSON.stringify(body) : undefined, diff --git a/packages/platforms/rivet/src/util.ts b/packages/platforms/rivet/src/util.ts index 6f3cd08da..5dda225c6 100644 --- a/packages/platforms/rivet/src/util.ts +++ b/packages/platforms/rivet/src/util.ts @@ -1,4 +1,5 @@ import type { ActorContext } from "@rivet-gg/actor-core"; +import invariant from "invariant"; export interface RivetHandler { start(ctx: ActorContext): Promise; @@ -10,7 +11,7 @@ export const KEY_SEPARATOR = ","; /** * Serializes an array of key strings into a single string for storage in a Rivet tag - * + * * @param key Array of key strings to serialize * @returns A single string containing the serialized key */ @@ -19,69 +20,64 @@ export function serializeKeyForTag(key: string[]): string { if (key.length === 0) { return EMPTY_KEY; } - + // Escape each key part to handle the separator and the empty key marker - const escapedParts = key.map(part => { + const escapedParts = key.map((part) => { // First check if it matches our empty key marker if (part === EMPTY_KEY) { return `\\${EMPTY_KEY}`; } - + // Escape backslashes first, then commas let escaped = part.replace(/\\/g, "\\\\"); escaped = escaped.replace(/,/g, "\\,"); return escaped; }); - + return escapedParts.join(KEY_SEPARATOR); } /** * Deserializes a key string from a Rivet tag back into an array of key strings - * + * * @param keyString The serialized key string from a tag * @returns Array of key strings */ export function deserializeKeyFromTag(keyString: string): string[] { - // Handle empty values - if (!keyString) { - return []; - } - // Check for special empty key marker if (keyString === EMPTY_KEY) { return []; } - + // Split by unescaped commas and unescape the escaped characters const parts: string[] = []; - let currentPart = ''; + let currentPart = ""; let escaping = false; - + for (let i = 0; i < keyString.length; i++) { const char = keyString[i]; - + if (escaping) { // This is an escaped character, add it directly currentPart += char; escaping = false; - } else if (char === '\\') { + } else if (char === "\\") { // Start of an escape sequence escaping = true; } else if (char === KEY_SEPARATOR) { // This is a separator parts.push(currentPart); - currentPart = ''; + currentPart = ""; } else { // Regular character currentPart += char; } } - + // Add the last part if it exists if (currentPart || parts.length > 0) { parts.push(currentPart); } - + return parts; } diff --git a/packages/platforms/rivet/src/ws-proxy.ts b/packages/platforms/rivet/src/ws-proxy.ts new file mode 100644 index 000000000..8bdbeb157 --- /dev/null +++ b/packages/platforms/rivet/src/ws-proxy.ts @@ -0,0 +1,119 @@ +import { upgradeWebSocket } from "hono/deno"; +import { WSContext } from "hono/ws"; +import { Context } from "hono"; +import { logger } from "./log"; +import invariant from "invariant"; + +/** + * Creates a WebSocket proxy to forward connections to a target endpoint + * + * @param c Hono context + * @param targetUrl Target WebSocket URL to proxy to + * @returns Response with upgraded WebSocket + */ +export function createWebSocketProxy(c: Context, targetUrl: string) { + return upgradeWebSocket((c) => { + let targetWs: WebSocket | undefined = undefined; + const messageQueue: any[] = []; + + return { + onOpen: (_evt: any, wsContext: WSContext) => { + // Create target WebSocket connection + targetWs = new WebSocket(targetUrl); + + // Set up target websocket handlers + targetWs.onopen = () => { + invariant(targetWs, "targetWs does not exist"); + + // Process any queued messages once connected + if (messageQueue.length > 0) { + for (const data of messageQueue) { + targetWs.send(data); + } + // Clear the queue after sending + messageQueue.length = 0; + } + }; + + targetWs.onmessage = (event) => { + wsContext.send(event.data); + }; + + targetWs.onclose = (event) => { + logger().debug("target websocket closed", { + code: event.code, + reason: event.reason, + }); + + if (wsContext.readyState === WebSocket.OPEN) { + // Forward the close code and reason from target to client + wsContext.close(event.code, event.reason); + } + }; + + targetWs.onerror = (event) => { + logger().warn("target websocket error"); + + if (wsContext.readyState === WebSocket.OPEN) { + // Use standard WebSocket error code: 1006 - Abnormal Closure + // The connection was closed abnormally, e.g., without sending or receiving a Close control frame + wsContext.close(1006, "Error in target connection"); + } + }; + }, + + // Handle messages from client to target + onMessage: (evt: { data: any }, wsContext: WSContext) => { + invariant(targetWs, "targetWs not defined"); + + // If the WebSocket is OPEN, send immediately + if (targetWs.readyState === WebSocket.OPEN) { + targetWs.send(evt.data); + } + // If the WebSocket is CONNECTING, queue the message + else if (targetWs.readyState === WebSocket.CONNECTING) { + messageQueue.push(evt.data); + } + // Otherwise (CLOSING or CLOSED), ignore the message + }, + + // Handle client WebSocket close + onClose: (evt: CloseEvent, wsContext: WSContext) => { + invariant(targetWs, "targetWs not defined"); + + logger().debug("client websocket closed", { + code: evt.code, + reason: evt.reason, + }); + + // Close target if it's either CONNECTING or OPEN + // + // We're only allowed to send code 1000 from the client + if ( + targetWs.readyState === WebSocket.CONNECTING || + targetWs.readyState === WebSocket.OPEN + ) { + // We can only send code 1000 from the client + targetWs.close(1000, evt.reason || "Client closed connection"); + } + }, + + // Handle client WebSocket errors + onError: (_evt: Event, wsContext: WSContext) => { + invariant(targetWs, "targetWs not defined"); + + logger().warn("websocket proxy received error from client"); + + // Close target with specific error code for proxy errors + // + // We're only allowed to send code 1000 from the client + if ( + targetWs.readyState === WebSocket.CONNECTING || + targetWs.readyState === WebSocket.OPEN + ) { + targetWs.close(1000, "Error in client connection"); + } + }, + }; + })(c, async () => {}); +} diff --git a/packages/platforms/rivet/tests/deployment.test.ts b/packages/platforms/rivet/tests/deployment.test.ts new file mode 100644 index 000000000..a49c84b76 --- /dev/null +++ b/packages/platforms/rivet/tests/deployment.test.ts @@ -0,0 +1,72 @@ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { deployToRivet } from "./rivet-deploy"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Simple counter actor definition to deploy +const COUNTER_ACTOR = ` +import { actor, setup } from "actor-core"; + +const counter = actor({ + state: { count: 0 }, + actions: { + increment: (c, amount) => { + c.state.count += amount; + c.broadcast("newCount", c.state.count); + return c.state.count; + }, + getCount: (c) => { + return c.state.count; + }, + }, +}); + +export const app = setup({ + actors: { counter }, +}); + +export type App = typeof app; +`; + +describe.skip("Rivet deployment tests", () => { + let tmpDir: string; + let cleanup: () => Promise; + + // Set up test environment before all tests + beforeAll(async () => { + // Create a temporary path for the counter actor + const tempFilePath = path.join( + __dirname, + "../../../..", + "target", + "temp-counter-app.ts", + ); + + // Ensure target directory exists + await fs.mkdir(path.dirname(tempFilePath), { recursive: true }); + + // Write the counter actor file + await fs.writeFile(tempFilePath, COUNTER_ACTOR); + + // Run the deployment + const result = await deployToRivet(tempFilePath); + tmpDir = result.tmpDir; + cleanup = result.cleanup; + }); + + // Clean up after all tests + afterAll(async () => { + if (cleanup) { + await cleanup(); + } + }); + + test("deploys counter actor to Rivet and retrieves endpoint", async () => { + // This test just verifies that the deployment was successful + // The actual deployment work is done in the beforeAll hook + expect(tmpDir).toBeTruthy(); + }, 180000); // Increased timeout to 3 minutes for the full deployment +}); diff --git a/packages/platforms/rivet/tests/driver-tests.test.ts b/packages/platforms/rivet/tests/driver-tests.test.ts new file mode 100644 index 000000000..46d925ca4 --- /dev/null +++ b/packages/platforms/rivet/tests/driver-tests.test.ts @@ -0,0 +1,73 @@ +import { runDriverTests } from "actor-core/driver-test-suite"; +import { deployToRivet, RIVET_CLIENT_CONFIG } from "./rivet-deploy"; +import { type RivetClientConfig, rivetRequest } from "../src/rivet_client"; +import invariant from "invariant"; + +let alreadyDeployedManager = false; +const alreadyDeployedApps = new Set(); +let managerEndpoint: string | undefined = undefined; + +const driverTestConfig = { + useRealTimers: true, + HACK_skipCleanupNet: true, + async start(appPath: string) { + console.log("Starting test", { + alreadyDeployedManager, + alreadyDeployedApps, + managerEndpoint, + }); + + // Cleanup actors from previous tests + await deleteAllActors(RIVET_CLIENT_CONFIG, !alreadyDeployedManager); + + if (!alreadyDeployedApps.has(appPath)) { + console.log(`Starting Rivet driver tests with app: ${appPath}`); + + // Deploy to Rivet + const result = await deployToRivet(appPath, !alreadyDeployedManager); + console.log( + `Deployed to Rivet at ${result.endpoint} (manager: ${!alreadyDeployedManager})`, + ); + + // Save as deployed + managerEndpoint = result.endpoint; + alreadyDeployedApps.add(appPath); + alreadyDeployedManager = true; + } else { + console.log(`Already deployed: ${appPath}`); + } + + invariant(managerEndpoint, "missing manager endpoint"); + return { + endpoint: managerEndpoint, + async cleanup() { + await deleteAllActors(RIVET_CLIENT_CONFIG, false); + }, + }; + }, +}; + +async function deleteAllActors( + clientConfig: RivetClientConfig, + deleteManager: boolean, +) { + console.log("Listing actors to delete"); + const { actors } = await rivetRequest< + void, + { actors: { id: string; tags: Record }[] } + >(clientConfig, "GET", "/actors"); + + for (const actor of actors) { + if (!deleteManager && actor.tags.name === "manager") continue; + + console.log(`Deleting actor ${actor.id} (${JSON.stringify(actor.tags)})`); + await rivetRequest( + clientConfig, + "DELETE", + `/actors/${actor.id}`, + ); + } +} + +// Run the driver tests with our config +runDriverTests(driverTestConfig); diff --git a/packages/platforms/rivet/tests/rivet-deploy.ts b/packages/platforms/rivet/tests/rivet-deploy.ts new file mode 100644 index 000000000..77f7e62b4 --- /dev/null +++ b/packages/platforms/rivet/tests/rivet-deploy.ts @@ -0,0 +1,201 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { spawn, exec } from "node:child_process"; +import crypto from "node:crypto"; +import { promisify } from "node:util"; +import invariant from "invariant"; +import type { RivetClientConfig } from "../src/rivet_client"; + +const execPromise = promisify(exec); +//const RIVET_API_ENDPOINT = "https://api.rivet.gg"; +const RIVET_API_ENDPOINT = "http://localhost:8080"; +const ENV = "default"; + +const rivetCloudToken = process.env.RIVET_CLOUD_TOKEN; +invariant(rivetCloudToken, "missing RIVET_CLOUD_TOKEN"); +export const RIVET_CLIENT_CONFIG: RivetClientConfig = { + endpoint: RIVET_API_ENDPOINT, + token: rivetCloudToken, +}; + +/** + * Deploy an app to Rivet and return the endpoint + */ +export async function deployToRivet(appPath: string, deployManager: boolean) { + console.log("=== START deployToRivet ==="); + console.log(`Deploying app from path: ${appPath}`); + + // Create a temporary directory for the test + const uuid = crypto.randomUUID(); + const appName = `actor-core-test-${uuid}`; + const tmpDir = path.join(os.tmpdir(), appName); + console.log(`Creating temp directory: ${tmpDir}`); + await fs.mkdir(tmpDir, { recursive: true }); + + // Create package.json with workspace dependencies + const packageJson = { + name: "actor-core-test", + private: true, + version: "1.0.0", + type: "module", + scripts: { + deploy: "actor-core deploy rivet app.ts --env prod", + }, + dependencies: { + "@actor-core/rivet": "workspace:*", + "@actor-core/cli": "workspace:*", + "actor-core": "workspace:*", + }, + packageManager: + "yarn@4.7.0+sha512.5a0afa1d4c1d844b3447ee3319633797bcd6385d9a44be07993ae52ff4facabccafb4af5dcd1c2f9a94ac113e5e9ff56f6130431905884414229e284e37bb7c9", + }; + console.log("Writing package.json"); + await fs.writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify(packageJson, null, 2), + ); + + // Disable PnP + const yarnPnp = "nodeLinker: node-modules"; + console.log("Configuring Yarn nodeLinker"); + await fs.writeFile(path.join(tmpDir, ".yarnrc.yml"), yarnPnp); + + // Get the current workspace root path and link the workspace + const workspaceRoot = path.resolve(__dirname, "../../../.."); + console.log(`Linking workspace from: ${workspaceRoot}`); + + try { + console.log("Running yarn link command..."); + const linkOutput = await execPromise(`yarn link -A ${workspaceRoot}`, { + cwd: tmpDir, + }); + console.log("Yarn link output:", linkOutput.stdout); + } catch (error) { + console.error("Error linking workspace:", error); + throw error; + } + + // Install deps + console.log("Installing dependencies..."); + try { + const installOutput = await execPromise("yarn install", { cwd: tmpDir }); + console.log("Install output:", installOutput.stdout); + } catch (error) { + console.error("Error installing dependencies:", error); + throw error; + } + + // Create app.ts file based on the app path + const appTsContent = `export { app } from "${appPath.replace(/\.ts$/, "")}"`; + console.log(`Creating app.ts with content: ${appTsContent}`); + await fs.writeFile(path.join(tmpDir, "app.ts"), appTsContent); + + // Build and deploy to Rivet using actor-core CLI + console.log("Building and deploying to Rivet..."); + + if (!process.env._RIVET_SKIP_DEPLOY) { + // Deploy using the actor-core CLI + console.log("Spawning @actor-core/cli deploy command..."); + const deployProcess = spawn( + "npx", + [ + "@actor-core/cli", + "deploy", + "rivet", + "app.ts", + "--env", + ENV, + ...(deployManager ? [] : ["--skip-manager"]), + ], + { + cwd: tmpDir, + env: { + ...process.env, + RIVET_ENDPOINT: RIVET_API_ENDPOINT, + RIVET_CLOUD_TOKEN: rivetCloudToken, + _RIVET_MANAGER_LOG_LEVEL: "DEBUG", + _RIVET_ACTOR_LOG_LEVEL: "DEBUG", + //CI: "1", + }, + stdio: "inherit", // Stream output directly to console + }, + ); + + console.log("Waiting for deploy process to complete..."); + await new Promise((resolve, reject) => { + deployProcess.on("exit", (code) => { + console.log(`Deploy process exited with code: ${code}`); + if (code === 0) { + resolve(undefined); + } else { + reject(new Error(`Deploy process exited with code ${code}`)); + } + }); + deployProcess.on("error", (err) => { + console.error("Deploy process error:", err); + reject(err); + }); + }); + console.log("Deploy process completed successfully"); + } + + // Get the endpoint URL + console.log("Getting Rivet endpoint..."); + + // Get the endpoint using the CLI endpoint command + console.log("Spawning @actor-core/cli endpoint command..."); + const endpointProcess = spawn( + "npx", + ["@actor-core/cli", "endpoint", "rivet", "--env", ENV, "--plain"], + { + cwd: tmpDir, + env: { + ...process.env, + RIVET_ENDPOINT: RIVET_API_ENDPOINT, + RIVET_CLOUD_TOKEN: rivetCloudToken, + CI: "1", + }, + stdio: ["inherit", "pipe", "inherit"], // Capture stdout + }, + ); + + // Capture the endpoint + let endpointOutput = ""; + endpointProcess.stdout.on("data", (data) => { + const output = data.toString(); + console.log(`Endpoint output: ${output}`); + endpointOutput += output; + }); + + // Wait for endpoint command to complete + console.log("Waiting for endpoint process to complete..."); + await new Promise((resolve, reject) => { + endpointProcess.on("exit", (code) => { + console.log(`Endpoint process exited with code: ${code}`); + if (code === 0) { + resolve(undefined); + } else { + reject(new Error(`Endpoint command failed with code ${code}`)); + } + }); + endpointProcess.on("error", (err) => { + console.error("Endpoint process error:", err); + reject(err); + }); + }); + + invariant(endpointOutput, "endpoint command returned empty output"); + console.log(`Raw endpoint output: ${endpointOutput}`); + + // Look for something that looks like a URL in the string + const lines = endpointOutput.trim().split("\n"); + const endpoint = lines[lines.length - 1]; + invariant(endpoint, "endpoint not found"); + + console.log("=== END deployToRivet ==="); + + return { + endpoint, + }; +} diff --git a/packages/platforms/rivet/turbo.json b/packages/platforms/rivet/turbo.json index 95960709b..da2495302 100644 --- a/packages/platforms/rivet/turbo.json +++ b/packages/platforms/rivet/turbo.json @@ -1,4 +1,10 @@ { "$schema": "https://turbo.build/schema.json", - "extends": ["//"] + "extends": ["//"], + "tasks": { + "test": { + "dependsOn": ["^build", "check-types", "build", "@actor-core/cli#build"], + "env": ["RIVET_API_ENDPOINT", "RIVET_CLOUD_TOKEN", "_RIVET_SKIP_DEPLOY"] + } + } } diff --git a/packages/platforms/rivet/vitest.config.ts b/packages/platforms/rivet/vitest.config.ts index c7da6b38e..9dbd4e8c9 100644 --- a/packages/platforms/rivet/vitest.config.ts +++ b/packages/platforms/rivet/vitest.config.ts @@ -1,8 +1,9 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig } from "vitest/config"; export default defineConfig({ - test: { - globals: true, - environment: 'node', - }, -}); \ No newline at end of file + test: { + globals: true, + environment: "node", + testTimeout: 60_000, + }, +}); diff --git a/turbo.json b/turbo.json index 9e9446a6c..7d236df3c 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,4 @@ -{ - "$schema": "https://turbo.build/schema.json", +{ "$schema": "https://turbo.build/schema.json", "tasks": { "//#fmt": { "cache": false diff --git a/vitest.base.ts b/vitest.base.ts index 5c50b5c0a..f69bd00ea 100644 --- a/vitest.base.ts +++ b/vitest.base.ts @@ -6,11 +6,10 @@ export default { sequence: { concurrent: true, }, - // Increase timeout - testTimeout: 5_000, env: { - // Enable loggin - _LOG_LEVEL: "DEBUG" + // Enable logging + _LOG_LEVEL: "DEBUG", + _ACTOR_CORE_ERROR_STACK: "1" } }, } satisfies ViteUserConfig; diff --git a/yarn.lock b/yarn.lock index 13971b43d..71a3177d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -35,6 +35,7 @@ __metadata: "@sentry/esbuild-plugin": "npm:^3.2.0" "@sentry/node": "npm:^9.3.0" "@sentry/profiling-node": "npm:^9.3.0" + "@types/invariant": "npm:^2" "@types/micromatch": "npm:^4" "@types/react": "npm:^18.3" "@types/semver": "npm:^7.5.8" @@ -49,6 +50,7 @@ __metadata: ink-gradient: "npm:^3.0.0" ink-link: "npm:^4.1.0" ink-spinner: "npm:^5.0.0" + invariant: "npm:^2.2.4" micromatch: "npm:^4.0.8" open: "npm:^10.1.0" pkg-types: "npm:^2.0.0" @@ -73,7 +75,6 @@ __metadata: version: 0.0.0-use.local resolution: "@actor-core/cloudflare-workers@workspace:packages/platforms/cloudflare-workers" dependencies: - "@actor-core/driver-test-suite": "workspace:*" "@cloudflare/workers-types": "npm:^4.20250129.0" "@types/invariant": "npm:^2" actor-core: "workspace:*" @@ -89,28 +90,10 @@ __metadata: languageName: unknown linkType: soft -"@actor-core/driver-test-suite@workspace:*, @actor-core/driver-test-suite@workspace:packages/misc/driver-test-suite": - version: 0.0.0-use.local - resolution: "@actor-core/driver-test-suite@workspace:packages/misc/driver-test-suite" - dependencies: - "@hono/node-server": "npm:^1.14.0" - "@hono/node-ws": "npm:^1.1.1" - "@types/node": "npm:^22.13.1" - actor-core: "workspace:*" - bundle-require: "npm:^5.1.0" - tsup: "npm:^8.4.0" - typescript: "npm:^5.7.3" - vitest: "npm:^3.1.1" - peerDependencies: - actor-core: "workspace:*" - languageName: unknown - linkType: soft - "@actor-core/file-system@workspace:*, @actor-core/file-system@workspace:^, @actor-core/file-system@workspace:packages/drivers/file-system": version: 0.0.0-use.local resolution: "@actor-core/file-system@workspace:packages/drivers/file-system" dependencies: - "@actor-core/driver-test-suite": "workspace:*" "@types/invariant": "npm:^2" "@types/node": "npm:^22.14.0" actor-core: "workspace:*" @@ -142,7 +125,6 @@ __metadata: version: 0.0.0-use.local resolution: "@actor-core/memory@workspace:packages/drivers/memory" dependencies: - "@actor-core/driver-test-suite": "workspace:*" "@types/node": "npm:^22.13.1" actor-core: "workspace:*" hono: "npm:^4.7.0" @@ -194,7 +176,6 @@ __metadata: version: 0.0.0-use.local resolution: "@actor-core/redis@workspace:packages/drivers/redis" dependencies: - "@actor-core/driver-test-suite": "workspace:*" "@types/node": "npm:^22.13.1" actor-core: "workspace:*" dedent: "npm:^1.5.3" @@ -214,12 +195,13 @@ __metadata: version: 0.0.0-use.local resolution: "@actor-core/rivet@workspace:packages/platforms/rivet" dependencies: - "@actor-core/driver-test-suite": "workspace:*" "@rivet-gg/actor-core": "npm:^25.1.0" "@types/deno": "npm:^2.0.0" + "@types/invariant": "npm:^2" "@types/node": "npm:^22.13.1" actor-core: "workspace:*" hono: "npm:^4.7.0" + invariant: "npm:^2.2.4" tsup: "npm:^8.4.0" typescript: "npm:^5.5.2" vitest: "npm:^3.1.1" @@ -330,6 +312,17 @@ __metadata: languageName: node linkType: hard +"@asteasolutions/zod-to-openapi@npm:^7.3.0": + version: 7.3.0 + resolution: "@asteasolutions/zod-to-openapi@npm:7.3.0" + dependencies: + openapi3-ts: "npm:^4.1.2" + peerDependencies: + zod: ^3.20.2 + checksum: 10c0/f0a68a89929cdeaa3e21d2027489689f982824d676a9332c680e119f60881dd39b571324b24ad4837fda49bf6fe7c3e2af2199268b281bf1aec923d7a7cbfc40 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.26.2": version: 7.26.2 resolution: "@babel/code-frame@npm:7.26.2" @@ -810,6 +803,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/aix-ppc64@npm:0.25.4" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/android-arm64@npm:0.17.19" @@ -831,6 +831,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/android-arm64@npm:0.25.4" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/android-arm@npm:0.17.19" @@ -852,6 +859,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/android-arm@npm:0.25.4" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/android-x64@npm:0.17.19" @@ -873,6 +887,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/android-x64@npm:0.25.4" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/darwin-arm64@npm:0.17.19" @@ -894,6 +915,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/darwin-arm64@npm:0.25.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/darwin-x64@npm:0.17.19" @@ -915,6 +943,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/darwin-x64@npm:0.25.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/freebsd-arm64@npm:0.17.19" @@ -936,6 +971,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/freebsd-arm64@npm:0.25.4" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/freebsd-x64@npm:0.17.19" @@ -957,6 +999,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/freebsd-x64@npm:0.25.4" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-arm64@npm:0.17.19" @@ -978,6 +1027,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/linux-arm64@npm:0.25.4" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-arm@npm:0.17.19" @@ -999,6 +1055,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/linux-arm@npm:0.25.4" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-ia32@npm:0.17.19" @@ -1020,6 +1083,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/linux-ia32@npm:0.25.4" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-loong64@npm:0.17.19" @@ -1041,6 +1111,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/linux-loong64@npm:0.25.4" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-mips64el@npm:0.17.19" @@ -1062,6 +1139,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/linux-mips64el@npm:0.25.4" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-ppc64@npm:0.17.19" @@ -1083,6 +1167,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/linux-ppc64@npm:0.25.4" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-riscv64@npm:0.17.19" @@ -1104,6 +1195,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/linux-riscv64@npm:0.25.4" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-s390x@npm:0.17.19" @@ -1125,6 +1223,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/linux-s390x@npm:0.25.4" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-x64@npm:0.17.19" @@ -1146,6 +1251,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/linux-x64@npm:0.25.4" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-arm64@npm:0.25.2": version: 0.25.2 resolution: "@esbuild/netbsd-arm64@npm:0.25.2" @@ -1153,6 +1265,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-arm64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/netbsd-arm64@npm:0.25.4" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/netbsd-x64@npm:0.17.19" @@ -1174,6 +1293,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/netbsd-x64@npm:0.25.4" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.25.2": version: 0.25.2 resolution: "@esbuild/openbsd-arm64@npm:0.25.2" @@ -1181,6 +1307,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/openbsd-arm64@npm:0.25.4" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/openbsd-x64@npm:0.17.19" @@ -1202,6 +1335,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/openbsd-x64@npm:0.25.4" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/sunos-x64@npm:0.17.19" @@ -1223,6 +1363,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/sunos-x64@npm:0.25.4" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/win32-arm64@npm:0.17.19" @@ -1244,6 +1391,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/win32-arm64@npm:0.25.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/win32-ia32@npm:0.17.19" @@ -1265,6 +1419,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/win32-ia32@npm:0.25.4" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/win32-x64@npm:0.17.19" @@ -1286,6 +1447,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.25.4": + version: 0.25.4 + resolution: "@esbuild/win32-x64@npm:0.25.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@fastify/busboy@npm:^2.0.0": version: 2.1.1 resolution: "@fastify/busboy@npm:2.1.1" @@ -1356,6 +1524,29 @@ __metadata: languageName: node linkType: hard +"@hono/zod-openapi@npm:^0.19.6": + version: 0.19.6 + resolution: "@hono/zod-openapi@npm:0.19.6" + dependencies: + "@asteasolutions/zod-to-openapi": "npm:^7.3.0" + "@hono/zod-validator": "npm:^0.5.0" + peerDependencies: + hono: ">=4.3.6" + zod: 3.* + checksum: 10c0/3a3313ba56f68829616263c25841633ff9390f2c5bedd16f454a46a12c98ab1dea708bb3a9dc9810a5fe33e291bc4e9c6d312c44cb2f24dd4e53934aebd3a724 + languageName: node + linkType: hard + +"@hono/zod-validator@npm:^0.5.0": + version: 0.5.0 + resolution: "@hono/zod-validator@npm:0.5.0" + peerDependencies: + hono: ">=3.9.0" + zod: ^3.19.1 + checksum: 10c0/fe941a6ea6fb9e05ed4fc6f4e00aa8ca3bf34e582b5a79dcdbeae965927eaf944f1894e7bae2d9f16e17a57f94f8432d2811f34397254af1c454f3efd798a093 + languageName: node + linkType: hard + "@img/sharp-darwin-arm64@npm:0.33.5": version: 0.33.5 resolution: "@img/sharp-darwin-arm64@npm:0.33.5" @@ -3291,9 +3482,13 @@ __metadata: version: 0.0.0-use.local resolution: "actor-core@workspace:packages/actor-core" dependencies: + "@hono/node-server": "npm:^1.14.0" + "@hono/node-ws": "npm:^1.1.1" + "@hono/zod-openapi": "npm:^0.19.6" "@types/invariant": "npm:^2" "@types/node": "npm:^22.13.1" "@types/ws": "npm:^8" + bundle-require: "npm:^5.1.0" cbor-x: "npm:^1.6.0" eventsource: "npm:^3.0.5" hono: "npm:^4.7.0" @@ -3301,6 +3496,7 @@ __metadata: on-change: "npm:^5.0.1" p-retry: "npm:^6.2.1" tsup: "npm:^8.4.0" + tsx: "npm:^4.19.4" typescript: "npm:^5.7.3" vitest: "npm:^3.1.1" ws: "npm:^8.18.1" @@ -5019,6 +5215,92 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:~0.25.0": + version: 0.25.4 + resolution: "esbuild@npm:0.25.4" + dependencies: + "@esbuild/aix-ppc64": "npm:0.25.4" + "@esbuild/android-arm": "npm:0.25.4" + "@esbuild/android-arm64": "npm:0.25.4" + "@esbuild/android-x64": "npm:0.25.4" + "@esbuild/darwin-arm64": "npm:0.25.4" + "@esbuild/darwin-x64": "npm:0.25.4" + "@esbuild/freebsd-arm64": "npm:0.25.4" + "@esbuild/freebsd-x64": "npm:0.25.4" + "@esbuild/linux-arm": "npm:0.25.4" + "@esbuild/linux-arm64": "npm:0.25.4" + "@esbuild/linux-ia32": "npm:0.25.4" + "@esbuild/linux-loong64": "npm:0.25.4" + "@esbuild/linux-mips64el": "npm:0.25.4" + "@esbuild/linux-ppc64": "npm:0.25.4" + "@esbuild/linux-riscv64": "npm:0.25.4" + "@esbuild/linux-s390x": "npm:0.25.4" + "@esbuild/linux-x64": "npm:0.25.4" + "@esbuild/netbsd-arm64": "npm:0.25.4" + "@esbuild/netbsd-x64": "npm:0.25.4" + "@esbuild/openbsd-arm64": "npm:0.25.4" + "@esbuild/openbsd-x64": "npm:0.25.4" + "@esbuild/sunos-x64": "npm:0.25.4" + "@esbuild/win32-arm64": "npm:0.25.4" + "@esbuild/win32-ia32": "npm:0.25.4" + "@esbuild/win32-x64": "npm:0.25.4" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/db9f51248f0560bc46ab219461d338047617f6caf373c95f643b204760bdfa10c95b48cfde948949f7e509599ae4ab61c3f112092a3534936c6abfb800c565b0 + languageName: node + linkType: hard + "escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -5513,6 +5795,15 @@ __metadata: languageName: node linkType: hard +"get-tsconfig@npm:^4.7.5": + version: 4.10.1 + resolution: "get-tsconfig@npm:4.10.1" + dependencies: + resolve-pkg-maps: "npm:^1.0.0" + checksum: 10c0/7f8e3dabc6a49b747920a800fb88e1952fef871cdf51b79e98db48275a5de6cdaf499c55ee67df5fa6fe7ce65f0063e26de0f2e53049b408c585aa74d39ffa21 + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -7138,6 +7429,15 @@ __metadata: languageName: node linkType: hard +"openapi3-ts@npm:^4.1.2": + version: 4.4.0 + resolution: "openapi3-ts@npm:4.4.0" + dependencies: + yaml: "npm:^2.5.0" + checksum: 10c0/900b834279fc8a43c545728ad75ec7c26934ec5344225b60d1e1c0df44d742d7e7379aea18d9034e03031f079d3308ba5a68600682eece3ed41cdbdd10346a9e + languageName: node + linkType: hard + "p-limit@npm:^3.0.2": version: 3.1.0 resolution: "p-limit@npm:3.1.0" @@ -9093,6 +9393,22 @@ __metadata: languageName: node linkType: hard +"tsx@npm:^4.19.4": + version: 4.19.4 + resolution: "tsx@npm:4.19.4" + dependencies: + esbuild: "npm:~0.25.0" + fsevents: "npm:~2.3.3" + get-tsconfig: "npm:^4.7.5" + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 10c0/f7b8d44362343fbde1f2ecc9832d243a450e1168dd09702a545ebe5f699aa6912e45b431a54b885466db414cceda48e5067b36d182027c43b2c02a4f99d8721e + languageName: node + linkType: hard + "turbo-darwin-64@npm:2.5.0": version: 2.5.0 resolution: "turbo-darwin-64@npm:2.5.0" @@ -9910,6 +10226,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.5.0": + version: 2.8.0 + resolution: "yaml@npm:2.8.0" + bin: + yaml: bin.mjs + checksum: 10c0/f6f7310cf7264a8107e72c1376f4de37389945d2fb4656f8060eca83f01d2d703f9d1b925dd8f39852a57034fafefde6225409ddd9f22aebfda16c6141b71858 + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1"